From 0884a2d69cac0c68504d97dbdb80ba0974aa5d9c Mon Sep 17 00:00:00 2001 From: Sauer Date: Fri, 13 Nov 2026 00:39:45 +0100 Subject: [PATCH 01/14] feat(cli): make whk safe for agents, add wait and api, retire the TUI - --json prints the API's own JSON (every field the server sends), not the CLI's parsed types re-encoded, which dropped fields such as retentionDays, layouts and emailFields - Failures carry a stable code and exit status (src/error.rs, listed in whk --help), and under --json come as {"error": {...}} on stderr with the HTTP status, a hint and Retry-After - Nothing reads stdin without a terminal: delete and requests clear stop with confirmation_required (exit 9) unless --yes (--force still works) - whk auth login --json prints the code and link as its first line, and an expired code is an error instead of exit 0 - whk wait blocks until the next matching request or email arrives (--method, --kind, --path, --count, --since, --timeout; exit 8 on timeout) - whk api calls any API route with the CLI's credentials, paths only, so no command is ever missing for an agent - listen prints emails with their subject and every line with its request id, and --json prints the stream's JSON as received; tunnel's forwarded events carry the request id - The full-screen TUI is removed: a bare whk opened it and hung any agent or script without a terminal. A bare whk now prints help. 99 crates fewer. - Docs, README, the agent skill and AGENTS.md describe the new contract and record the "nobody needs the dashboard" rule Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 +- README.md | 27 +- apps/cli-rs/Cargo.lock | 1060 +---------------- apps/cli-rs/Cargo.toml | 5 +- apps/cli-rs/src/api/client.rs | 125 +- apps/cli-rs/src/api/endpoints.rs | 17 +- apps/cli-rs/src/api/mod.rs | 9 +- apps/cli-rs/src/api/requests.rs | 47 +- apps/cli-rs/src/api/stream.rs | 9 +- apps/cli-rs/src/api/teams.rs | 23 +- apps/cli-rs/src/cli/api.rs | 144 +++ apps/cli-rs/src/cli/auth.rs | 25 +- apps/cli-rs/src/cli/confirm.rs | 76 ++ apps/cli-rs/src/cli/endpoints.rs | 43 +- apps/cli-rs/src/cli/listen.rs | 60 +- apps/cli-rs/src/cli/mod.rs | 111 +- apps/cli-rs/src/cli/requests.rs | 29 +- apps/cli-rs/src/cli/share.rs | 13 +- apps/cli-rs/src/cli/teams.rs | 8 +- apps/cli-rs/src/cli/tunnel.rs | 9 +- apps/cli-rs/src/cli/usage.rs | 2 +- apps/cli-rs/src/cli/wait.rs | 305 +++++ apps/cli-rs/src/error.rs | 245 ++++ apps/cli-rs/src/lib.rs | 2 +- apps/cli-rs/src/main.rs | 85 +- apps/cli-rs/src/tui/event.rs | 43 - apps/cli-rs/src/tui/keys.rs | 95 -- apps/cli-rs/src/tui/mod.rs | 300 ----- apps/cli-rs/src/tui/screens/auth.rs | 398 ------- .../cli-rs/src/tui/screens/endpoint_detail.rs | 242 ---- apps/cli-rs/src/tui/screens/endpoints.rs | 385 ------ apps/cli-rs/src/tui/screens/listen.rs | 333 ------ apps/cli-rs/src/tui/screens/menu.rs | 269 ----- apps/cli-rs/src/tui/screens/mod.rs | 118 -- apps/cli-rs/src/tui/screens/request_detail.rs | 475 -------- apps/cli-rs/src/tui/screens/search.rs | 471 -------- apps/cli-rs/src/tui/screens/send.rs | 348 ------ apps/cli-rs/src/tui/screens/tunnel.rs | 388 ------ apps/cli-rs/src/tui/screens/update.rs | 268 ----- apps/cli-rs/src/tui/screens/usage.rs | 489 -------- apps/cli-rs/src/tui/theme.rs | 73 -- apps/cli-rs/src/tui/widgets/header.rs | 80 -- apps/cli-rs/src/tui/widgets/mod.rs | 4 - apps/cli-rs/src/tui/widgets/request_list.rs | 194 --- apps/cli-rs/src/tui/widgets/spinner.rs | 36 - apps/cli-rs/src/tui/widgets/status_bar.rs | 68 -- apps/cli-rs/src/types/mod.rs | 25 +- apps/cli-rs/tests/integration.rs | 2 +- .../changelog/web/cli-for-agents-docs.json | 7 + content/docs/cli/commands.mdx | 73 +- content/docs/cli/index.mdx | 71 +- content/docs/cli/tunnel.mdx | 24 +- .../skills/webhook-testing/SKILL.md | 8 +- 53 files changed, 1416 insertions(+), 6354 deletions(-) create mode 100644 apps/cli-rs/src/cli/api.rs create mode 100644 apps/cli-rs/src/cli/confirm.rs create mode 100644 apps/cli-rs/src/cli/wait.rs create mode 100644 apps/cli-rs/src/error.rs delete mode 100644 apps/cli-rs/src/tui/event.rs delete mode 100644 apps/cli-rs/src/tui/keys.rs delete mode 100644 apps/cli-rs/src/tui/mod.rs delete mode 100644 apps/cli-rs/src/tui/screens/auth.rs delete mode 100644 apps/cli-rs/src/tui/screens/endpoint_detail.rs delete mode 100644 apps/cli-rs/src/tui/screens/endpoints.rs delete mode 100644 apps/cli-rs/src/tui/screens/listen.rs delete mode 100644 apps/cli-rs/src/tui/screens/menu.rs delete mode 100644 apps/cli-rs/src/tui/screens/mod.rs delete mode 100644 apps/cli-rs/src/tui/screens/request_detail.rs delete mode 100644 apps/cli-rs/src/tui/screens/search.rs delete mode 100644 apps/cli-rs/src/tui/screens/send.rs delete mode 100644 apps/cli-rs/src/tui/screens/tunnel.rs delete mode 100644 apps/cli-rs/src/tui/screens/update.rs delete mode 100644 apps/cli-rs/src/tui/screens/usage.rs delete mode 100644 apps/cli-rs/src/tui/theme.rs delete mode 100644 apps/cli-rs/src/tui/widgets/header.rs delete mode 100644 apps/cli-rs/src/tui/widgets/mod.rs delete mode 100644 apps/cli-rs/src/tui/widgets/request_list.rs delete mode 100644 apps/cli-rs/src/tui/widgets/spinner.rs delete mode 100644 apps/cli-rs/src/tui/widgets/status_bar.rs create mode 100644 apps/web/changelog/web/cli-for-agents-docs.json diff --git a/AGENTS.md b/AGENTS.md index c753c614a..504b5139e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ Production: `https://webhooks.cc` (app) and `https://go.webhooks.cc` (webhook re | `content/docs/` | MDX source for `/docs/*`, rendered by the catch-all route in `apps/web/app/docs/`. | | `apps/receiver-rs/` | Rust (Axum, Tokio, sqlx) webhook receiver. Captures at `/w/{slug}`. Env vars are read in `src/config.rs`. | | `apps/mx-rs/` | Rust (Tokio) receive-only SMTP host for email capture on `mailhooks.cc`. Env vars are read in `src/config.rs`. | -| `apps/cli-rs/` | Rust CLI `whk` (Clap, Ratatui). Subcommands live in `src/cli/`. | +| `apps/cli-rs/` | Rust CLI `whk` (Clap). Subcommands live in `src/cli/`; exit statuses and error codes in `src/error.rs`. | | `apps/edge-rs/` | Rust (Tokio, hyper, yamux) tunnel edge: serves `whk share` previews on sharehooks.cc and relays `whk tunnel --live` captures. Env vars are read in `src/config.rs`. | | `crates/tunnel/` | `whk-tunnel`, the tunnel protocol (WebSocket, yamux, splice) shared by the edge and the CLI. | | `apps/parser-rs/` | Rust attachment parser `webhooks-parser` (CSV, TSV, XLSX, XLS, ODS, PDF text, text): a sandboxed child process of the web app's parse worker. | @@ -86,6 +86,8 @@ Capture path: a sender POSTs to `go.webhooks.cc/w/{slug}/...`. The receiver vali Decisions worth knowing, with the reasons: +- **Nobody needs the dashboard.** Every user-facing capability works from the API, the CLI, the SDKs and the MCP server, because people increasingly work through coding agents, and a step that needs a dashboard stops the agent. Signup and payment are the exceptions. Where a person has to decide (a sensitive change, a payment), the agent asks and the person approves through a link or a code, never by doing the work in the dashboard. The dashboard is a view, never the only way. +- **`whk` serves agents as well as people.** `--json` prints the API's own JSON (`Fetched` in `api/client.rs` keeps it beside the parsed value, so fields the CLI does not know still reach the caller), failures carry a code and a stable exit status (`src/error.rs`, listed in `whk --help` and `/docs/cli`), nothing reads stdin without a terminal (`cli/confirm.rs`: `--yes` instead), `whk wait` exits on the next arrival, and `whk api` reaches any route a command does not cover. There is no full-screen TUI: a bare `whk` opened one and hung any agent that ran it, so it was retired (November 2026) for one-line and NDJSON output from `listen` and `tunnel`. - **The receiver talks to Postgres directly** (`DATABASE_URL`), not through the web app. One fewer hop on the hot path, and the stored procedure keeps quota and counters atomic. In production it connects to Postgres on the same Docker network rather than through Supavisor: on one host the pooler only costs CPU, and a load test sustained about twice the throughput without it. - **DB failures are classified.** Transient errors (pool timeout, connection loss, SQLSTATE classes 08/40/53/57/58) return 503 with `Retry-After: 5` so senders retry; at-least-once delivery beats silent loss. Permanent errors fail open with 200 and are logged and counted in the receiver's once-a-minute `counts` log line (`capture_failed.`). NUL bytes in bodies and paths are sanitised (raw bytes kept in `body_raw`) instead of failing the insert. - **RLS is deny-by-default for client roles.** Anonymous users cannot read endpoints, requests, or device codes, and may only read published blog posts. Guest dashboard reads and guest endpoint creation go through server routes using the service role. Client roles have no write access to `users`, `endpoints`, or `api_keys` (migrations 00037 and 00045; every write goes through a server route that validates, rate-limits, and audits it) and no EXECUTE on `public` functions unless a migration grants it (00037 revoked them and reset the default privileges). Team members can read shared endpoints through `can_view_team_endpoint()`, which also lets them join the endpoint's Realtime topic. diff --git a/README.md b/README.md index af1c33100..b0fe12e52 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ curl -X POST https://go.webhooks.cc/w/ -d '{"test": true}' - **Verify signatures**: check provider signatures (Stripe, GitHub, Shopify and more) on captured requests - **Forward**: tunnel webhooks to localhost during development (with `--live`, the sender gets your local app's answer), or forward them to other URLs, chat and file drops - **Share localhost**: put the site on your machine at a public link on sharehooks.cc with `whk share` -- **CLI with interactive TUI**: manage endpoints, tunnel and stream requests from your terminal +- **CLI for people and agents**: manage endpoints, tunnel, stream and wait for requests from your terminal, with JSON output and stable exit codes for scripts and coding agents - **SDKs for TypeScript, Python and Go**: wait for and assert on webhooks in tests and CI, and share a local port from code - **pytest plugin**: `pip install pytest-webhooks-cc` gives every test a capture URL and an email address as fixtures - **GitHub Action**: [`setup-whk`](https://github.com/marketplace/actions/setup-whk) installs the CLI in a workflow @@ -124,35 +124,18 @@ See [webhooks.cc/installation](https://webhooks.cc/installation) for all install ## CLI -Run `whk` to launch the interactive TUI: - -``` -$ whk - - webhooks.cc - - ● Logged in as you@example.com - - ▸ Tunnel Forward webhooks to localhost - Listen Stream incoming requests - Create Create a new endpoint - Endpoints Manage your endpoints - Auth Login / logout - Update Check for updates -``` - -All commands also work directly from the command line: - ```bash whk tunnel 8080 # Forward webhooks to localhost:8080 whk share 3000 # Share localhost:3000 at https://.sharehooks.cc -whk listen # Stream incoming requests +whk listen # Stream incoming requests, one line each +whk wait --json # Block until the next request arrives, print it, exit whk create my-endpoint # Create a new endpoint whk list # List your endpoints whk replay --to :3000 # Replay a captured request +whk api endpoints//destinations # Call any API route with your credentials ``` -Pass `--nogui` or set `WHK_NOGUI=1` to skip the TUI and print help. +Every command takes `--json` (the API's own answers, NDJSON for streams, failures as `{"error": {...}}` on stderr) and exits with a stable status that says what went wrong; see `whk --help`. Nothing waits for input without a terminal: destructive commands need `--yes` there. ## SDK diff --git a/apps/cli-rs/Cargo.lock b/apps/cli-rs/Cargo.lock index c726ce81a..684ef0b2d 100644 --- a/apps/cli-rs/Cargo.lock +++ b/apps/cli-rs/Cargo.lock @@ -8,15 +8,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - [[package]] name = "allocator-api2" version = "0.2.21" @@ -88,24 +79,6 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" -[[package]] -name = "approx" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" -dependencies = [ - "num-traits", -] - -[[package]] -name = "atomic" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" -dependencies = [ - "bytemuck", -] - [[package]] name = "atomic-waker" version = "1.1.2" @@ -182,42 +155,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" -[[package]] -name = "bit-set" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - [[package]] name = "block-buffer" version = "0.12.0" @@ -233,12 +176,6 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" -[[package]] -name = "by_address" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" - [[package]] name = "bytemuck" version = "1.25.0" @@ -257,15 +194,6 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" -[[package]] -name = "castaway" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" -dependencies = [ - "rustversion", -] - [[package]] name = "cc" version = "1.2.58" @@ -295,8 +223,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.0", + "cpufeatures", + "rand_core", ] [[package]] @@ -368,20 +296,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" -[[package]] -name = "compact_str" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "static_assertions", -] - [[package]] name = "const-oid" version = "0.10.2" @@ -403,15 +317,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - [[package]] name = "cpufeatures" version = "0.3.0" @@ -430,19 +335,13 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "critical-section" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" - [[package]] name = "crossterm" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.13.0", + "bitflags", "crossterm_winapi", "derive_more", "document-features", @@ -463,16 +362,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - [[package]] name = "crypto-common" version = "0.2.1" @@ -482,71 +371,12 @@ dependencies = [ "hybrid-array", ] -[[package]] -name = "csscolorparser" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" -dependencies = [ - "lab", - "phf", -] - -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.117", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core", - "quote", - "syn 2.0.117", -] - [[package]] name = "data-encoding" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" -[[package]] -name = "deltae" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] - [[package]] name = "derive_more" version = "2.1.1" @@ -569,25 +399,15 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", -] - [[package]] name = "digest" version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" dependencies = [ - "block-buffer 0.12.0", + "block-buffer", "const-oid", - "crypto-common 0.2.1", + "crypto-common", ] [[package]] @@ -631,12 +451,6 @@ dependencies = [ "litrs", ] -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - [[package]] name = "equivalent" version = "1.0.2" @@ -653,42 +467,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "euclid" -version = "0.22.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" -dependencies = [ - "num-traits", -] - -[[package]] -name = "fancy-regex" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" -dependencies = [ - "bit-set", - "regex", -] - -[[package]] -name = "fast-srgb8" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" - -[[package]] -name = "filedescriptor" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" -dependencies = [ - "libc", - "thiserror 1.0.69", - "winapi", -] - [[package]] name = "filetime" version = "0.2.27" @@ -706,18 +484,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "finl_unicode" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" - -[[package]] -name = "fixedbitset" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" - [[package]] name = "flate2" version = "1.1.11" @@ -729,12 +495,6 @@ dependencies = [ "zlib-rs", ] -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.1.5" @@ -872,16 +632,6 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "312d2295c7302019c395cfb90dacd00a82a2eabd700429bba9c7a3f38dbbe11b" -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "gethostname" version = "1.1.0" @@ -905,18 +655,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - [[package]] name = "getrandom" version = "0.4.2" @@ -926,8 +664,8 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi 6.0.0", - "rand_core 0.10.0", + "r-efi", + "rand_core", "wasip2", "wasip3", "wasm-bindgen", @@ -947,11 +685,6 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", -] [[package]] name = "hashbrown" @@ -1203,12 +936,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - [[package]] name = "idna" version = "1.1.0" @@ -1254,28 +981,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "indoc" -version = "2.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" -dependencies = [ - "rustversion", -] - -[[package]] -name = "instability" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" -dependencies = [ - "darling", - "indoc", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "ipnet" version = "2.12.0" @@ -1317,15 +1022,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.18" @@ -1344,29 +1040,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "kasuari" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" -dependencies = [ - "hashbrown 0.16.1", - "portable-atomic", - "thiserror 2.0.21", -] - -[[package]] -name = "lab" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "leb128fmt" version = "0.1.0" @@ -1379,33 +1052,18 @@ version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - [[package]] name = "libredox" version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ - "bitflags 2.13.0", + "bitflags", "libc", "plain", "redox_syscall 0.7.3", ] -[[package]] -name = "line-clipping" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" -dependencies = [ - "bitflags 2.13.0", -] - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1454,16 +1112,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "mac_address" -version = "1.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" -dependencies = [ - "nix", - "winapi", -] - [[package]] name = "matchit" version = "0.8.4" @@ -1476,33 +1124,12 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" -[[package]] -name = "memmem" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - [[package]] name = "mime" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "miniz_oxide" version = "0.9.1" @@ -1536,51 +1163,11 @@ dependencies = [ ] [[package]] -name = "nix" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" -dependencies = [ - "bitflags 2.13.0", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset", -] - -[[package]] -name = "nohash-hasher" -version = "0.2.0" +name = "nohash-hasher" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "num-conv" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" - -[[package]] -name = "num-derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -1590,15 +1177,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "num_threads" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" -dependencies = [ - "libc", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -1627,39 +1205,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "ordered-float" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" -dependencies = [ - "num-traits", -] - -[[package]] -name = "palette" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" -dependencies = [ - "approx", - "fast-srgb8", - "libm", - "palette_derive", -] - -[[package]] -name = "palette_derive" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" -dependencies = [ - "by_address", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "parking_lot" version = "0.12.5" @@ -1689,101 +1234,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "pest" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" -dependencies = [ - "memchr", - "ucd-trie", -] - -[[package]] -name = "pest_derive" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" -dependencies = [ - "pest", - "pest_generator", -] - -[[package]] -name = "pest_generator" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" -dependencies = [ - "pest", - "pest_meta", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "pest_meta" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" -dependencies = [ - "pest", - "sha2 0.10.9", -] - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros", - "phf_shared", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator", - "phf_shared", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared", - "rand 0.8.6", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", -] - [[package]] name = "pin-project" version = "1.1.13" @@ -1816,12 +1266,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - [[package]] name = "potential_utf" version = "0.1.5" @@ -1831,12 +1275,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - [[package]] name = "prettyplease" version = "0.2.37" @@ -1882,7 +1320,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.21", + "thiserror", "tokio", "tracing", "web-time", @@ -1897,14 +1335,14 @@ dependencies = [ "bytes", "getrandom 0.4.2", "lru-slab", - "rand 0.10.3", + "rand", "rand_pcg", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.21", + "thiserror", "tinyvec", "tracing", "web-time", @@ -1933,27 +1371,12 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "rand_core 0.6.4", -] - [[package]] name = "rand" version = "0.10.3" @@ -1962,15 +1385,9 @@ checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", "getrandom 0.4.2", - "rand_core 0.10.0", + "rand_core", ] -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" - [[package]] name = "rand_core" version = "0.10.0" @@ -1983,108 +1400,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core 0.10.0", -] - -[[package]] -name = "ratatui" -version = "0.30.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" -dependencies = [ - "instability", - "ratatui-core", - "ratatui-crossterm", - "ratatui-macros", - "ratatui-termina", - "ratatui-termwiz", - "ratatui-widgets", - "serde", -] - -[[package]] -name = "ratatui-core" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" -dependencies = [ - "bitflags 2.13.0", - "compact_str", - "critical-section", - "hashbrown 0.17.1", - "itertools", - "kasuari", - "lru", - "palette", - "serde", - "strum", - "thiserror 2.0.21", - "unicode-segmentation", - "unicode-truncate", - "unicode-width", -] - -[[package]] -name = "ratatui-crossterm" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" -dependencies = [ - "cfg-if", - "crossterm", - "instability", - "ratatui-core", -] - -[[package]] -name = "ratatui-macros" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" -dependencies = [ - "ratatui-core", - "ratatui-widgets", -] - -[[package]] -name = "ratatui-termina" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" -dependencies = [ - "instability", - "ratatui-core", - "termina", -] - -[[package]] -name = "ratatui-termwiz" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" -dependencies = [ - "ratatui-core", - "termwiz", -] - -[[package]] -name = "ratatui-widgets" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" -dependencies = [ - "bitflags 2.13.0", - "hashbrown 0.17.1", - "indoc", - "instability", - "itertools", - "line-clipping", - "ratatui-core", - "serde", - "strum", - "time", - "unicode-segmentation", - "unicode-width", + "rand_core", ] [[package]] @@ -2093,7 +1409,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.0", + "bitflags", ] [[package]] @@ -2102,7 +1418,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" dependencies = [ - "bitflags 2.13.0", + "bitflags", ] [[package]] @@ -2113,38 +1429,9 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.21", + "thiserror", ] -[[package]] -name = "regex" -version = "1.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" - [[package]] name = "reqwest" version = "0.12.28" @@ -2253,7 +1540,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.0", + "bitflags", "errno", "libc", "linux-raw-sys", @@ -2392,19 +1679,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.2", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", + "cpufeatures", + "digest", ] [[package]] @@ -2414,8 +1690,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.2", + "cpufeatures", + "digest", ] [[package]] @@ -2461,12 +1737,6 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" -[[package]] -name = "siphasher" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" - [[package]] name = "slab" version = "0.4.12" @@ -2507,44 +1777,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.117" @@ -2598,109 +1836,13 @@ dependencies = [ "xattr", ] -[[package]] -name = "termina" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" -dependencies = [ - "bitflags 2.13.0", - "parking_lot", - "rustix", - "signal-hook", - "windows-sys 0.61.2", -] - -[[package]] -name = "terminfo" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" -dependencies = [ - "fnv", - "nom", - "phf", - "phf_codegen", -] - -[[package]] -name = "termios" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" -dependencies = [ - "libc", -] - -[[package]] -name = "termwiz" -version = "0.23.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" -dependencies = [ - "anyhow", - "base64 0.22.1", - "bitflags 2.13.0", - "fancy-regex", - "filedescriptor", - "finl_unicode", - "fixedbitset", - "hex", - "lazy_static", - "libc", - "log", - "memmem", - "nix", - "num-derive", - "num-traits", - "ordered-float", - "pest", - "pest_derive", - "phf", - "sha2 0.10.9", - "signal-hook", - "siphasher", - "terminfo", - "termios", - "thiserror 1.0.69", - "ucd-trie", - "unicode-segmentation", - "vtparse", - "wezterm-bidi", - "wezterm-blob-leases", - "wezterm-color-types", - "wezterm-dynamic", - "wezterm-input-types", - "winapi", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - [[package]] name = "thiserror" version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.21", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "thiserror-impl", ] [[package]] @@ -2714,27 +1856,6 @@ dependencies = [ "syn 3.0.2", ] -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "libc", - "num-conv", - "num_threads", - "powerfmt", - "serde_core", - "time-core", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - [[package]] name = "tinystr" version = "0.8.3" @@ -2861,7 +1982,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.13.0", + "bitflags", "bytes", "futures-util", "http", @@ -2922,11 +2043,11 @@ dependencies = [ "http", "httparse", "log", - "rand 0.10.3", + "rand", "rustls", "rustls-pki-types", "sha1", - "thiserror 2.0.21", + "thiserror", ] [[package]] @@ -2935,12 +2056,6 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" -[[package]] -name = "ucd-trie" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" - [[package]] name = "unicode-ident" version = "1.0.24" @@ -2953,17 +2068,6 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" -[[package]] -name = "unicode-truncate" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" -dependencies = [ - "itertools", - "unicode-segmentation", - "unicode-width", -] - [[package]] name = "unicode-width" version = "0.2.3" @@ -3012,33 +2116,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "uuid" -version = "1.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" -dependencies = [ - "atomic", - "getrandom 0.4.2", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "vtparse" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" -dependencies = [ - "utf8parse", -] - [[package]] name = "want" version = "0.3.1" @@ -3168,7 +2245,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.13.0", + "bitflags", "hashbrown 0.15.5", "indexmap", "semver", @@ -3212,78 +2289,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "wezterm-bidi" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" -dependencies = [ - "log", - "wezterm-dynamic", -] - -[[package]] -name = "wezterm-blob-leases" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" -dependencies = [ - "getrandom 0.3.4", - "mac_address", - "sha2 0.10.9", - "thiserror 1.0.69", - "uuid", -] - -[[package]] -name = "wezterm-color-types" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" -dependencies = [ - "csscolorparser", - "deltae", - "lazy_static", - "wezterm-dynamic", -] - -[[package]] -name = "wezterm-dynamic" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" -dependencies = [ - "log", - "ordered-float", - "strsim", - "thiserror 1.0.69", - "wezterm-dynamic-derive", -] - -[[package]] -name = "wezterm-dynamic-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "wezterm-input-types" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" -dependencies = [ - "bitflags 1.3.2", - "euclid", - "lazy_static", - "serde", - "wezterm-dynamic", -] - [[package]] name = "whk" version = "0.1.0" @@ -3302,8 +2307,7 @@ dependencies = [ "hex", "open", "qrcode", - "rand 0.10.3", - "ratatui", + "rand", "reqwest", "rpassword", "rqrr", @@ -3311,9 +2315,9 @@ dependencies = [ "semver", "serde", "serde_json", - "sha2 0.11.1", + "sha2", "tar", - "thiserror 2.0.21", + "thiserror", "tokio", "tokio-stream", "tokio-tungstenite", @@ -3632,7 +2636,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.13.0", + "bitflags", "indexmap", "log", "serde", diff --git a/apps/cli-rs/Cargo.toml b/apps/cli-rs/Cargo.toml index 09f8bda91..77ec34a20 100644 --- a/apps/cli-rs/Cargo.toml +++ b/apps/cli-rs/Cargo.toml @@ -2,7 +2,7 @@ name = "whk" version = "0.1.0" edition = "2024" -description = "CLI and TUI for webhooks.cc — inspect, forward, and replay webhooks" +description = "The webhooks.cc CLI: capture, inspect, forward and replay webhooks and email, share localhost, for people and coding agents" license = "MIT" repository = "https://github.com/kroqdotdev/webhooks-cc" @@ -15,8 +15,7 @@ path = "src/main.rs" clap = { version = "4", features = ["derive", "env", "string"] } clap_complete = "4" -# TUI -ratatui = "0.30" +# Terminal width for wrapped output crossterm = "0.29" # Async diff --git a/apps/cli-rs/src/api/client.rs b/apps/cli-rs/src/api/client.rs index d4acbb1e7..fc36789d6 100644 --- a/apps/cli-rs/src/api/client.rs +++ b/apps/cli-rs/src/api/client.rs @@ -1,13 +1,57 @@ use anyhow::{Context, Result}; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue, RETRY_AFTER}; use reqwest::{Method, Response, StatusCode}; +use serde::de::DeserializeOwned; +use serde_json::Value; use super::{ApiClient, api_error_message}; +use crate::error::ApiError; /// HTTP response with body pre-read. pub struct ApiResponse { pub body: String, } +impl ApiResponse { + /// The body as `T`, keeping the server's JSON beside it so `--json` + /// prints exactly what the API answered, fields this build does not know + /// included. + pub fn parse(&self, what: &str) -> Result> { + let raw: Value = + serde_json::from_str(&self.body).with_context(|| format!("failed to parse {what}"))?; + let value = serde_json::from_value(raw.clone()) + .with_context(|| format!("failed to parse {what}"))?; + Ok(Fetched { value, raw }) + } +} + +/// A parsed answer and the JSON it came from. Derefs to the parsed value. +#[derive(Debug, Clone)] +pub struct Fetched { + pub value: T, + pub raw: Value, +} + +impl std::ops::Deref for Fetched { + type Target = T; + fn deref(&self) -> &T { + &self.value + } +} + +impl std::ops::DerefMut for Fetched { + fn deref_mut(&mut self) -> &mut T { + &mut self.value + } +} + +/// A raw answer for `whk api`: the status and body whatever they are. +pub struct RawResponse { + pub status: StatusCode, + pub headers: HeaderMap, + pub body: Vec, +} + impl ApiClient { /// Perform a GET request and return the response body. pub async fn get(&self, path: &str) -> Result { @@ -83,6 +127,50 @@ impl ApiClient { read_response(resp).await } + /// Sends any request to an API path and returns the answer whatever its + /// status (`whk api`). `extra` headers are added after the defaults, so + /// they can replace `Content-Type`; `Authorization` and `User-Agent` + /// always stay the CLI's own. + pub async fn send_raw( + &self, + method: Method, + path: &str, + body: Option>, + extra: &[(String, String)], + ) -> Result { + let mut extra_headers = HeaderMap::new(); + for (name, value) in extra { + let name = HeaderName::from_bytes(name.trim().as_bytes()) + .with_context(|| format!("invalid header name: {name}"))?; + if name == reqwest::header::AUTHORIZATION || name == reqwest::header::USER_AGENT { + anyhow::bail!("whk sets the {name} header itself"); + } + let value = HeaderValue::from_str(value.trim()) + .with_context(|| format!("invalid value for header {name}"))?; + extra_headers.insert(name, value); + } + let mut resp = self + .send_once_with(&method, path, &body, false, &extra_headers) + .await?; + if resp.status() == StatusCode::UNAUTHORIZED && self.can_refresh() { + resp = self + .send_once_with(&method, path, &body, true, &extra_headers) + .await?; + } + let status = resp.status(); + let headers = resp.headers().clone(); + let body = resp + .bytes() + .await + .context("failed to read response body")? + .to_vec(); + Ok(RawResponse { + status, + headers, + body, + }) + } + async fn send_once( &self, method: &Method, @@ -90,7 +178,22 @@ impl ApiClient { body: &Option>, refresh: bool, ) -> Result { - let headers = self.headers_with(refresh).await?; + self.send_once_with(method, path, body, refresh, &HeaderMap::new()) + .await + } + + async fn send_once_with( + &self, + method: &Method, + path: &str, + body: &Option>, + refresh: bool, + extra: &HeaderMap, + ) -> Result { + let mut headers = self.headers_with(refresh).await?; + for (name, value) in extra { + headers.insert(name.clone(), value.clone()); + } let mut request = self .http .request(method.clone(), self.url(path)) @@ -104,11 +207,29 @@ impl ApiClient { async fn read_response(resp: Response) -> Result { let status = resp.status(); + let retry_after = retry_after(resp.headers()); let body = resp.text().await.context("failed to read response body")?; if status.is_client_error() || status.is_server_error() { - anyhow::bail!("{}", api_error_message(status, &body)); + return Err(api_error(status, &body, retry_after).into()); } Ok(ApiResponse { body }) } + +/// The error for an answer with a failing status. +pub fn api_error(status: StatusCode, body: &str, retry_after: Option) -> ApiError { + ApiError { + status: status.as_u16(), + message: api_error_message(status, body), + retry_after, + } +} + +/// Seconds from a `Retry-After` header given in seconds. +pub fn retry_after(headers: &HeaderMap) -> Option { + headers + .get(RETRY_AFTER) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.trim().parse().ok()) +} diff --git a/apps/cli-rs/src/api/endpoints.rs b/apps/cli-rs/src/api/endpoints.rs index 7fbbae7f6..faeedb23b 100644 --- a/apps/cli-rs/src/api/endpoints.rs +++ b/apps/cli-rs/src/api/endpoints.rs @@ -1,36 +1,37 @@ use anyhow::{Context, Result}; use super::ApiClient; +use super::client::Fetched; use crate::types::{ CreateEndpointRequest, Endpoint, EndpointList, FavoriteState, UpdateEndpointRequest, }; impl ApiClient { - pub async fn create_endpoint(&self, req: &CreateEndpointRequest) -> Result { + pub async fn create_endpoint(&self, req: &CreateEndpointRequest) -> Result> { self.require_auth()?; let resp = self.post("/api/endpoints", req).await?; - serde_json::from_str(&resp.body).context("failed to parse endpoint") + resp.parse("endpoint") } - pub async fn list_endpoints(&self) -> Result { + pub async fn list_endpoints(&self) -> Result> { self.require_auth()?; let resp = self.get("/api/endpoints").await?; - serde_json::from_str(&resp.body).context("failed to parse endpoint list") + resp.parse("endpoint list") } - pub async fn get_endpoint(&self, slug: &str) -> Result { + pub async fn get_endpoint(&self, slug: &str) -> Result> { self.require_auth()?; let resp = self .get(&format!("/api/endpoints/{}", urlencoding::encode(slug))) .await?; - serde_json::from_str(&resp.body).context("failed to parse endpoint") + resp.parse("endpoint") } pub async fn update_endpoint( &self, slug: &str, req: &UpdateEndpointRequest, - ) -> Result { + ) -> Result> { self.require_auth()?; let resp = self .patch( @@ -38,7 +39,7 @@ impl ApiClient { req, ) .await?; - serde_json::from_str(&resp.body).context("failed to parse endpoint") + resp.parse("endpoint") } pub async fn delete_endpoint(&self, slug: &str) -> Result<()> { diff --git a/apps/cli-rs/src/api/mod.rs b/apps/cli-rs/src/api/mod.rs index c84dc407c..2cbff544a 100644 --- a/apps/cli-rs/src/api/mod.rs +++ b/apps/cli-rs/src/api/mod.rs @@ -298,12 +298,9 @@ impl ApiClient { /// Require auth or return a friendly error. pub fn require_auth(&self) -> Result<()> { if matches!(self.bearer, Bearer::None) { - match ignored_key_hint() { - Some(hint) => anyhow::bail!("Not logged in. {hint}"), - None => anyhow::bail!( - "Not logged in. Run `whk auth login` first, or connect an agent with `whk sandbox connect --email
`." - ), - } + // The next step (log in, or why WHK_API_KEY was ignored) comes + // with the error's hint; see `error::classify`. + return Err(crate::error::CliError::NotLoggedIn("Not logged in.".into()).into()); } Ok(()) } diff --git a/apps/cli-rs/src/api/requests.rs b/apps/cli-rs/src/api/requests.rs index 3a0297775..a31ca2958 100644 --- a/apps/cli-rs/src/api/requests.rs +++ b/apps/cli-rs/src/api/requests.rs @@ -2,6 +2,7 @@ use anyhow::{Context, Result}; use urlencoding::encode; use super::ApiClient; +use super::client::Fetched; use crate::types::{ CapturedRequest, CountResult, EmailFormat, PaginatedRequestList, RenderedEmail, RequestList, SearchResult, @@ -13,7 +14,7 @@ impl ApiClient { slug: &str, limit: Option, since: Option, - ) -> Result { + ) -> Result> { self.require_auth()?; let mut params = vec![]; if let Some(l) = limit { @@ -30,21 +31,41 @@ impl ApiClient { let resp = self .get(&format!("/api/endpoints/{}/requests{qs}", encode(slug))) .await?; - // API returns a bare array - let requests: Vec = - serde_json::from_str(&resp.body).context("failed to parse request list")?; - Ok(RequestList { - count: None, - requests, + // API returns a bare array; --json prints it as {"requests": [...]} + // as it always has. + let list = resp.parse::>("request list")?; + Ok(Fetched { + value: RequestList { + count: None, + requests: list.value, + }, + raw: serde_json::json!({ "requests": list.raw, "count": null }), }) } + /// The requests received since `since` (ms), as the server sent them. + pub async fn list_requests_raw( + &self, + slug: &str, + limit: u32, + since: i64, + ) -> Result> { + self.require_auth()?; + let resp = self + .get(&format!( + "/api/endpoints/{}/requests?limit={limit}&since={since}", + encode(slug) + )) + .await?; + serde_json::from_str(&resp.body).context("failed to parse request list") + } + pub async fn list_requests_paginated( &self, slug: &str, limit: Option, cursor: Option<&str>, - ) -> Result { + ) -> Result> { self.require_auth()?; let mut params = vec![]; if let Some(l) = limit { @@ -64,15 +85,15 @@ impl ApiClient { encode(slug) )) .await?; - serde_json::from_str(&resp.body).context("failed to parse paginated request list") + resp.parse("paginated request list") } - pub async fn get_request(&self, request_id: &str) -> Result { + pub async fn get_request(&self, request_id: &str) -> Result> { self.require_auth()?; let resp = self .get(&format!("/api/requests/{}", encode(request_id))) .await?; - serde_json::from_str(&resp.body).context("failed to parse request") + resp.parse("request") } /// Render a captured email for a handler (`POST /api/requests/{id}/render`): @@ -109,7 +130,7 @@ impl ApiClient { limit: Option, offset: Option, order: Option<&str>, - ) -> Result { + ) -> Result> { self.require_auth()?; let mut params = vec![]; if let Some(s) = slug { @@ -142,7 +163,7 @@ impl ApiClient { format!("?{}", params.join("&")) }; let resp = self.get(&format!("/api/search/requests{qs}")).await?; - serde_json::from_str(&resp.body).context("failed to parse search results") + resp.parse("search results") } pub async fn count_requests( diff --git a/apps/cli-rs/src/api/stream.rs b/apps/cli-rs/src/api/stream.rs index a61b1f75f..64dc90efc 100644 --- a/apps/cli-rs/src/api/stream.rs +++ b/apps/cli-rs/src/api/stream.rs @@ -9,7 +9,7 @@ use tokio::task::JoinHandle; use super::{ApiClient, extract_error}; use crate::agent::AgentError; -use crate::types::{CapturedRequest, SseEvent}; +use crate::types::{SseEvent, Streamed}; const MAX_BUFFER_SIZE: usize = 1024 * 1024; // 1 MB const CONNECT_TIMEOUT: Duration = Duration::from_secs(30); @@ -433,15 +433,12 @@ fn parse_sse_event(event_type: &str, data: &str) -> Option { let _: serde_json::Value = serde_json::from_str(data).ok()?; Some(SseEvent::Connected) } - "request" => { - let req: CapturedRequest = serde_json::from_str(data).ok()?; - Some(SseEvent::Request(Box::new(req))) - } + "request" => Some(SseEvent::Request(Box::new(Streamed::parse(data)?))), "endpoint_deleted" => Some(SseEvent::EndpointDeleted), "timeout" => Some(SseEvent::Timeout), _ => { if !data.is_empty() - && let Ok(req) = serde_json::from_str::(data) + && let Some(req) = Streamed::parse(data) { return Some(SseEvent::Request(Box::new(req))); } diff --git a/apps/cli-rs/src/api/teams.rs b/apps/cli-rs/src/api/teams.rs index 1c3f260fb..76f5baead 100644 --- a/apps/cli-rs/src/api/teams.rs +++ b/apps/cli-rs/src/api/teams.rs @@ -1,16 +1,17 @@ -use anyhow::{Context, Result}; +use anyhow::Result; use super::ApiClient; +use super::client::Fetched; use crate::types::{Team, TeamInvite, TeamMembersResponse}; impl ApiClient { - pub async fn list_teams(&self) -> Result> { + pub async fn list_teams(&self) -> Result>> { self.require_auth()?; let resp = self.get("/api/teams").await?; - serde_json::from_str(&resp.body).context("failed to parse team list") + resp.parse("team list") } - pub async fn list_team_members(&self, team_id: &str) -> Result { + pub async fn list_team_members(&self, team_id: &str) -> Result> { self.require_auth()?; let resp = self .get(&format!( @@ -18,7 +19,7 @@ impl ApiClient { urlencoding::encode(team_id) )) .await?; - serde_json::from_str(&resp.body).context("failed to parse team members") + resp.parse("team members") } /// Share an endpoint the caller owns with a team. Takes the endpoint id; @@ -45,7 +46,11 @@ impl ApiClient { Ok(()) } - pub async fn invite_team_member(&self, team_id: &str, email: &str) -> Result { + pub async fn invite_team_member( + &self, + team_id: &str, + email: &str, + ) -> Result> { self.require_auth()?; let body = serde_json::json!({ "email": email }); let resp = self @@ -54,13 +59,13 @@ impl ApiClient { &body, ) .await?; - serde_json::from_str(&resp.body).context("failed to parse invite") + resp.parse("invite") } - pub async fn list_invites(&self) -> Result> { + pub async fn list_invites(&self) -> Result>> { self.require_auth()?; let resp = self.get("/api/invites").await?; - serde_json::from_str(&resp.body).context("failed to parse invite list") + resp.parse("invite list") } pub async fn accept_invite(&self, invite_id: &str) -> Result<()> { diff --git a/apps/cli-rs/src/cli/api.rs b/apps/cli-rs/src/cli/api.rs new file mode 100644 index 000000000..9526f6f0b --- /dev/null +++ b/apps/cli-rs/src/cli/api.rs @@ -0,0 +1,144 @@ +//! `whk api `: any API route with the CLI's credentials, like `gh api`. +//! It covers whatever has no command of its own yet, so an agent is never +//! sent to the dashboard for something the API can do. + +use std::io::{Read, Write}; + +use anyhow::{Context, Result, bail}; +use reqwest::Method; + +use crate::api::ApiClient; +use crate::api::client::{api_error, retry_after}; +use crate::error::CliError; + +pub async fn run( + client: &ApiClient, + path: &str, + method: Option<&str>, + data: Option<&str>, + headers: &[String], + include: bool, +) -> Result<()> { + let path = api_path(path)?; + client.require_auth()?; + let body = data.map(read_data).transpose()?; + let method = match method { + Some(m) => Method::from_bytes(m.trim().to_ascii_uppercase().as_bytes()) + .map_err(|_| CliError::Usage(format!("invalid HTTP method: {m}")))?, + None if body.is_some() => Method::POST, + None => Method::GET, + }; + let headers = headers + .iter() + .map(|h| { + h.split_once(':') + .map(|(k, v)| (k.trim().to_string(), v.trim().to_string())) + .ok_or_else(|| { + CliError::Usage(format!("invalid header: {h} (expected Key:Value)")).into() + }) + }) + .collect::>>()?; + + let resp = client.send_raw(method, &path, body, &headers).await?; + + if include { + let mut err = std::io::stderr().lock(); + writeln!(err, "HTTP {}", resp.status)?; + for (name, value) in &resp.headers { + writeln!(err, "{name}: {}", value.to_str().unwrap_or(""))?; + } + writeln!(err)?; + } + + let mut out = std::io::stdout().lock(); + out.write_all(&resp.body)?; + if !resp.body.is_empty() && !resp.body.ends_with(b"\n") { + out.write_all(b"\n")?; + } + out.flush()?; + + if resp.status.is_client_error() || resp.status.is_server_error() { + let text = String::from_utf8_lossy(&resp.body); + return Err(api_error(resp.status, &text, retry_after(&resp.headers)).into()); + } + Ok(()) +} + +/// The path to request: under `/api`, never another host (the bearer must +/// only ever go to webhooks.cc). +fn api_path(path: &str) -> Result { + let path = path.trim(); + if path.contains("://") || path.starts_with("//") { + return Err(CliError::Usage( + "give an API path such as /api/endpoints, not a URL (whk only sends your credentials to the configured API)" + .into(), + ) + .into()); + } + let trimmed = path.trim_start_matches('/'); + if trimmed.is_empty() { + return Err(CliError::Usage("give an API path such as /api/endpoints".into()).into()); + } + if trimmed == "api" || trimmed.starts_with("api/") || trimmed.starts_with("api?") { + Ok(format!("/{trimmed}")) + } else { + Ok(format!("/api/{trimmed}")) + } +} + +/// `-d`: the text itself, `@file`, or `@-` for stdin. +fn read_data(data: &str) -> Result> { + match data.strip_prefix('@') { + Some("-") => { + let mut buf = Vec::new(); + std::io::stdin() + .read_to_end(&mut buf) + .context("failed to read the body from stdin")?; + Ok(buf) + } + Some(file) => { + if file.is_empty() { + bail!("-d @ needs a file name, or @- for stdin"); + } + std::fs::read(file).with_context(|| format!("failed to read {file}")) + } + None => Ok(data.as_bytes().to_vec()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn paths_land_under_api() { + assert_eq!(api_path("endpoints").unwrap(), "/api/endpoints"); + assert_eq!(api_path("/api/endpoints").unwrap(), "/api/endpoints"); + assert_eq!(api_path("api/usage").unwrap(), "/api/usage"); + assert_eq!( + api_path("endpoints/acme/requests?limit=5").unwrap(), + "/api/endpoints/acme/requests?limit=5" + ); + // "apis" is not the api prefix. + assert_eq!(api_path("apis").unwrap(), "/api/apis"); + } + + #[test] + fn other_hosts_are_refused() { + assert!(api_path("https://evil.example/api/endpoints").is_err()); + assert!(api_path("//evil.example/x").is_err()); + assert!(api_path(" ").is_err()); + } + + #[test] + fn data_is_text_or_a_file() { + assert_eq!(read_data(r#"{"a":1}"#).unwrap(), br#"{"a":1}"#); + let dir = std::env::temp_dir().join(format!("whk-api-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("body.json"); + std::fs::write(&file, b"{}").unwrap(); + assert_eq!(read_data(&format!("@{}", file.display())).unwrap(), b"{}"); + std::fs::remove_dir_all(&dir).unwrap(); + assert!(read_data("@").is_err()); + } +} diff --git a/apps/cli-rs/src/cli/auth.rs b/apps/cli-rs/src/cli/auth.rs index fa30f2c0d..eadefca42 100644 --- a/apps/cli-rs/src/cli/auth.rs +++ b/apps/cli-rs/src/cli/auth.rs @@ -5,6 +5,7 @@ use crate::agent::CredentialStore; use crate::api::{ApiClient, AuthSource}; use crate::auth; use crate::cli::output::{bold, dim, green, red}; +use crate::error::CliError; use crate::types::Token; pub async fn login(client: &mut ApiClient, json: bool) -> Result<()> { @@ -25,7 +26,20 @@ pub async fn login(client: &mut ApiClient, json: bool) -> Result<()> { // Step 1: Create device code let device = client.create_device_code().await?; - if !json { + if json { + // One line per step (NDJSON), so an agent can show the human the + // code and the link while this keeps polling. + println!( + "{}", + serde_json::json!({ + "status": "pending", + "user_code": device.user_code, + "verification_url": device.verification_url, + "expires_at": device.expires_at, + "message": "Open verification_url and enter user_code to approve this login.", + }) + ); + } else { println!("\n {} {}\n", bold("Your code:"), green(&device.user_code)); println!( " Open {} and enter the code above.", @@ -49,14 +63,7 @@ pub async fn login(client: &mut ApiClient, json: bool) -> Result<()> { let poll = client.poll_device_code(&device.device_code).await?; match poll.status.as_str() { "authorized" => break, - "expired" => { - if json { - println!("{}", serde_json::json!({ "status": "expired" })); - } else { - println!("\n {} Code expired. Please try again.", red("Error:")); - } - return Ok(()); - } + "expired" => return Err(CliError::LoginExpired.into()), _ => continue, // "pending" } } diff --git a/apps/cli-rs/src/cli/confirm.rs b/apps/cli-rs/src/cli/confirm.rs new file mode 100644 index 000000000..c77c35c34 --- /dev/null +++ b/apps/cli-rs/src/cli/confirm.rs @@ -0,0 +1,76 @@ +//! Asking before a destructive command, in a way that never blocks a script +//! or an agent: without a terminal there is no one to ask, so the command +//! fails with `confirmation_required` (exit 9) and says to pass `--yes`. + +use std::io::{self, BufRead, IsTerminal, Write}; + +use anyhow::Result; + +use crate::error::CliError; + +/// Goes ahead when `yes`, else asks `question` on the terminal. Fails with +/// [`CliError::ConfirmationRequired`] when stdin or stderr is not a terminal, +/// and with [`CliError::Cancelled`] when the answer is not "y". +pub fn confirm(question: &str, yes: bool) -> Result<()> { + if yes { + return Ok(()); + } + let interactive = io::stdin().is_terminal() && io::stderr().is_terminal(); + decide(question, interactive, || { + // The question goes to stderr, so stdout stays clean for --json. + eprint!(" {question} [y/N] "); + io::stderr().flush()?; + let mut input = String::new(); + io::stdin().lock().read_line(&mut input)?; + Ok(input) + }) +} + +fn decide( + question: &str, + interactive: bool, + ask: impl FnOnce() -> io::Result, +) -> Result<()> { + if !interactive { + return Err(CliError::ConfirmationRequired(format!( + "{question} Not asked, because no terminal is attached: pass --yes to go ahead." + )) + .into()); + } + let answer = ask()?; + if answer.trim().eq_ignore_ascii_case("y") || answer.trim().eq_ignore_ascii_case("yes") { + Ok(()) + } else { + Err(CliError::Cancelled.into()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::classify; + + #[test] + fn without_a_terminal_it_refuses_instead_of_waiting() { + let error = decide("Delete endpoint acme?", false, || { + panic!("must not read stdin without a terminal") + }) + .unwrap_err(); + let c = classify(&error); + assert_eq!((c.code, c.exit), ("confirmation_required", 9)); + assert!(error.to_string().contains("--yes")); + } + + #[test] + fn only_yes_goes_ahead() { + assert!(decide("Go?", true, || Ok("y\n".into())).is_ok()); + assert!(decide("Go?", true, || Ok("YES\n".into())).is_ok()); + let error = decide("Go?", true, || Ok("\n".into())).unwrap_err(); + assert_eq!(classify(&error).code, "cancelled"); + } + + #[test] + fn yes_skips_the_question() { + assert!(confirm("Go?", true).is_ok()); + } +} diff --git a/apps/cli-rs/src/cli/endpoints.rs b/apps/cli-rs/src/cli/endpoints.rs index 335096e3b..fa75ad8b6 100644 --- a/apps/cli-rs/src/cli/endpoints.rs +++ b/apps/cli-rs/src/cli/endpoints.rs @@ -1,6 +1,5 @@ use anyhow::Result; use std::collections::HashMap; -use std::io::{self, Write}; use crate::api::ApiClient; use crate::cli::output::{bold, dim, green, print_endpoint_table, red, sanitize}; @@ -69,7 +68,7 @@ pub async fn create( } if json { - println!("{}", serde_json::to_string_pretty(&endpoint)?); + println!("{}", serde_json::to_string_pretty(&endpoint.raw)?); } else { let url = client.webhook_url_for(&endpoint.slug); println!( @@ -109,10 +108,26 @@ pub async fn list(client: &ApiClient, team: Option<&str>, json: bool) -> Result< }; list.owned.retain(tied); list.shared.retain(tied); + // The printed JSON keeps the same endpoints. + let kept: std::collections::HashSet = list + .owned + .iter() + .chain(list.shared.iter()) + .map(|ep| ep.slug.clone()) + .collect(); + for key in ["owned", "shared"] { + if let Some(items) = list.raw.get_mut(key).and_then(|v| v.as_array_mut()) { + items.retain(|ep| { + ep.get("slug") + .and_then(|s| s.as_str()) + .is_some_and(|slug| kept.contains(slug)) + }); + } + } } if json { - println!("{}", serde_json::to_string_pretty(&list)?); + println!("{}", serde_json::to_string_pretty(&list.raw)?); return Ok(()); } @@ -144,7 +159,7 @@ pub async fn get(client: &ApiClient, slug: &str, json: bool) -> Result<()> { let endpoint = client.get_endpoint(slug).await?; if json { - println!("{}", serde_json::to_string_pretty(&endpoint)?); + println!("{}", serde_json::to_string_pretty(&endpoint.raw)?); return Ok(()); } @@ -215,7 +230,7 @@ pub async fn update_endpoint( let endpoint = client.update_endpoint(slug, &req).await?; if json { - println!("{}", serde_json::to_string_pretty(&endpoint)?); + println!("{}", serde_json::to_string_pretty(&endpoint.raw)?); } else { println!(" {} Updated endpoint {}", green("✓"), bold(&endpoint.slug)); } @@ -256,20 +271,10 @@ pub async fn set_favorite( } pub async fn delete(client: &ApiClient, slug: &str, force: bool, json: bool) -> Result<()> { - if !force { - print!( - " Delete endpoint {}? This cannot be undone. [y/N] ", - bold(slug) - ); - io::stdout().flush()?; - - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - if !input.trim().eq_ignore_ascii_case("y") { - println!(" Cancelled."); - return Ok(()); - } - } + crate::cli::confirm::confirm( + &format!("Delete endpoint {slug}? This cannot be undone."), + force, + )?; client.delete_endpoint(slug).await?; diff --git a/apps/cli-rs/src/cli/listen.rs b/apps/cli-rs/src/cli/listen.rs index 1dce7a207..058e72c7f 100644 --- a/apps/cli-rs/src/cli/listen.rs +++ b/apps/cli-rs/src/cli/listen.rs @@ -6,7 +6,8 @@ use crate::api::stream::join_stream; use crate::cli::output::{ bold, dim, green, method_color, print_reconnected, print_reconnecting, red, }; -use crate::types::SseEvent; +use crate::cli::output::{sanitize, sanitize_line}; +use crate::types::{CapturedRequest, SseEvent}; use crate::util::format::format_bytes; pub async fn run(client: &ApiClient, slug: &str, json: bool) -> Result<()> { @@ -36,28 +37,9 @@ pub async fn run(client: &ApiClient, slug: &str, json: bool) -> Result<()> { match event { SseEvent::Request(req) => { if json { - println!("{}", serde_json::to_string(&req).unwrap_or_default()); + println!("{}", serde_json::to_string(&req.raw).unwrap_or_default()); } else { - let time = chrono::Local::now().format("%H:%M:%S"); - let sig_status = match req.signature_verified { - Some(true) => { - let provider = req.signing_provider.as_deref().unwrap_or("?"); - format!(" {} {}", green("✓"), provider) - } - Some(false) => { - let provider = req.signing_provider.as_deref().unwrap_or("?"); - format!(" {} {}", red("✗"), provider) - } - None => String::new(), - }; - println!( - " {} {} {}{} {}", - dim(&time.to_string()), - method_color(&req.method), - req.path, - sig_status, - dim(&format_bytes(req.size)), - ); + println!("{}", listen_line(&req)); } } SseEvent::EndpointDeleted => { @@ -94,3 +76,37 @@ pub async fn run(client: &ApiClient, slug: &str, json: bool) -> Result<()> { drop(rx); join_stream(stream_handle, interrupted).await } + +/// One line per arrival: the time, the method, the path (for an email the +/// recipient and the subject), the signature check, the size and the id +/// (for `whk requests get`). +fn listen_line(req: &CapturedRequest) -> String { + let time = chrono::Local::now().format("%H:%M:%S").to_string(); + let what = match req.email.as_ref().filter(|_| req.is_email()) { + Some(email) => format!( + "{} {}", + sanitize_line(&req.path), + bold(&sanitize_line( + email.subject.as_deref().unwrap_or("(no subject)") + )) + ), + None => sanitize(&req.path), + }; + let signature = match req.signature_verified { + Some(ok) => { + let provider = sanitize(req.signing_provider.as_deref().unwrap_or("?")); + let mark = if ok { green("✓") } else { red("✗") }; + format!(" {mark} {provider}") + } + None => String::new(), + }; + format!( + " {} {} {}{} {} {}", + dim(&time), + method_color(&req.method), + what, + signature, + dim(&format_bytes(req.size)), + dim(&sanitize(&req.id)), + ) +} diff --git a/apps/cli-rs/src/cli/mod.rs b/apps/cli-rs/src/cli/mod.rs index 6f2255e54..305038203 100644 --- a/apps/cli-rs/src/cli/mod.rs +++ b/apps/cli-rs/src/cli/mod.rs @@ -1,4 +1,6 @@ +pub mod api; pub mod auth; +pub mod confirm; pub mod endpoints; pub mod listen; pub mod output; @@ -11,6 +13,7 @@ pub mod teams; pub mod tunnel; pub mod update; pub mod usage; +pub mod wait; use clap::{Parser, Subcommand}; @@ -19,16 +22,17 @@ use crate::types::EmailFormat; #[derive(Parser, Debug)] #[command( name = "whk", - about = "webhooks.cc — inspect, forward, and replay webhooks", + about = "webhooks.cc: capture, inspect, forward and replay webhooks and email, and share localhost", version = env!("WHK_VERSION"), arg_required_else_help = false, + after_help = AFTER_HELP, )] pub struct Cli { - /// Disable TUI and show help - #[arg(long)] + /// Accepted for scripts written for older versions, which had a TUI; does nothing + #[arg(long, hide = true)] pub nogui: bool, - /// Output as JSON where supported + /// Print JSON: the API's own answers on stdout, NDJSON for streams, and failures as {"error":{...}} on stderr #[arg(long, global = true)] pub json: bool, @@ -48,6 +52,27 @@ pub struct Cli { pub command: Option, } +/// Printed under `whk --help`. +pub const AFTER_HELP: &str = "\ +Exit status: + 0 success + 1 any other failure, or the server refused the input (bad_request) + 2 bad arguments (usage) + 3 not logged in, or the credentials were refused (not_logged_in, unauthorized) + 4 not found (not_found) + 5 not allowed by the plan, a limit or a permission (forbidden) + 6 rate limited; wait and retry (rate_limited) + 7 server or network failure; retry (server_error, network_error) + 8 a wait ran out of time (timeout) + 9 a destructive command was not confirmed; pass --yes (confirmation_required, cancelled) + +With --json, a failure is written to stderr as + {\"error\":{\"code\":\"not_found\",\"message\":\"...\",\"exit\":4,\"status\":404,\"hint\":\"...\"}} + +Agents: `whk wait ` blocks until a request arrives and prints it, and +`whk api ` calls any API route with your credentials. +Docs: https://webhooks.cc/docs/cli"; + #[derive(Subcommand, Debug)] pub enum Command { /// Authenticate with webhooks.cc @@ -149,8 +174,8 @@ pub enum Command { /// Endpoint slug slug: String, - /// Skip confirmation prompt - #[arg(short, long)] + /// Delete without asking (needed when no terminal is attached) + #[arg(short = 'y', long = "yes", visible_alias = "force", short_alias = 'f')] force: bool, }, @@ -231,12 +256,80 @@ pub enum Command { action: NamesAction, }, - /// Stream incoming requests to terminal + /// Stream requests and emails as they arrive: one line each, or NDJSON with --json Listen { /// Endpoint slug to listen on slug: String, }, + /// Wait for the next request or email at an endpoint, print it and exit + #[command(after_help = "\ +Run it in the background before triggering the sender, or after: requests from +the last 5 minutes count unless --since says otherwise. Exits 8 (timeout) when +nothing matching arrived in time.")] + Wait { + /// Endpoint slug + slug: String, + + /// How long to wait (e.g. "30s", "5m", "1h") + #[arg(long, default_value = "30s")] + timeout: String, + + /// How many matching requests to wait for; more than one prints a JSON array with --json + #[arg(long, default_value_t = 1, value_parser = clap::value_parser!(u32).range(1..=100))] + count: u32, + + /// Only requests received after this: "now", a duration back ("2m"), or a timestamp in milliseconds + #[arg(long)] + since: Option, + + /// Only this HTTP method (e.g. POST) + #[arg(long)] + method: Option, + + /// Only HTTP requests or only emails + #[arg(long, value_enum)] + kind: Option, + + /// Only requests whose path contains this text (for an email, the recipient address) + #[arg(long)] + path: Option, + }, + + /// Call any webhooks.cc API route with your credentials and print the answer + #[command(after_help = "\ +Examples: + whk api /api/endpoints + whk api endpoints/acme-orders/destinations + whk api -X PATCH endpoints/acme-orders -d '{\"retentionDays\":7}' + whk api -X POST endpoints/acme-orders/destinations -d @destination.json + +The path is relative to /api when it does not start with it. Data given with +-d is sent as JSON (POST unless -X says otherwise); @file reads a file and @- +reads stdin. The body is printed as received; a failing status exits as +described in `whk --help`. Routes that need a browser session (billing, account +deletion) refuse API keys.")] + Api { + /// API path, e.g. "endpoints" or "/api/usage" + path: String, + + /// HTTP method (default GET, or POST when -d is given) + #[arg(short = 'X', long)] + method: Option, + + /// Request body: JSON text, @file, or @- for stdin + #[arg(short = 'd', long)] + data: Option, + + /// Extra header (repeatable, format: Key:Value) + #[arg(short = 'H', long = "header")] + headers: Vec, + + /// Print the status line and headers before the body (to stderr) + #[arg(short = 'i', long)] + include: bool, + }, + /// Replay a captured request Replay { /// Request ID to replay @@ -619,8 +712,8 @@ pub enum RequestsAction { #[arg(long)] before: Option, - /// Skip confirmation - #[arg(short, long)] + /// Clear without asking (needed when no terminal is attached) + #[arg(short = 'y', long = "yes", visible_alias = "force", short_alias = 'f')] force: bool, }, diff --git a/apps/cli-rs/src/cli/requests.rs b/apps/cli-rs/src/cli/requests.rs index f5ee1451b..e2401751e 100644 --- a/apps/cli-rs/src/cli/requests.rs +++ b/apps/cli-rs/src/cli/requests.rs @@ -1,5 +1,4 @@ use anyhow::Result; -use std::io::{self, Write}; use crate::api::ApiClient; use crate::cli::ExportFormat; @@ -20,7 +19,7 @@ pub async fn list( .list_requests_paginated(slug, Some(limit), Some(c)) .await?; if json { - println!("{}", serde_json::to_string_pretty(&result)?); + println!("{}", serde_json::to_string_pretty(&result.raw)?); return Ok(()); } if result.requests.is_empty() { @@ -36,7 +35,7 @@ pub async fn list( } else { let result = client.list_requests(slug, Some(limit), since).await?; if json { - println!("{}", serde_json::to_string_pretty(&result)?); + println!("{}", serde_json::to_string_pretty(&result.raw)?); return Ok(()); } if result.requests.is_empty() { @@ -59,7 +58,7 @@ pub async fn list( pub async fn get(client: &ApiClient, id: &str, json: bool) -> Result<()> { let req = client.get_request(id).await?; if json { - println!("{}", serde_json::to_string_pretty(&req)?); + println!("{}", serde_json::to_string_pretty(&req.raw)?); } else { print_request_detail(&req); } @@ -96,7 +95,7 @@ pub async fn search( .await?; if json { - println!("{}", serde_json::to_string_pretty(&result)?); + println!("{}", serde_json::to_string_pretty(&result.raw)?); return Ok(()); } @@ -150,21 +149,11 @@ pub async fn clear( force: bool, json: bool, ) -> Result<()> { - if !force { - let msg = match before { - Some(b) => format!("Clear requests for {} before {}? [y/N] ", bold(slug), b), - None => format!("Clear ALL requests for {}? [y/N] ", bold(slug)), - }; - print!(" {msg}"); - io::stdout().flush()?; - - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - if !input.trim().eq_ignore_ascii_case("y") { - println!(" Cancelled."); - return Ok(()); - } - } + let question = match before { + Some(b) => format!("Clear requests for {slug} before {b}?"), + None => format!("Clear ALL requests for {slug}?"), + }; + crate::cli::confirm::confirm(&question, force)?; client.clear_requests(slug, before).await?; diff --git a/apps/cli-rs/src/cli/share.rs b/apps/cli-rs/src/cli/share.rs index 2d6bfc5e2..2134d5d5e 100644 --- a/apps/cli-rs/src/cli/share.rs +++ b/apps/cli-rs/src/cli/share.rs @@ -29,12 +29,15 @@ pub async fn run(client: &ApiClient, options: ShareOptions) -> Result<()> { let json = options.json; let result = run_inner(client, options).await; if json && let Err(e) = &result { - Event::Error { - message: &format!("{e:#}"), - } - .print(); + let c = crate::error::classify(e); + let message = match &c.hint { + Some(hint) => format!("{e:#} {hint}"), + None => format!("{e:#}"), + }; + Event::Error { message: &message }.print(); events::flush(events::FINAL_WAIT); - std::process::exit(1); + // The same exit status as any other failure (see `whk --help`). + std::process::exit(c.exit); } // What the share printed is out before the error, or the exit. events::flush(events::FINAL_WAIT); diff --git a/apps/cli-rs/src/cli/teams.rs b/apps/cli-rs/src/cli/teams.rs index e0059251c..6b4f32222 100644 --- a/apps/cli-rs/src/cli/teams.rs +++ b/apps/cli-rs/src/cli/teams.rs @@ -133,7 +133,7 @@ pub async fn list(client: &ApiClient, json: bool) -> Result<()> { let teams = client.list_teams().await?; if json { - println!("{}", serde_json::to_string_pretty(&teams)?); + println!("{}", serde_json::to_string_pretty(&teams.raw)?); return Ok(()); } @@ -154,7 +154,7 @@ pub async fn members(client: &ApiClient, team: &str, json: bool) -> Result<()> { let result = client.list_team_members(&team.id).await?; if json { - println!("{}", serde_json::to_string_pretty(&result)?); + println!("{}", serde_json::to_string_pretty(&result.raw)?); return Ok(()); } @@ -237,7 +237,7 @@ pub async fn invite(client: &ApiClient, team: &str, email: &str, json: bool) -> let invite = client.invite_team_member(&team.id, email).await?; if json { - println!("{}", serde_json::to_string_pretty(&invite)?); + println!("{}", serde_json::to_string_pretty(&invite.raw)?); } else { println!( "{} Invited {} to {}", @@ -266,7 +266,7 @@ pub async fn invites(client: &ApiClient, json: bool) -> Result<()> { let invites = client.list_invites().await?; if json { - println!("{}", serde_json::to_string_pretty(&invites)?); + println!("{}", serde_json::to_string_pretty(&invites.raw)?); return Ok(()); } diff --git a/apps/cli-rs/src/cli/tunnel.rs b/apps/cli-rs/src/cli/tunnel.rs index 0567012cb..c16ef77f4 100644 --- a/apps/cli-rs/src/cli/tunnel.rs +++ b/apps/cli-rs/src/cli/tunnel.rs @@ -66,7 +66,7 @@ pub async fn run( .get_endpoint(s) .await .ok() - .and_then(|ep| ep.email_address), + .and_then(|ep| ep.value.email_address), None => None, }; (s.to_string(), address, false) @@ -79,7 +79,7 @@ pub async fn run( mock_response: None, }; let ep = client.create_endpoint(&req).await?; - (ep.slug, ep.email_address, true) + (ep.value.slug, ep.value.email_address, true) } }; @@ -209,7 +209,7 @@ pub async fn run( // forwarded from the stream like a plain tunnel's. let req = if live && !req.is_email() { let mut relay = lock(&relay); - let Some(req) = relay.admit(*req, Instant::now()) else { continue }; + let Some(req) = relay.admit(req.request, Instant::now()) else { continue }; if relay.announce() { drop(relay); if json { @@ -227,7 +227,7 @@ pub async fn run( } req } else { - *req + req.request }; // --no-email: the email is listed, not posted. if req.is_email() && email_format.is_none() { @@ -835,6 +835,7 @@ fn forwarded_event( ) -> serde_json::Value { let mut event = serde_json::json!({ "event": "forwarded", + "id": req.id, "method": req.method, "path": req.path, "status": result.status_code, diff --git a/apps/cli-rs/src/cli/usage.rs b/apps/cli-rs/src/cli/usage.rs index ae9661c23..ce44af70a 100644 --- a/apps/cli-rs/src/cli/usage.rs +++ b/apps/cli-rs/src/cli/usage.rs @@ -13,7 +13,7 @@ pub async fn run(client: &ApiClient, json: bool) -> Result<()> { // as `teamsError` so an empty `teams` is never mistaken for "no teams". let mut teams_error: Option = None; let teams: Vec = match teams_result { - Ok(teams) => teams.into_iter().filter(|t| !t.suspended).collect(), + Ok(teams) => teams.value.into_iter().filter(|t| !t.suspended).collect(), Err(e) => { if !json { eprintln!(" {} {}", dim("Could not load team pools:"), e); diff --git a/apps/cli-rs/src/cli/wait.rs b/apps/cli-rs/src/cli/wait.rs new file mode 100644 index 000000000..4df7ca8a4 --- /dev/null +++ b/apps/cli-rs/src/cli/wait.rs @@ -0,0 +1,305 @@ +//! `whk wait `: block until a matching request or email arrives, print +//! it and exit. Built for agents: run it in the background, trigger the +//! sender, and the command's exit is the signal that something arrived. +//! +//! It polls like the SDKs' `waitFor`: requests from the last five minutes +//! count unless `--since` says otherwise, each poll starts a little before the +//! newest request seen (on the server's clock), and repeats are skipped by id. + +use std::collections::HashSet; +use std::time::{Duration, Instant}; + +use anyhow::Result; +use serde_json::Value; + +use crate::api::ApiClient; +use crate::cli::output::{dim, print_request_detail, print_request_line}; +use crate::error::{CliError, classify}; +use crate::types::CapturedRequest; +use crate::util::format::parse_duration; + +/// How far back a wait looks when `--since` is not given (as the SDKs do). +const LOOKBACK_MS: i64 = 5 * 60 * 1000; +/// How far before the newest request seen the next poll starts: a capture +/// can commit after a newer one. +const OVERLAP_MS: i64 = 30 * 1000; +const POLL: Duration = Duration::from_secs(1); +const PAGE: u32 = 100; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum Kind { + Http, + Email, +} + +pub struct WaitOptions { + pub slug: String, + pub timeout: String, + pub count: u32, + pub since: Option, + pub method: Option, + pub kind: Option, + pub path: Option, +} + +/// Where the wait starts. +#[derive(Debug, PartialEq)] +enum Since { + /// Only what arrives after the first poll: what that poll finds is + /// skipped, so the local clock never matters. + Now, + /// Requests received at or after this server time (ms). + At(i64), +} + +fn parse_since(since: Option<&str>, now_ms: i64) -> Result { + let Some(since) = since.map(str::trim) else { + return Ok(Since::At(now_ms - LOOKBACK_MS)); + }; + if since.eq_ignore_ascii_case("now") { + return Ok(Since::Now); + } + // A bare number is a timestamp, as for the other --since flags. + if let Ok(at) = since.parse::() { + return Ok(Since::At(at)); + } + let back = parse_duration(since).map_err(|e| { + CliError::Usage(format!( + "--since takes \"now\", a duration such as 2m, or a timestamp in ms ({e})" + )) + })?; + Ok(Since::At(now_ms - back)) +} + +struct Filter { + method: Option, + kind: Option, + path: Option, +} + +impl Filter { + fn matches(&self, request: &Value) -> bool { + let text = |key: &str| request.get(key).and_then(Value::as_str).unwrap_or(""); + if let Some(method) = &self.method + && !text("method").eq_ignore_ascii_case(method) + { + return false; + } + if let Some(kind) = self.kind { + let is_email = match request.get("kind").and_then(Value::as_str) { + Some(kind) => kind == "email", + None => { + text("method").eq_ignore_ascii_case("EMAIL") && !text("path").starts_with('/') + } + }; + if is_email != (kind == Kind::Email) { + return false; + } + } + if let Some(path) = &self.path + && !text("path").to_lowercase().contains(&path.to_lowercase()) + { + return false; + } + true + } +} + +fn request_id(request: &Value) -> Option { + request + .get("id") + .or_else(|| request.get("_id")) + .and_then(Value::as_str) + .map(String::from) +} + +fn received_at(request: &Value) -> Option { + request.get("receivedAt").and_then(Value::as_i64) +} + +fn now_ms() -> i64 { + chrono::Utc::now().timestamp_millis() +} + +/// Whether a failed poll is worth another try: rate limits, our side, the +/// network. A missing endpoint or refused credentials end the wait. +fn transient(error: &anyhow::Error) -> bool { + matches!( + classify(error).code, + "rate_limited" | "server_error" | "network_error" + ) +} + +pub async fn run(client: &ApiClient, options: WaitOptions, json: bool) -> Result<()> { + client.require_auth()?; + let timeout_ms = + parse_duration(&options.timeout).map_err(|e| CliError::Usage(format!("--timeout: {e}")))?; + let deadline = Instant::now() + Duration::from_millis(timeout_ms.max(0) as u64); + let filter = Filter { + method: options.method.clone(), + kind: options.kind, + path: options.path.clone(), + }; + let count = options.count.max(1) as usize; + let slug = options.slug.as_str(); + + let since = parse_since(options.since.as_deref(), now_ms())?; + let mut skip_first = since == Since::Now; + let mut last_checked = match since { + Since::Now => now_ms() - LOOKBACK_MS, + Since::At(at) => at, + }; + let mut seen: HashSet = HashSet::new(); + let mut collected: Vec = Vec::new(); + + if !json { + let what = if count == 1 { + "a request".to_string() + } else { + format!("{count} requests") + }; + eprintln!( + " {}", + dim(&format!( + "Waiting up to {} for {what} at {slug}...", + options.timeout + )) + ); + } + + loop { + let mut pause = POLL; + match client.list_requests_raw(slug, PAGE, last_checked).await { + Ok(mut items) => { + items.sort_by_key(|r| received_at(r).unwrap_or(0)); + for item in &items { + if let Some(at) = received_at(item) { + last_checked = last_checked.max(at - OVERLAP_MS); + } + } + for item in items { + let Some(id) = request_id(&item) else { + continue; + }; + if !seen.insert(id) || skip_first || !filter.matches(&item) { + continue; + } + collected.push(item); + if collected.len() >= count { + return print(&collected, count, json); + } + } + skip_first = false; + } + Err(error) if transient(&error) => { + if let Some(seconds) = classify(&error).retry_after { + pause = pause.max(Duration::from_secs(seconds)); + } + } + Err(error) => return Err(error), + } + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + return Err(CliError::Timeout(format!( + "{} of {count} matching request(s) arrived at {slug} within {}", + collected.len(), + options.timeout + )) + .into()); + } + tokio::time::sleep(left.min(pause)).await; + } +} + +fn print(collected: &[Value], count: usize, json: bool) -> Result<()> { + if json { + let out = if count == 1 { + collected[0].clone() + } else { + Value::Array(collected.to_vec()) + }; + println!("{}", serde_json::to_string_pretty(&out)?); + return Ok(()); + } + let parsed: Vec = collected + .iter() + .filter_map(|v| serde_json::from_value(v.clone()).ok()) + .collect(); + if let [one] = parsed.as_slice() { + print_request_detail(one); + } else { + for request in &parsed { + print_request_line(request); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn since_reads_now_durations_and_timestamps() { + let now = 1_000_000_000; + assert_eq!( + parse_since(None, now).unwrap(), + Since::At(now - LOOKBACK_MS) + ); + assert_eq!(parse_since(Some("now"), now).unwrap(), Since::Now); + assert_eq!( + parse_since(Some("2m"), now).unwrap(), + Since::At(now - 120_000) + ); + assert_eq!( + parse_since(Some("1774866106592"), now).unwrap(), + Since::At(1774866106592) + ); + let error = parse_since(Some("soon"), now).unwrap_err(); + assert_eq!(classify(&error).code, "usage"); + } + + #[test] + fn filters_match_method_kind_and_path() { + let post = json!({"id":"1","method":"POST","path":"/stripe/events","receivedAt":1}); + let email = json!({"id":"2","method":"EMAIL","path":"orders@mailhooks.cc","kind":"email","receivedAt":2}); + let any = Filter { + method: None, + kind: None, + path: None, + }; + assert!(any.matches(&post) && any.matches(&email)); + + let posts = Filter { + method: Some("post".into()), + kind: None, + path: None, + }; + assert!(posts.matches(&post) && !posts.matches(&email)); + + let emails = Filter { + method: None, + kind: Some(Kind::Email), + path: None, + }; + assert!(!emails.matches(&post) && emails.matches(&email)); + let http = Filter { + method: None, + kind: Some(Kind::Http), + path: None, + }; + assert!(http.matches(&post) && !http.matches(&email)); + + let stripe = Filter { + method: None, + kind: None, + path: Some("STRIPE".into()), + }; + assert!(stripe.matches(&post) && !stripe.matches(&email)); + + // An older answer without `kind` is read the way the server reads it. + let legacy = json!({"id":"3","method":"EMAIL","path":"a@mailhooks.cc"}); + assert!(emails.matches(&legacy)); + } +} diff --git a/apps/cli-rs/src/error.rs b/apps/cli-rs/src/error.rs new file mode 100644 index 000000000..a7de53af9 --- /dev/null +++ b/apps/cli-rs/src/error.rs @@ -0,0 +1,245 @@ +//! What a failed command reports: a stable error code and exit status that +//! scripts and coding agents can act on, the message, and a next step. +//! +//! Exit statuses (also in `whk --help`): +//! +//! | Status | Code | Meaning | +//! | ------ | ----------------------- | ----------------------------------------------- | +//! | 0 | | Success | +//! | 1 | `error`, `bad_request` | Any other failure, or the server refused input | +//! | 2 | `usage` | Bad arguments (clap answers these itself) | +//! | 3 | `not_logged_in`, `unauthorized` | No credentials, or they were refused | +//! | 4 | `not_found` | No such endpoint, request, team or share | +//! | 5 | `forbidden` | Not allowed: the plan, a limit, or a permission | +//! | 6 | `rate_limited` | Too many requests; wait and retry | +//! | 7 | `server_error`, `network_error` | Our side or the network failed; retry | +//! | 8 | `timeout` | `whk wait` (or another wait) ran out of time | +//! | 9 | `confirmation_required`, `cancelled` | A destructive command was not confirmed | + +use serde_json::{Value, json}; + +use crate::agent::AgentError; + +/// An API answer with an error status. The message is the server's own +/// words when it gave some. +#[derive(Debug, Clone)] +pub struct ApiError { + pub status: u16, + pub message: String, + /// Seconds from the `Retry-After` header. + pub retry_after: Option, +} + +impl std::fmt::Display for ApiError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for ApiError {} + +/// Failures the CLI decides on itself. +#[derive(Debug, Clone)] +pub enum CliError { + /// No credentials at all. + NotLoggedIn(String), + /// A destructive command without `--yes` and without a terminal to ask on. + ConfirmationRequired(String), + /// The person answered no. + Cancelled, + /// A wait ran out of time. + Timeout(String), + /// The device login's code expired before anyone approved it. + LoginExpired, + /// Arguments that parse but make no sense together. + Usage(String), +} + +impl std::fmt::Display for CliError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CliError::NotLoggedIn(m) + | CliError::ConfirmationRequired(m) + | CliError::Timeout(m) + | CliError::Usage(m) => f.write_str(m), + CliError::Cancelled => f.write_str("Cancelled."), + CliError::LoginExpired => f.write_str("The login code expired before it was approved."), + } + } +} + +impl std::error::Error for CliError {} + +/// How a failure is reported. +#[derive(Debug, Clone, PartialEq)] +pub struct Classified { + pub code: &'static str, + pub exit: i32, + pub status: Option, + pub hint: Option, + pub retry_after: Option, +} + +impl Classified { + fn new(code: &'static str, exit: i32) -> Self { + Self { + code, + exit, + status: None, + hint: None, + retry_after: None, + } + } + + fn hint(mut self, hint: impl Into) -> Self { + self.hint = Some(hint.into()); + self + } +} + +const LOGIN_HINT: &str = + "Run `whk auth login`, or connect an agent with `whk sandbox connect --email
`."; + +fn from_status(status: u16, retry_after: Option) -> Classified { + let mut c = match status { + 401 => Classified::new("unauthorized", 3).hint(LOGIN_HINT), + 402 | 403 => Classified::new("forbidden", 5), + 404 | 410 => Classified::new("not_found", 4), + 429 => Classified::new("rate_limited", 6).hint("Wait, then retry."), + 500..=599 => Classified::new("server_error", 7).hint("Retry in a little while."), + _ => Classified::new("bad_request", 1), + }; + c.status = Some(status); + c.retry_after = retry_after; + if let (Some(seconds), "rate_limited" | "server_error") = (retry_after, c.code) { + c.hint = Some(format!("Retry after {seconds} s.")); + } + c +} + +/// The code, exit status and next step for a failure, from the first error +/// in its chain that says what went wrong. +pub fn classify(error: &anyhow::Error) -> Classified { + for cause in error.chain() { + if let Some(e) = cause.downcast_ref::() { + return match e { + CliError::NotLoggedIn(_) => { + let hint = crate::api::ignored_key_hint().unwrap_or(LOGIN_HINT); + Classified::new("not_logged_in", 3).hint(hint) + } + CliError::ConfirmationRequired(_) => Classified::new("confirmation_required", 9) + .hint("Pass --yes to go ahead without being asked."), + CliError::Cancelled => Classified::new("cancelled", 9), + CliError::Timeout(_) => Classified::new("timeout", 8) + .hint("Run it again with a longer --timeout, or check that the sender ran."), + CliError::LoginExpired => { + Classified::new("login_expired", 3).hint("Run `whk auth login` again.") + } + CliError::Usage(_) => Classified::new("usage", 2), + }; + } + if let Some(e) = cause.downcast_ref::() { + return from_status(e.status, e.retry_after); + } + if let Some(e) = cause.downcast_ref::() { + return from_status(e.status, e.retry_after); + } + if let Some(e) = cause.downcast_ref::() { + let mut c = Classified::new("network_error", 7); + if let Some(status) = e.status() { + return from_status(status.as_u16(), None); + } + c = c.hint(if e.is_timeout() { + "The request timed out. Retry in a little while." + } else { + "Could not reach webhooks.cc. Check the network (and WHK_API_URL), then retry." + }); + return c; + } + } + Classified::new("error", 1) +} + +/// The JSON a failed command writes to stderr under `--json`. +pub fn error_json(error: &anyhow::Error, c: &Classified) -> Value { + let mut body = json!({ + "code": c.code, + "message": format!("{error:#}"), + "exit": c.exit, + }); + if let Some(status) = c.status { + body["status"] = json!(status); + } + if let Some(hint) = &c.hint { + body["hint"] = json!(hint); + } + if let Some(seconds) = c.retry_after { + body["retryAfter"] = json!(seconds); + } + json!({ "error": body }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn api(status: u16) -> anyhow::Error { + anyhow::Error::new(ApiError { + status, + message: "nope".into(), + retry_after: None, + }) + } + + #[test] + fn statuses_map_to_stable_codes_and_exits() { + let cases = [ + (400, "bad_request", 1), + (401, "unauthorized", 3), + (403, "forbidden", 5), + (404, "not_found", 4), + (409, "bad_request", 1), + (429, "rate_limited", 6), + (503, "server_error", 7), + ]; + for (status, code, exit) in cases { + let c = classify(&api(status)); + assert_eq!((c.code, c.exit, c.status), (code, exit, Some(status))); + } + } + + #[test] + fn context_does_not_hide_the_cause() { + let error = api(404).context("loading the endpoint"); + assert_eq!(classify(&error).code, "not_found"); + let json = error_json(&error, &classify(&error)); + assert_eq!(json["error"]["message"], "loading the endpoint: nope"); + assert_eq!(json["error"]["status"], 404); + assert_eq!(json["error"]["exit"], 4); + } + + #[test] + fn cli_failures_have_their_own_codes() { + let c = classify(&anyhow::Error::new(CliError::ConfirmationRequired( + "x".into(), + ))); + assert_eq!((c.code, c.exit), ("confirmation_required", 9)); + assert!(c.hint.unwrap().contains("--yes")); + let c = classify(&anyhow::Error::new(CliError::Timeout("x".into()))); + assert_eq!((c.code, c.exit), ("timeout", 8)); + assert_eq!(classify(&anyhow::anyhow!("plain")).code, "error"); + } + + #[test] + fn retry_after_becomes_the_hint() { + let error = anyhow::Error::new(ApiError { + status: 429, + message: "slow down".into(), + retry_after: Some(7), + }); + let c = classify(&error); + assert_eq!(c.retry_after, Some(7)); + assert_eq!(c.hint.as_deref(), Some("Retry after 7 s.")); + assert_eq!(error_json(&error, &c)["error"]["retryAfter"], 7); + } +} diff --git a/apps/cli-rs/src/lib.rs b/apps/cli-rs/src/lib.rs index 4c20d6432..8fed53caf 100644 --- a/apps/cli-rs/src/lib.rs +++ b/apps/cli-rs/src/lib.rs @@ -2,8 +2,8 @@ pub mod agent; pub mod api; pub mod auth; pub mod cli; +pub mod error; pub mod share; -pub mod tui; pub mod tunnel; pub mod types; pub mod util; diff --git a/apps/cli-rs/src/main.rs b/apps/cli-rs/src/main.rs index 1b7abcb5b..a53c04c72 100644 --- a/apps/cli-rs/src/main.rs +++ b/apps/cli-rs/src/main.rs @@ -1,3 +1,5 @@ +use std::process::ExitCode; + use anyhow::Result; use clap::Parser; @@ -6,26 +8,87 @@ use whk::cli::{ self, AuthAction, Cli, Command, NamesAction, RequestsAction, SandboxAction, SharesAction, TeamsAction, }; -use whk::tui; +use whk::error::{classify, error_json}; #[tokio::main] -async fn main() -> Result<()> { +async fn main() -> ExitCode { let args = Cli::parse(); - + let json = args.json; cli::output::set_no_color(args.no_color || std::env::var("NO_COLOR").is_ok()); - let mut client = ApiClient::new(args.api_url.as_deref(), args.webhook_url.as_deref())?; + match run(args).await { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + let c = classify(&error); + if json { + eprintln!("{}", error_json(&error, &c)); + } else { + eprintln!("Error: {error:#}"); + // A hint the message already gives is not repeated. + if let Some(hint) = c.hint.as_deref() + && !format!("{error:#}").contains(hint) + { + eprintln!("{hint}"); + } + } + ExitCode::from(c.exit as u8) + } + } +} - let nogui = args.nogui || std::env::var("WHK_NOGUI").is_ok(); +async fn run(args: Cli) -> Result<()> { + let mut client = ApiClient::new(args.api_url.as_deref(), args.webhook_url.as_deref())?; match args.command { + // A bare `whk` prints help. It used to open a full-screen TUI, which + // hung any agent or script that ran it without a terminal. None => { - if nogui { - use clap::CommandFactory; - Cli::command().print_help()?; - } else { - tui::run(client).await?; - } + use clap::CommandFactory; + // A closed pipe (`whk | head`) is not a failure. + let _ = Cli::command().print_help(); + } + + Some(Command::Wait { + slug, + timeout, + count, + since, + method, + kind, + path, + }) => { + cli::wait::run( + &client, + cli::wait::WaitOptions { + slug, + timeout, + count, + since, + method, + kind, + path, + }, + args.json, + ) + .await?; + } + + Some(Command::Api { + path, + method, + data, + headers, + include, + }) => { + cli::api::run( + &client, + &path, + method.as_deref(), + data.as_deref(), + &headers, + include, + ) + .await?; } Some(Command::Auth { action }) => match action { diff --git a/apps/cli-rs/src/tui/event.rs b/apps/cli-rs/src/tui/event.rs deleted file mode 100644 index 8885ee421..000000000 --- a/apps/cli-rs/src/tui/event.rs +++ /dev/null @@ -1,43 +0,0 @@ -use crossterm::event::{self, Event, KeyEvent, KeyEventKind}; -use std::time::Duration; -use tokio::sync::mpsc; - -/// Terminal events enriched with app-level ticks. -#[derive(Debug, Clone)] -pub enum AppEvent { - Key(KeyEvent), - Resize, - Tick, -} - -/// Spawn a background thread that reads crossterm events and sends them -/// over an mpsc channel. Also sends periodic ticks for animations. -pub fn spawn_event_reader(tick_rate: Duration) -> mpsc::UnboundedReceiver { - let (tx, rx) = mpsc::unbounded_channel(); - - std::thread::spawn(move || { - loop { - if event::poll(tick_rate).unwrap_or(false) { - let next = match event::read() { - Ok(Event::Key(key)) if key.kind == KeyEventKind::Press => { - Some(AppEvent::Key(key)) - } - Ok(Event::Resize(_, _)) => Some(AppEvent::Resize), - _ => None, - }; - if let Some(event) = next - && tx.send(event).is_err() - { - break; - } - } else { - // No event within tick_rate — send a tick for animations - if tx.send(AppEvent::Tick).is_err() { - break; - } - } - } - }); - - rx -} diff --git a/apps/cli-rs/src/tui/keys.rs b/apps/cli-rs/src/tui/keys.rs deleted file mode 100644 index 175e9550a..000000000 --- a/apps/cli-rs/src/tui/keys.rs +++ /dev/null @@ -1,95 +0,0 @@ -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - -pub fn is_quit(key: &KeyEvent) -> bool { - matches!( - key, - KeyEvent { - code: KeyCode::Char('c'), - modifiers: KeyModifiers::CONTROL, - .. - } | KeyEvent { - code: KeyCode::Char('q'), - modifiers: KeyModifiers::NONE, - .. - } - ) -} - -pub fn is_back(key: &KeyEvent) -> bool { - matches!( - key, - KeyEvent { - code: KeyCode::Esc, - .. - } - ) -} - -pub fn is_enter(key: &KeyEvent) -> bool { - matches!( - key, - KeyEvent { - code: KeyCode::Enter, - .. - } - ) -} - -pub fn is_up(key: &KeyEvent) -> bool { - matches!( - key, - KeyEvent { - code: KeyCode::Up, - .. - } | KeyEvent { - code: KeyCode::Char('k'), - modifiers: KeyModifiers::NONE, - .. - } - ) -} - -pub fn is_down(key: &KeyEvent) -> bool { - matches!( - key, - KeyEvent { - code: KeyCode::Down, - .. - } | KeyEvent { - code: KeyCode::Char('j'), - modifiers: KeyModifiers::NONE, - .. - } - ) -} - -pub fn is_tab(key: &KeyEvent) -> bool { - matches!( - key, - KeyEvent { - code: KeyCode::Tab, - .. - } - ) -} - -pub fn is_backtab(key: &KeyEvent) -> bool { - matches!( - key, - KeyEvent { - code: KeyCode::BackTab, - .. - } - ) -} - -pub fn is_char(key: &KeyEvent, c: char) -> bool { - matches!( - key, - KeyEvent { - code: KeyCode::Char(ch), - modifiers: KeyModifiers::NONE, - .. - } if *ch == c - ) -} diff --git a/apps/cli-rs/src/tui/mod.rs b/apps/cli-rs/src/tui/mod.rs deleted file mode 100644 index 031ba86a0..000000000 --- a/apps/cli-rs/src/tui/mod.rs +++ /dev/null @@ -1,300 +0,0 @@ -pub mod event; -pub mod keys; -pub mod screens; -pub mod theme; -pub mod widgets; - -use std::time::Duration; - -use anyhow::Result; -use crossterm::{ - ExecutableCommand, - terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, -}; -use ratatui::{ - DefaultTerminal, Frame, - layout::{Constraint, Layout}, - widgets::Widget, -}; -use tokio::sync::mpsc; - -use crate::api::ApiClient; -use crate::auth; - -use self::event::{AppEvent, spawn_event_reader}; -use self::screens::*; -use self::widgets::header::Header; -use self::widgets::status_bar::StatusBar; - -const TICK_RATE: Duration = Duration::from_millis(100); - -pub async fn run(client: ApiClient) -> Result<()> { - // Install panic hook to restore terminal - let original_hook = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |info| { - let _ = std::io::stdout().execute(LeaveAlternateScreen); - let _ = disable_raw_mode(); - original_hook(info); - })); - - enable_raw_mode()?; - if let Err(e) = std::io::stdout().execute(EnterAlternateScreen) { - let _ = disable_raw_mode(); - return Err(e.into()); - } - let mut terminal = ratatui::init(); - - let result = run_app(&mut terminal, client).await; - - ratatui::restore(); - - result -} - -async fn run_app(terminal: &mut DefaultTerminal, client: ApiClient) -> Result<()> { - let mut event_rx = spawn_event_reader(TICK_RATE); - let (msg_tx, mut msg_rx) = mpsc::unbounded_channel::(); - - // Load auth email - let auth_email = auth::load_token().ok().flatten().map(|t| t.email); - - let mut app = App::new(client, auth_email, msg_tx.clone()); - - loop { - terminal.draw(|frame| app.render(frame))?; - - tokio::select! { - Some(event) = event_rx.recv() => { - match event { - AppEvent::Key(key) => { - if let Some(action) = app.handle_key(&key) { - match action { - Action::Quit => break, - Action::NavigateBack => app.navigate_back(), - Action::Navigate(screen) => app.navigate_to(screen), - Action::SetAuthEmail(email) => app.set_auth_email(email), - } - } - } - AppEvent::Resize => { - // ratatui handles resize automatically - } - AppEvent::Tick => { - app.tick(); - } - } - } - Some(msg) = msg_rx.recv() => { - app.handle_message(msg); - } - } - } - - Ok(()) -} - -struct App { - client: ApiClient, - screen_stack: Vec>, - auth_email: Option, - msg_tx: mpsc::UnboundedSender, - show_help: bool, -} - -impl App { - fn new( - client: ApiClient, - auth_email: Option, - msg_tx: mpsc::UnboundedSender, - ) -> Self { - let menu = Box::new(screens::menu::MenuScreen::new(auth_email.clone())); - Self { - client, - screen_stack: vec![menu], - auth_email, - msg_tx, - show_help: false, - } - } - - fn current_screen(&self) -> &dyn Screen { - self.screen_stack.last().unwrap().as_ref() - } - - fn current_screen_mut(&mut self) -> &mut dyn Screen { - self.screen_stack.last_mut().unwrap().as_mut() - } - - fn handle_key(&mut self, key: &crossterm::event::KeyEvent) -> Option { - // Toggle help overlay with '?' - if key.code == crossterm::event::KeyCode::Char('?') { - self.show_help = !self.show_help; - return None; - } - // Close help with Esc if open - if self.show_help { - if key.code == crossterm::event::KeyCode::Esc { - self.show_help = false; - } - return None; - } - self.current_screen_mut().handle_key(key) - } - - fn handle_message(&mut self, msg: Message) { - self.current_screen_mut().handle_message(msg); - } - - fn tick(&mut self) { - self.current_screen_mut().tick(); - } - - fn navigate_to(&mut self, screen_id: ScreenId) { - let webhook_url = self.client.webhook_url.clone(); - - let mut screen: Box = match screen_id { - ScreenId::Auth => Box::new(screens::auth::AuthScreen::new(self.auth_email.clone())), - ScreenId::Endpoints => Box::new(screens::endpoints::EndpointsScreen::new(webhook_url)), - ScreenId::EndpointDetail(slug) => Box::new( - screens::endpoint_detail::EndpointDetailScreen::new(slug, webhook_url), - ), - ScreenId::Tunnel => Box::new(screens::tunnel::TunnelScreen::new(webhook_url)), - ScreenId::Listen => Box::new(screens::listen::ListenScreen::new(webhook_url)), - ScreenId::RequestDetail(id) => { - Box::new(screens::request_detail::RequestDetailScreen::new(id)) - } - ScreenId::Search => Box::new(screens::search::SearchScreen::new()), - ScreenId::Send => Box::new(screens::send::SendScreen::new()), - ScreenId::Usage => Box::new(screens::usage::UsageScreen::new()), - ScreenId::Update => Box::new(screens::update::UpdateScreen::new()), - }; - - screen.on_enter(&self.client, self.msg_tx.clone()); - self.screen_stack.push(screen); - } - - fn navigate_back(&mut self) { - if self.screen_stack.len() > 1 { - let mut screen = self.screen_stack.pop().unwrap(); - screen.on_leave(); - - // Refresh the menu auth email when returning - if let Some(menu) = self.screen_stack.last_mut() - && let Some(menu_screen) = menu - .as_any_mut() - .downcast_mut::() - { - menu_screen.set_auth_email(self.auth_email.clone()); - } - } - } - - fn set_auth_email(&mut self, email: Option) { - self.auth_email = email; - } - - fn render(&mut self, frame: &mut Frame) { - let area = frame.area(); - - // Background - frame.render_widget( - ratatui::widgets::Block::default().style(theme::style_surface()), - area, - ); - - let chunks = Layout::vertical([ - Constraint::Length(2), // Header - Constraint::Min(0), // Content - Constraint::Length(2), // Status bar - ]) - .split(area); - - // Header - let breadcrumb = self.current_screen().breadcrumb(); - let header = Header::new(breadcrumb).auth_status(self.auth_email.as_deref()); - header.render(chunks[0], frame.buffer_mut()); - - // Content - self.current_screen_mut().render(frame, chunks[1]); - - // Help overlay - if self.show_help { - render_help_overlay(frame, chunks[1]); - } - - // Status bar - let mut keys = self.current_screen().status_keys(); - keys.push(("?", "help")); - let version = format!("v{}", env!("WHK_VERSION")); - let bar = StatusBar::new(keys).right(&version); - bar.render(chunks[2], frame.buffer_mut()); - } -} - -fn render_help_overlay(frame: &mut Frame, area: ratatui::layout::Rect) { - use ratatui::style::{Modifier, Style}; - use ratatui::text::{Line, Span}; - use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph}; - - let help_items: &[(&str, &str)] = &[ - ("↑ / k", "Move up"), - ("↓ / j", "Move down"), - ("Enter", "Select / confirm"), - ("Esc", "Go back / cancel"), - ("Tab", "Next field / tab"), - ("Shift+Tab", "Previous field / tab"), - ("1-3", "Jump to tab (detail view)"), - ("n", "New endpoint"), - ("d", "Delete endpoint"), - ("r", "Refresh"), - ("c", "Copy to clipboard"), - ("q / Ctrl+C", "Quit"), - ("?", "Toggle this help"), - ]; - - // Center the overlay - let width: u16 = 44; - let height = (help_items.len() as u16) + 4; - let x = area.x + area.width.saturating_sub(width) / 2; - let y = area.y + area.height.saturating_sub(height) / 2; - let overlay = ratatui::layout::Rect::new( - x.max(area.x), - y.max(area.y), - width.min(area.width), - height.min(area.height), - ); - - // Clear background - frame.render_widget(Clear, overlay); - - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::PRIMARY)) - .title(Span::styled( - " Keyboard Shortcuts ", - Style::default() - .fg(theme::PRIMARY) - .add_modifier(Modifier::BOLD), - )) - .padding(Padding::new(2, 2, 1, 0)); - - let inner = block.inner(overlay); - frame.render_widget(block, overlay); - - let lines: Vec = help_items - .iter() - .map(|(key, desc)| { - Line::from(vec![ - Span::styled( - format!("{:<14}", key), - Style::default() - .fg(theme::ACCENT) - .add_modifier(Modifier::BOLD), - ), - Span::styled(*desc, theme::style_dim()), - ]) - }) - .collect(); - - frame.render_widget(Paragraph::new(lines), inner); -} diff --git a/apps/cli-rs/src/tui/screens/auth.rs b/apps/cli-rs/src/tui/screens/auth.rs deleted file mode 100644 index c5961f4d2..000000000 --- a/apps/cli-rs/src/tui/screens/auth.rs +++ /dev/null @@ -1,398 +0,0 @@ -use crossterm::event::KeyEvent; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Padding, Paragraph}, -}; -use tokio::sync::mpsc; - -use crate::api::ApiClient; -use crate::auth; -use crate::tui::{keys, theme}; -use crate::types::Token; - -use super::{Action, Message, Screen}; - -enum State { - Idle, - Polling { - device_code: String, - user_code: String, - verification_url: String, - }, - Success(String), // email - Error(String), -} - -pub struct AuthScreen { - state: State, - auth_email: Option, - tick: usize, - tx: Option>, - client: Option, - tasks: Vec>, -} - -impl AuthScreen { - pub fn new(auth_email: Option) -> Self { - Self { - state: State::Idle, - auth_email, - tick: 0, - tx: None, - client: None, - tasks: Vec::new(), - } - } -} - -impl Screen for AuthScreen { - fn handle_key(&mut self, key: &KeyEvent) -> Option { - if keys::is_back(key) { - return Some(Action::NavigateBack); - } - if keys::is_quit(key) { - return Some(Action::Quit); - } - - match &self.state { - State::Idle => { - if self.auth_email.is_some() { - // Logged in — press 'o' to logout - if keys::is_char(key, 'o') { - if let Err(e) = auth::clear_token() { - self.state = State::Error(format!("Failed to clear token: {e}")); - return None; - } - self.auth_email = None; - self.state = State::Idle; - return Some(Action::SetAuthEmail(None)); - } - } else { - // Not logged in — press 'l' to login - if keys::is_char(key, 'l') - && let (Some(tx), Some(client)) = (&self.tx, &self.client) - { - let tx = tx.clone(); - let client = client.clone(); - let handle = tokio::spawn(async move { - let result = client.create_device_code().await; - let _ = tx.send(Message::DeviceCode(result)); - }); - self.tasks.push(handle); - } - } - } - State::Polling { user_code, .. } => { - if keys::is_char(key, 'c') { - copy_to_clipboard(user_code); - } - } - State::Success(email) => { - if keys::is_enter(key) { - let email = email.clone(); - self.state = State::Idle; - return Some(Action::SetAuthEmail(Some(email))); - } - } - State::Error(_) => { - if keys::is_enter(key) { - self.state = State::Idle; - } - } - } - - None - } - - fn handle_message(&mut self, msg: Message) { - match msg { - Message::DeviceCode(Ok(device)) => { - if device.verification_url.starts_with("https://") { - let _ = open::that(&device.verification_url); - } - - let dc = device.device_code.clone(); - self.state = State::Polling { - device_code: device.device_code, - user_code: device.user_code, - verification_url: device.verification_url, - }; - - // Start polling - if let (Some(tx), Some(client)) = (&self.tx, &self.client) { - let tx = tx.clone(); - let client = client.clone(); - let handle = tokio::spawn(async move { - loop { - tokio::time::sleep(std::time::Duration::from_secs(5)).await; - let result = client.poll_device_code(&dc).await; - let done = matches!(&result, Ok(r) if r.status != "pending"); - let _ = tx.send(Message::AuthPoll(result)); - if done { - break; - } - } - }); - self.tasks.push(handle); - } - } - Message::DeviceCode(Err(e)) => { - self.state = State::Error(e.to_string()); - } - Message::AuthPoll(Ok(poll)) => { - if poll.status == "authorized" { - // Claim the code - if let State::Polling { - ref device_code, .. - } = self.state - { - let dc = device_code.clone(); - if let (Some(tx), Some(client)) = (&self.tx, &self.client) { - let tx = tx.clone(); - let client = client.clone(); - let handle = tokio::spawn(async move { - let result = client.claim_device_code(&dc).await; - let _ = tx.send(Message::AuthClaimed(result)); - }); - self.tasks.push(handle); - } - } - } else if poll.status == "expired" { - self.state = State::Error("Code expired. Press Enter and try again.".into()); - } - } - Message::AuthPoll(Err(e)) => { - self.state = State::Error(e.to_string()); - } - Message::AuthClaimed(Ok(claim)) => { - let token = Token { - access_token: claim.api_key, - user_id: claim.user_id, - email: claim.email.clone(), - }; - if let Err(e) = auth::save_token(&token) { - self.state = State::Error(format!("Failed to save token: {e}")); - return; - } - self.auth_email = Some(claim.email.clone()); - self.state = State::Success(claim.email); - } - Message::AuthClaimed(Err(e)) => { - self.state = State::Error(e.to_string()); - } - _ => {} - } - } - - fn render(&mut self, frame: &mut Frame, area: Rect) { - let chunks = Layout::vertical([Constraint::Length(2), Constraint::Min(0)]).split(area); - - let content_area = centered_rect(60, 12, chunks[1]); - - match &self.state { - State::Idle => { - if let Some(ref email) = self.auth_email { - let lines = vec![ - Line::from(""), - Line::from(vec![ - Span::styled(" Logged in as ", theme::style_dim()), - Span::styled(email, theme::style_bold()), - ]), - Line::from(""), - Line::from(Span::styled(" Press o to logout", theme::style_muted())), - ]; - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::SUCCESS)) - .title(Span::styled(" Authenticated ", theme::style_success())) - .padding(Padding::vertical(1)); - frame.render_widget(Paragraph::new(lines).block(block), content_area); - } else { - let lines = vec![ - Line::from(""), - Line::from(Span::styled(" Not logged in", theme::style_muted())), - Line::from(""), - Line::from(Span::styled( - " Press l to login via browser", - theme::style_dim(), - )), - ]; - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::BORDER)) - .title(Span::styled(" Authentication ", theme::style_bold())) - .padding(Padding::vertical(1)); - frame.render_widget(Paragraph::new(lines).block(block), content_area); - } - } - State::Polling { - user_code, - verification_url, - .. - } => { - let lines = vec![ - Line::from(""), - Line::from(vec![ - Span::styled(" Your code: ", theme::style_dim()), - Span::styled( - user_code, - Style::default() - .fg(theme::PRIMARY) - .add_modifier(Modifier::BOLD), - ), - ]), - Line::from(""), - Line::from(vec![ - Span::styled(" Open ", theme::style_dim()), - Span::styled(verification_url, Style::default().fg(theme::ACCENT)), - ]), - Line::from(Span::styled( - " and enter the code above.", - theme::style_dim(), - )), - Line::from(""), - Line::from(Span::styled( - format!( - " {} Waiting for authorization...", - spinner_frame(self.tick) - ), - theme::style_primary(), - )), - ]; - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::PRIMARY)) - .title(Span::styled(" Login ", theme::style_primary_bold())) - .padding(Padding::vertical(1)); - frame.render_widget(Paragraph::new(lines).block(block), content_area); - } - State::Success(email) => { - let lines = vec![ - Line::from(""), - Line::from(vec![ - Span::styled(" ✓ ", theme::style_success()), - Span::styled("Logged in as ", theme::style_dim()), - Span::styled(email, theme::style_bold()), - ]), - Line::from(""), - Line::from(Span::styled( - " Press Enter to continue.", - theme::style_muted(), - )), - ]; - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::SUCCESS)) - .title(Span::styled(" Success ", theme::style_success())) - .padding(Padding::vertical(1)); - frame.render_widget(Paragraph::new(lines).block(block), content_area); - } - State::Error(msg) => { - let lines = vec![ - Line::from(""), - Line::from(vec![ - Span::styled(" ✗ ", theme::style_danger()), - Span::styled(msg, theme::style_dim()), - ]), - Line::from(""), - Line::from(Span::styled( - " Press Enter to try again.", - theme::style_muted(), - )), - ]; - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::DANGER)) - .title(Span::styled(" Error ", theme::style_danger())) - .padding(Padding::vertical(1)); - frame.render_widget(Paragraph::new(lines).block(block), content_area); - } - } - } - - fn on_enter(&mut self, client: &ApiClient, tx: mpsc::UnboundedSender) { - self.tx = Some(tx); - self.client = Some(client.clone()); - } - - fn on_leave(&mut self) { - for handle in self.tasks.drain(..) { - handle.abort(); - } - self.tx = None; - } - - fn breadcrumb(&self) -> Vec<&str> { - vec!["Auth"] - } - - fn status_keys(&self) -> Vec<(&str, &str)> { - if self.auth_email.is_some() { - vec![("o", "logout"), ("esc", "back")] - } else { - match &self.state { - State::Idle => vec![("l", "login"), ("esc", "back")], - State::Polling { .. } => vec![("c", "copy code"), ("esc", "cancel")], - _ => vec![("esc", "cancel")], - } - } - } - - fn tick(&mut self) { - self.tick += 1; - } - - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self - } -} - -fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { - let x = area.x + area.width.saturating_sub(width) / 2; - let y = area.y + area.height.saturating_sub(height) / 2; - Rect::new( - x.max(area.x), - y.max(area.y), - width.min(area.width), - height.min(area.height), - ) -} - -const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - -fn spinner_frame(tick: usize) -> &'static str { - SPINNER_FRAMES[tick % SPINNER_FRAMES.len()] -} - -fn copy_to_clipboard(text: &str) { - #[cfg(target_os = "macos")] - { - use std::process::{Command, Stdio}; - if let Ok(mut child) = Command::new("pbcopy").stdin(Stdio::piped()).spawn() { - if let Some(mut stdin) = child.stdin.take() { - use std::io::Write; - let _ = stdin.write_all(text.as_bytes()); - } - let _ = child.wait(); - } - } - #[cfg(target_os = "linux")] - { - use std::process::{Command, Stdio}; - // Try xclip first, then xsel - let result = Command::new("xclip") - .args(["-selection", "clipboard"]) - .stdin(Stdio::piped()) - .spawn(); - if let Ok(mut child) = result { - if let Some(mut stdin) = child.stdin.take() { - use std::io::Write; - let _ = stdin.write_all(text.as_bytes()); - } - let _ = child.wait(); - } - } -} diff --git a/apps/cli-rs/src/tui/screens/endpoint_detail.rs b/apps/cli-rs/src/tui/screens/endpoint_detail.rs deleted file mode 100644 index b4cdd148f..000000000 --- a/apps/cli-rs/src/tui/screens/endpoint_detail.rs +++ /dev/null @@ -1,242 +0,0 @@ -use crossterm::event::KeyEvent; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - style::Style, - text::{Line, Span}, - widgets::{Block, Borders, Padding, Paragraph}, -}; -use tokio::sync::mpsc; - -use crate::api::ApiClient; -use crate::tui::widgets::request_list::{RequestList, RequestListState}; -use crate::tui::widgets::spinner::Spinner; -use crate::tui::{keys, theme}; -use crate::types::Endpoint; - -use super::{Action, Message, Screen, ScreenId}; - -enum State { - Loading, - Loaded, - Error(String), -} - -pub struct EndpointDetailScreen { - slug: String, - state: State, - endpoint: Option, - requests: RequestListState, - webhook_url: String, - tx: Option>, - client: Option, - tasks: Vec>, - tick: usize, -} - -impl EndpointDetailScreen { - pub fn new(slug: String, webhook_url: String) -> Self { - Self { - slug, - state: State::Loading, - endpoint: None, - requests: RequestListState::new(), - webhook_url, - tx: None, - client: None, - tasks: Vec::new(), - tick: 0, - } - } -} - -impl Screen for EndpointDetailScreen { - fn handle_key(&mut self, key: &KeyEvent) -> Option { - if keys::is_back(key) { - return Some(Action::NavigateBack); - } - if keys::is_quit(key) { - return Some(Action::Quit); - } - - if keys::is_up(key) { - self.requests.select_prev(); - return None; - } - if keys::is_down(key) { - self.requests.select_next(); - return None; - } - - if keys::is_enter(key) { - if let Some(req) = self.requests.selected_item() { - return Some(Action::Navigate(ScreenId::RequestDetail(req.id.clone()))); - } - return None; - } - - // 'r' to refresh - if keys::is_char(key, 'r') { - self.load_data(); - return None; - } - - None - } - - fn handle_message(&mut self, msg: Message) { - match msg { - Message::EndpointLoaded(Ok(ep)) => { - self.endpoint = Some(ep); - self.state = State::Loaded; - } - Message::EndpointLoaded(Err(e)) => { - self.state = State::Error(e.to_string()); - } - Message::RequestsLoaded(Ok(list)) => { - self.requests.items = list.requests; - if !self.requests.items.is_empty() && self.requests.selected == 0 { - self.requests.selected = 0; - } - } - Message::RequestsLoaded(Err(_)) => {} - _ => {} - } - } - - fn render(&mut self, frame: &mut Frame, area: Rect) { - if let State::Loading = &self.state { - frame.render_widget( - Spinner::new(self.tick, "Loading endpoint..."), - Rect::new(area.x + 2, area.y + 1, area.width.saturating_sub(4), 1), - ); - return; - } - - if let State::Error(msg) = &self.state { - let p = Paragraph::new(Line::from(vec![ - Span::styled(" Error: ", theme::style_danger()), - Span::styled(msg.as_str(), theme::style_dim()), - ])); - frame.render_widget(p, area); - return; - } - - let chunks = Layout::vertical([ - Constraint::Length(6), // Endpoint info - Constraint::Min(8), // Request list - ]) - .split(area); - - // Endpoint info panel - if let Some(ref ep) = self.endpoint { - let url = format!("{}/w/{}", self.webhook_url, ep.slug); - let mut lines = vec![ - Line::from(vec![ - Span::styled(" URL: ", theme::style_muted()), - Span::styled(&url, theme::style_bold()), - ]), - Line::from(vec![ - Span::styled(" Name: ", theme::style_muted()), - Span::styled(ep.name.as_deref().unwrap_or("—"), theme::style()), - ]), - Line::from(vec![ - Span::styled(" Requests: ", theme::style_muted()), - Span::styled(ep.request_count.unwrap_or(0).to_string(), theme::style()), - ]), - ]; - - if let Some(ref mock) = ep.mock_response { - lines.push(Line::from(vec![ - Span::styled(" Mock: ", theme::style_muted()), - Span::styled( - format!( - "{} — {}", - mock.status, - mock.body.chars().take(40).collect::() - ), - Style::default().fg(theme::ACCENT), - ), - ])); - } - - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::BORDER)) - .title(Span::styled( - format!(" {} ", ep.slug), - theme::style_primary_bold(), - )) - .padding(Padding::new(0, 0, 0, 0)); - - frame.render_widget(Paragraph::new(lines).block(block), chunks[0]); - } - - // Request list - let list = RequestList::new("Recent Requests"); - frame.render_stateful_widget(list, chunks[1], &mut self.requests); - } - - fn on_enter(&mut self, client: &ApiClient, tx: mpsc::UnboundedSender) { - self.tx = Some(tx); - self.client = Some(client.clone()); - self.load_data(); - } - - fn on_leave(&mut self) { - for handle in self.tasks.drain(..) { - handle.abort(); - } - self.tx = None; - } - - fn breadcrumb(&self) -> Vec<&str> { - vec!["Endpoints", &self.slug] - } - - fn status_keys(&self) -> Vec<(&str, &str)> { - vec![ - ("↑↓", "navigate"), - ("enter", "inspect"), - ("r", "refresh"), - ("esc", "back"), - ] - } - - fn tick(&mut self) { - self.tick += 1; - } - - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self - } -} - -impl EndpointDetailScreen { - fn load_data(&mut self) { - // Abort any in-flight tasks before spawning new ones - for handle in self.tasks.drain(..) { - handle.abort(); - } - self.state = State::Loading; - if let (Some(tx), Some(client)) = (&self.tx, &self.client) { - let tx1 = tx.clone(); - let tx2 = tx.clone(); - let slug = self.slug.clone(); - let slug2 = self.slug.clone(); - let client = client.clone(); - let c2 = client.clone(); - - let h1 = tokio::spawn(async move { - let result = client.get_endpoint(&slug).await; - let _ = tx1.send(Message::EndpointLoaded(result)); - }); - let h2 = tokio::spawn(async move { - let result = c2.list_requests(&slug2, Some(50), None).await; - let _ = tx2.send(Message::RequestsLoaded(result)); - }); - self.tasks.push(h1); - self.tasks.push(h2); - } - } -} diff --git a/apps/cli-rs/src/tui/screens/endpoints.rs b/apps/cli-rs/src/tui/screens/endpoints.rs deleted file mode 100644 index 8161e4b5d..000000000 --- a/apps/cli-rs/src/tui/screens/endpoints.rs +++ /dev/null @@ -1,385 +0,0 @@ -use crossterm::event::{KeyCode, KeyEvent}; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - style::Style, - text::{Line, Span}, - widgets::{Block, Borders, Padding, Paragraph, Row, Table, TableState}, -}; -use tokio::sync::mpsc; - -use crate::api::ApiClient; -use crate::tui::widgets::spinner::Spinner; -use crate::tui::{keys, theme}; -use crate::types::{CreateEndpointRequest, Endpoint, favorites_first}; - -use super::{Action, Message, Screen, ScreenId, favorite_marker}; - -enum State { - Loading, - Loaded, - Creating(String), // input buffer - Deleting(usize), // index to delete - Error(String), -} - -pub struct EndpointsScreen { - state: State, - endpoints: Vec, - table_state: TableState, - webhook_url: String, - tx: Option>, - client: Option, - tasks: Vec>, - tick: usize, -} - -impl EndpointsScreen { - pub fn new(webhook_url: String) -> Self { - Self { - state: State::Loading, - endpoints: Vec::new(), - table_state: TableState::default(), - webhook_url, - tx: None, - client: None, - tasks: Vec::new(), - tick: 0, - } - } -} - -impl Screen for EndpointsScreen { - fn handle_key(&mut self, key: &KeyEvent) -> Option { - // Handle creating state first - if let State::Creating(ref mut input) = self.state { - match key.code { - KeyCode::Enter => { - let name = if input.is_empty() { - None - } else { - Some(input.clone()) - }; - if let (Some(tx), Some(client)) = (&self.tx, &self.client) { - let tx = tx.clone(); - let client = client.clone(); - let handle = tokio::spawn(async move { - let req = CreateEndpointRequest { - name, - is_ephemeral: None, - expires_at: None, - mock_response: None, - }; - let result = client.create_endpoint(&req).await; - let _ = tx.send(Message::EndpointCreated(result)); - }); - self.tasks.push(handle); - } - self.state = State::Loading; - return None; - } - KeyCode::Esc => { - self.state = State::Loaded; - return None; - } - KeyCode::Char(c) => { - input.push(c); - return None; - } - KeyCode::Backspace => { - input.pop(); - return None; - } - _ => return None, - } - } - - // Handle confirming delete - if let State::Deleting(idx) = self.state { - if keys::is_char(key, 'y') { - if let Some(ep) = self.endpoints.get(idx) { - let slug = ep.slug.clone(); - if let (Some(tx), Some(client)) = (&self.tx, &self.client) { - let tx = tx.clone(); - let client = client.clone(); - let handle = tokio::spawn(async move { - let result = client.delete_endpoint(&slug).await.map(|_| slug); - let _ = tx.send(Message::EndpointDeleted(result)); - }); - self.tasks.push(handle); - } - } - self.state = State::Loading; - return None; - } else { - self.state = State::Loaded; - return None; - } - } - - if keys::is_back(key) { - return Some(Action::NavigateBack); - } - if keys::is_quit(key) { - return Some(Action::Quit); - } - - if keys::is_up(key) { - let i = self.table_state.selected().unwrap_or(0); - self.table_state.select(Some(i.saturating_sub(1))); - return None; - } - if keys::is_down(key) { - let i = self.table_state.selected().unwrap_or(0); - let max = self.endpoints.len().saturating_sub(1); - self.table_state.select(Some((i + 1).min(max))); - return None; - } - - if keys::is_enter(key) { - if let Some(i) = self.table_state.selected() - && let Some(ep) = self.endpoints.get(i) - { - return Some(Action::Navigate(ScreenId::EndpointDetail(ep.slug.clone()))); - } - return None; - } - - // 'n' for new - if keys::is_char(key, 'n') { - self.state = State::Creating(String::new()); - return None; - } - - // 'd' for delete - if keys::is_char(key, 'd') { - if let Some(i) = self.table_state.selected() { - self.state = State::Deleting(i); - } - return None; - } - - // 'r' to refresh - if keys::is_char(key, 'r') { - if let (Some(tx), Some(client)) = (&self.tx, &self.client) { - self.state = State::Loading; - let tx = tx.clone(); - let client = client.clone(); - let handle = tokio::spawn(async move { - let result = client.list_endpoints().await; - let _ = tx.send(Message::EndpointsLoaded(result)); - }); - self.tasks.push(handle); - } - return None; - } - - None - } - - fn handle_message(&mut self, msg: Message) { - match msg { - Message::EndpointsLoaded(Ok(list)) => { - // Keep the highlight on the same endpoint: a reload can move - // it, since favourites are listed first. - let selected_slug = self - .table_state - .selected() - .and_then(|i| self.endpoints.get(i)) - .map(|ep| ep.slug.clone()); - self.endpoints = list.owned; - self.endpoints.extend(list.shared); - self.endpoints.sort_by(|a, b| a.slug.cmp(&b.slug)); - favorites_first(&mut self.endpoints); - let kept = selected_slug - .and_then(|slug| self.endpoints.iter().position(|ep| ep.slug == slug)); - if kept.is_some() { - self.table_state.select(kept); - } else if self.endpoints.is_empty() { - self.table_state.select(None); - } else if self - .table_state - .selected() - .is_none_or(|i| i >= self.endpoints.len()) - { - self.table_state.select(Some(0)); - } - self.state = State::Loaded; - } - Message::EndpointsLoaded(Err(e)) => { - self.state = State::Error(e.to_string()); - } - Message::EndpointCreated(Ok(_ep)) => { - // Refresh the list - if let (Some(tx), Some(client)) = (&self.tx, &self.client) { - let tx = tx.clone(); - let client = client.clone(); - let handle = tokio::spawn(async move { - let result = client.list_endpoints().await; - let _ = tx.send(Message::EndpointsLoaded(result)); - }); - self.tasks.push(handle); - } - } - Message::EndpointCreated(Err(e)) => { - self.state = State::Error(e.to_string()); - } - Message::EndpointDeleted(Ok(_slug)) => { - // Refresh - if let (Some(tx), Some(client)) = (&self.tx, &self.client) { - let tx = tx.clone(); - let client = client.clone(); - let handle = tokio::spawn(async move { - let result = client.list_endpoints().await; - let _ = tx.send(Message::EndpointsLoaded(result)); - }); - self.tasks.push(handle); - } - } - Message::EndpointDeleted(Err(e)) => { - self.state = State::Error(e.to_string()); - } - _ => {} - } - } - - fn render(&mut self, frame: &mut Frame, area: Rect) { - match &self.state { - State::Loading => { - frame.render_widget( - Spinner::new(self.tick, "Loading endpoints..."), - Rect::new(area.x + 2, area.y + 1, area.width.saturating_sub(4), 1), - ); - return; - } - State::Error(msg) => { - let p = Paragraph::new(Line::from(vec![ - Span::styled(" Error: ", theme::style_danger()), - Span::styled(msg, theme::style_dim()), - ])); - frame.render_widget(p, area); - return; - } - _ => {} - } - - let chunks = Layout::vertical([ - Constraint::Min(0), // Table - Constraint::Length(2), // Footer - ]) - .split(area); - - // Endpoint table - let header = - Row::new(vec![" SLUG", "NAME", "REQUESTS", "URL"]).style(theme::style_muted()); - - let rows: Vec = self - .endpoints - .iter() - .map(|ep| { - let name = ep.name.as_deref().unwrap_or("—"); - let url = format!("{}/w/{}", self.webhook_url, ep.slug); - let count = ep.request_count.unwrap_or(0).to_string(); - Row::new(vec![ - format!("{} {}", favorite_marker(ep), ep.slug), - name.to_string(), - count, - url, - ]) - }) - .collect(); - - let widths = [ - Constraint::Length(22), - Constraint::Length(20), - Constraint::Length(10), - Constraint::Min(30), - ]; - - let table = Table::new(rows, widths) - .header(header) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::BORDER)) - .title(Span::styled( - format!(" Endpoints ({}) ", self.endpoints.len()), - theme::style_bold(), - )) - .padding(Padding::vertical(0)), - ) - .row_highlight_style(theme::style_highlight()); - - frame.render_stateful_widget(table, chunks[0], &mut self.table_state); - - // Creating / Deleting overlay - match &self.state { - State::Creating(input) => { - let line = Line::from(vec![ - Span::styled(" Name: ", theme::style_dim()), - Span::styled(input, theme::style_bold()), - Span::styled("█", theme::style_primary()), - Span::styled(" (Enter to create, Esc to cancel)", theme::style_muted()), - ]); - frame.render_widget(Paragraph::new(line), chunks[1]); - } - State::Deleting(idx) => { - let slug = self - .endpoints - .get(*idx) - .map(|e| e.slug.as_str()) - .unwrap_or("?"); - let line = Line::from(vec![ - Span::styled(" Delete ", theme::style_danger()), - Span::styled(slug, theme::style_bold()), - Span::styled("? (y/n)", theme::style_dim()), - ]); - frame.render_widget(Paragraph::new(line), chunks[1]); - } - _ => {} - } - } - - fn on_enter(&mut self, client: &ApiClient, tx: mpsc::UnboundedSender) { - self.tx = Some(tx.clone()); - self.client = Some(client.clone()); - self.state = State::Loading; - - let client = client.clone(); - let handle = tokio::spawn(async move { - let result = client.list_endpoints().await; - let _ = tx.send(Message::EndpointsLoaded(result)); - }); - self.tasks.push(handle); - } - - fn on_leave(&mut self) { - for handle in self.tasks.drain(..) { - handle.abort(); - } - self.tx = None; - } - - fn breadcrumb(&self) -> Vec<&str> { - vec!["Endpoints"] - } - - fn status_keys(&self) -> Vec<(&str, &str)> { - vec![ - ("↑↓", "navigate"), - ("enter", "detail"), - ("n", "new"), - ("d", "delete"), - ("r", "refresh"), - ("esc", "back"), - ] - } - - fn tick(&mut self) { - self.tick += 1; - } - - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self - } -} diff --git a/apps/cli-rs/src/tui/screens/listen.rs b/apps/cli-rs/src/tui/screens/listen.rs deleted file mode 100644 index 24701f142..000000000 --- a/apps/cli-rs/src/tui/screens/listen.rs +++ /dev/null @@ -1,333 +0,0 @@ -use crossterm::event::KeyEvent; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - style::Style, - text::{Line, Span}, - widgets::{Block, Borders, Paragraph, Row, Table, TableState}, -}; -use tokio::sync::mpsc; - -use crate::api::ApiClient; -use crate::api::stream::join_stream; -use crate::tui::widgets::request_list::{RequestList, RequestListState}; -use crate::tui::widgets::spinner::Spinner; -use crate::tui::{keys, theme}; -use crate::types::{Endpoint, SseEvent, favorites_first}; - -use super::{Action, Message, Screen, ScreenId, favorite_marker}; - -enum State { - LoadingEndpoints, - Picking, - Connecting, - Streaming, - Error(String), -} - -pub struct ListenScreen { - state: State, - endpoints: Vec, - table_state: TableState, - slug: Option, - requests: RequestListState, - /// Stream status line (set while reconnecting, cleared on reconnect). - stream_status: Option, - webhook_url: String, - tx: Option>, - client: Option, - tasks: Vec>, - tick: usize, -} - -impl ListenScreen { - pub fn new(webhook_url: String) -> Self { - Self { - state: State::LoadingEndpoints, - endpoints: Vec::new(), - table_state: TableState::default(), - slug: None, - requests: RequestListState::new(), - stream_status: None, - webhook_url, - tx: None, - client: None, - tasks: Vec::new(), - tick: 0, - } - } -} - -impl Screen for ListenScreen { - fn handle_key(&mut self, key: &KeyEvent) -> Option { - if keys::is_quit(key) { - return Some(Action::Quit); - } - - match &self.state { - State::Picking => { - if keys::is_back(key) { - return Some(Action::NavigateBack); - } - if keys::is_up(key) { - let i = self.table_state.selected().unwrap_or(0); - self.table_state.select(Some(i.saturating_sub(1))); - return None; - } - if keys::is_down(key) { - let i = self.table_state.selected().unwrap_or(0); - let max = self.endpoints.len().saturating_sub(1); - self.table_state.select(Some((i + 1).min(max))); - return None; - } - if keys::is_enter(key) { - if let Some(i) = self.table_state.selected() - && let Some(ep) = self.endpoints.get(i) - { - self.slug = Some(ep.slug.clone()); - self.state = State::Connecting; - self.start_stream(); - } - return None; - } - } - State::Streaming => { - if keys::is_back(key) { - return Some(Action::NavigateBack); - } - if keys::is_up(key) { - self.requests.select_prev(); - return None; - } - if keys::is_down(key) { - self.requests.select_next(); - return None; - } - if keys::is_enter(key) - && let Some(req) = self.requests.selected_item() - { - return Some(Action::Navigate(ScreenId::RequestDetail(req.id.clone()))); - } - } - State::Error(_) => { - if keys::is_back(key) || keys::is_enter(key) { - return Some(Action::NavigateBack); - } - } - _ => { - if keys::is_back(key) { - return Some(Action::NavigateBack); - } - } - } - - None - } - - fn handle_message(&mut self, msg: Message) { - match msg { - Message::EndpointsLoaded(Ok(list)) => { - self.endpoints = list.owned; - self.endpoints.extend(list.shared); - favorites_first(&mut self.endpoints); - if !self.endpoints.is_empty() { - self.table_state.select(Some(0)); - } - self.state = State::Picking; - } - Message::EndpointsLoaded(Err(e)) => { - self.state = State::Error(e.to_string()); - } - Message::SseEvent(SseEvent::Connected) => { - self.stream_status = None; - self.state = State::Streaming; - } - Message::SseEvent(SseEvent::Request(req)) => { - self.state = State::Streaming; - self.requests.push(*req); - } - Message::SseEvent(SseEvent::EndpointDeleted) => { - self.state = State::Error("Endpoint was deleted.".into()); - } - Message::SseEvent(SseEvent::Reconnecting { - attempt, - delay_ms, - reason, - }) => { - let secs = (delay_ms + 500) / 1000; - self.stream_status = Some(format!( - "Reconnecting in {secs}s (attempt {attempt}): {reason}" - )); - } - Message::StreamClosed(Err(e)) => { - self.stream_status = None; - self.state = State::Error(format!("Stream failed: {e:#}")); - } - _ => {} - } - } - - fn render(&mut self, frame: &mut Frame, area: Rect) { - match &self.state { - State::LoadingEndpoints => { - frame.render_widget( - Spinner::new(self.tick, "Loading endpoints..."), - Rect::new(area.x + 2, area.y + 1, area.width.saturating_sub(4), 1), - ); - } - State::Picking => { - let rows: Vec = self - .endpoints - .iter() - .map(|ep| { - Row::new(vec![ - format!("{} {}", favorite_marker(ep), ep.slug), - ep.name.clone().unwrap_or_else(|| "—".into()), - ]) - }) - .collect(); - - let header = Row::new(vec![" SLUG", "NAME"]).style(theme::style_muted()); - let widths = [Constraint::Length(24), Constraint::Min(20)]; - - let table = Table::new(rows, widths) - .header(header) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::BORDER)) - .title(Span::styled(" Select Endpoint ", theme::style_bold())), - ) - .row_highlight_style(theme::style_highlight()); - - frame.render_stateful_widget(table, area, &mut self.table_state); - } - State::Connecting => { - frame.render_widget( - Spinner::new(self.tick, "Connecting to stream..."), - Rect::new(area.x + 2, area.y + 1, area.width.saturating_sub(4), 1), - ); - } - State::Streaming => { - let chunks = Layout::vertical([ - Constraint::Length(3), // Info - Constraint::Min(8), // Request list - ]) - .split(area); - - let slug = self.slug.as_deref().unwrap_or("—"); - let url = format!("{}/w/{}", self.webhook_url, slug); - - let mut lines = vec![ - Line::from(vec![ - Span::styled(" ● ", theme::style_success()), - Span::styled(format!("Listening on {slug}"), theme::style_success()), - ]), - Line::from(vec![ - Span::styled(" Webhook URL: ", theme::style_muted()), - Span::styled(&url, theme::style_bold()), - ]), - ]; - if let Some(status) = &self.stream_status { - lines.push(Line::from(vec![ - Span::styled(" ● ", theme::style_primary()), - Span::styled(status.as_str(), theme::style_dim()), - ])); - } - let info = Paragraph::new(lines); - frame.render_widget(info, chunks[0]); - - let list = RequestList::new("Incoming Requests"); - frame.render_stateful_widget(list, chunks[1], &mut self.requests); - } - State::Error(msg) => { - let p = Paragraph::new(vec![ - Line::from(""), - Line::from(vec![ - Span::styled(" ✗ ", theme::style_danger()), - Span::styled(msg.as_str(), theme::style_dim()), - ]), - ]); - frame.render_widget(p, area); - } - } - } - - fn on_enter(&mut self, client: &ApiClient, tx: mpsc::UnboundedSender) { - self.tx = Some(tx.clone()); - self.client = Some(client.clone()); - - if self.slug.is_some() { - // Direct slug — start streaming - self.start_stream(); - } else { - // Load endpoints for picker - let client = client.clone(); - let handle = tokio::spawn(async move { - let result = client.list_endpoints().await; - let _ = tx.send(Message::EndpointsLoaded(result)); - }); - self.tasks.push(handle); - } - } - - fn on_leave(&mut self) { - for handle in self.tasks.drain(..) { - handle.abort(); - } - self.tx = None; - } - - fn breadcrumb(&self) -> Vec<&str> { - match &self.slug { - Some(_) => vec!["Listen"], - None => vec!["Listen"], - } - } - - fn status_keys(&self) -> Vec<(&str, &str)> { - match &self.state { - State::Picking => vec![("↑↓", "navigate"), ("enter", "select"), ("esc", "back")], - State::Streaming => vec![("↑↓", "navigate"), ("enter", "inspect"), ("esc", "stop")], - _ => vec![("esc", "back")], - } - } - - fn tick(&mut self) { - self.tick += 1; - } - - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self - } -} - -impl ListenScreen { - fn start_stream(&mut self) { - if let (Some(slug), Some(tx), Some(client)) = (&self.slug, &self.tx, &self.client) { - let slug = slug.clone(); - let tx = tx.clone(); - let client = client.clone(); - - let handle = tokio::spawn(async move { - let (sse_tx, mut sse_rx) = mpsc::channel(64); - let stream_handle = tokio::spawn({ - let slug = slug.clone(); - async move { client.stream_requests(&slug, sse_tx).await } - }); - - while let Some(event) = sse_rx.recv().await { - if tx.send(Message::SseEvent(event)).is_err() { - // UI is gone: stop streaming. - stream_handle.abort(); - return; - } - } - // Channel closed: the stream task finished. Surface its result. - let result = join_stream(stream_handle, false).await; - let _ = tx.send(Message::StreamClosed(result)); - }); - self.tasks.push(handle); - } - } -} diff --git a/apps/cli-rs/src/tui/screens/menu.rs b/apps/cli-rs/src/tui/screens/menu.rs deleted file mode 100644 index 80a95ab66..000000000 --- a/apps/cli-rs/src/tui/screens/menu.rs +++ /dev/null @@ -1,269 +0,0 @@ -use crossterm::event::KeyEvent; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Padding, Paragraph}, -}; -use tokio::sync::mpsc; - -use crate::api::ApiClient; -use crate::tui::{keys, theme}; - -use super::{Action, Message, Screen, ScreenId}; - -struct MenuItem { - key: &'static str, - label: &'static str, - desc: &'static str, - icon: &'static str, - screen: ScreenId, -} - -const ITEMS: &[MenuItem] = &[ - MenuItem { - key: "t", - label: "Tunnel", - desc: "Forward webhooks to localhost", - icon: "⇋", - screen: ScreenId::Tunnel, - }, - MenuItem { - key: "l", - label: "Listen", - desc: "Stream incoming requests", - icon: "◉", - screen: ScreenId::Listen, - }, - MenuItem { - key: "e", - label: "Endpoints", - desc: "Manage webhook endpoints", - icon: "◆", - screen: ScreenId::Endpoints, - }, - MenuItem { - key: "s", - label: "Send", - desc: "Send a test webhook", - icon: "➤", - screen: ScreenId::Send, - }, - MenuItem { - key: "/", - label: "Search", - desc: "Search captured requests", - icon: "⌕", - screen: ScreenId::Search, - }, - MenuItem { - key: "i", - label: "Usage", - desc: "Plan, quota, and billing", - icon: "◧", - screen: ScreenId::Usage, - }, - MenuItem { - key: "a", - label: "Auth", - desc: "Login / logout", - icon: "◈", - screen: ScreenId::Auth, - }, - MenuItem { - key: "u", - label: "Update", - desc: "Check for updates", - icon: "↻", - screen: ScreenId::Update, - }, -]; - -pub struct MenuScreen { - selected: usize, - auth_email: Option, -} - -impl MenuScreen { - pub fn new(auth_email: Option) -> Self { - Self { - selected: 0, - auth_email, - } - } - - pub fn set_auth_email(&mut self, email: Option) { - self.auth_email = email; - } -} - -impl Screen for MenuScreen { - fn handle_key(&mut self, key: &KeyEvent) -> Option { - if keys::is_quit(key) { - return Some(Action::Quit); - } - if keys::is_up(key) { - self.selected = self.selected.saturating_sub(1); - return None; - } - if keys::is_down(key) { - self.selected = (self.selected + 1).min(ITEMS.len() - 1); - return None; - } - if keys::is_enter(key) { - return Some(Action::Navigate(ITEMS[self.selected].screen.clone())); - } - - // Shortcut keys - for (i, item) in ITEMS.iter().enumerate() { - if keys::is_char(key, item.key.chars().next().unwrap()) { - self.selected = i; - return Some(Action::Navigate(item.screen.clone())); - } - } - - None - } - - fn handle_message(&mut self, _msg: Message) {} - - fn render(&mut self, frame: &mut Frame, area: Rect) { - let chunks = Layout::vertical([ - Constraint::Length(5), // Logo - Constraint::Length(1), // Spacer - Constraint::Min(10), // Menu items - Constraint::Length(3), // Auth status - ]) - .split(area); - - // Logo / branding - render_logo(frame, chunks[0]); - - // Menu items - render_menu_items(frame, chunks[2], self.selected); - - // Auth status bar - render_auth_status(frame, chunks[3], self.auth_email.as_deref()); - } - - fn on_enter(&mut self, _client: &ApiClient, _tx: mpsc::UnboundedSender) {} - - fn breadcrumb(&self) -> Vec<&str> { - vec![] - } - - fn status_keys(&self) -> Vec<(&str, &str)> { - vec![("↑↓", "navigate"), ("enter", "select"), ("q", "quit")] - } - - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self - } -} - -fn render_logo(frame: &mut Frame, area: Rect) { - let logo = vec![ - Line::from(vec![Span::styled( - " ╔══════════════════════════╗", - Style::default().fg(theme::BORDER), - )]), - Line::from(vec![ - Span::styled(" ║ ", Style::default().fg(theme::BORDER)), - Span::styled("webhooks", theme::style_primary_bold()), - Span::styled(".", theme::style_dim()), - Span::styled("cc", theme::style_primary_bold()), - Span::styled(" ", Style::default()), - Span::styled( - "cli", - Style::default() - .fg(theme::ACCENT) - .add_modifier(Modifier::BOLD), - ), - Span::styled(" ║", Style::default().fg(theme::BORDER)), - ]), - Line::from(vec![Span::styled( - " ╚══════════════════════════╝", - Style::default().fg(theme::BORDER), - )]), - ]; - - let p = Paragraph::new(logo); - frame.render_widget(p, area); -} - -fn render_menu_items(frame: &mut Frame, area: Rect, selected: usize) { - let block = Block::default().padding(Padding::horizontal(2)); - let inner = block.inner(area); - frame.render_widget(block, area); - - for (i, item) in ITEMS.iter().enumerate() { - if i as u16 >= inner.height { - break; - } - - let is_selected = i == selected; - let y = inner.y + (i as u16 * 2).min(inner.height.saturating_sub(1)); - - if y >= inner.y + inner.height { - break; - } - - let (indicator, ind_style) = if is_selected { - ("▸ ", Style::default().fg(theme::PRIMARY)) - } else { - (" ", theme::style_dim()) - }; - - let label_style = if is_selected { - theme::style_primary_bold() - } else { - theme::style_bold() - }; - - let line = Line::from(vec![ - Span::styled(indicator, ind_style), - Span::styled( - item.icon, - Style::default().fg(if is_selected { - theme::PRIMARY - } else { - theme::MUTED - }), - ), - Span::styled(" ", Style::default()), - Span::styled(item.label, label_style), - Span::styled(" ", Style::default()), - Span::styled(item.desc, theme::style_muted()), - ]); - - frame.render_widget(Paragraph::new(line), Rect::new(inner.x, y, inner.width, 1)); - } -} - -fn render_auth_status(frame: &mut Frame, area: Rect, email: Option<&str>) { - let block = Block::default() - .borders(Borders::TOP) - .border_style(Style::default().fg(theme::BORDER)) - .padding(Padding::horizontal(2)); - - let inner = block.inner(area); - frame.render_widget(block, area); - - if inner.height == 0 { - return; - } - - let line = match email { - Some(e) => Line::from(vec![ - Span::styled("● ", theme::style_success()), - Span::styled(e, theme::style_dim()), - ]), - None => Line::from(vec![ - Span::styled("● ", theme::style_danger()), - Span::styled("Not logged in", theme::style_muted()), - ]), - }; - - frame.render_widget(Paragraph::new(line), inner); -} diff --git a/apps/cli-rs/src/tui/screens/mod.rs b/apps/cli-rs/src/tui/screens/mod.rs deleted file mode 100644 index 15a318c24..000000000 --- a/apps/cli-rs/src/tui/screens/mod.rs +++ /dev/null @@ -1,118 +0,0 @@ -pub mod auth; -pub mod endpoint_detail; -pub mod endpoints; -pub mod listen; -pub mod menu; -pub mod request_detail; -pub mod search; -pub mod send; -pub mod tunnel; -pub mod update; -pub mod usage; - -use crossterm::event::KeyEvent; -use ratatui::{Frame, layout::Rect}; -use tokio::sync::mpsc; - -use crate::api::ApiClient; - -/// The first column of an endpoint row: a star for the caller's favourites, -/// padding otherwise, so slugs line up either way. -pub(crate) fn favorite_marker(ep: &crate::types::Endpoint) -> &'static str { - if ep.is_favorite() { " \u{2605}" } else { " " } -} - -/// Messages from async tasks back to the TUI. -#[derive(Debug)] -pub enum Message { - // Endpoint operations - EndpointsLoaded(anyhow::Result), - EndpointCreated(anyhow::Result), - EndpointDeleted(anyhow::Result), - EndpointLoaded(anyhow::Result), - - // Request operations - RequestsLoaded(anyhow::Result), - RequestLoaded(anyhow::Result), - - // SSE - SseEvent(crate::types::SseEvent), - /// The stream task finished: `Ok` after endpoint deletion or shutdown, - /// `Err` for a terminal stream failure (not authenticated, no access, 404). - StreamClosed(anyhow::Result<()>), - - // Tunnel - ForwardResult { - request_id: String, - result: crate::types::ForwardResult, - }, - - // Auth - DeviceCode(anyhow::Result), - AuthPoll(anyhow::Result), - AuthClaimed(anyhow::Result), - - // Usage - UsageLoaded(anyhow::Result), - - // Send - SendResult(anyhow::Result), - - // Update - UpdateCheck(anyhow::Result>), - UpdateResult(anyhow::Result), -} - -/// Actions that screens emit to the app. -#[derive(Debug, Clone)] -pub enum Action { - Navigate(ScreenId), - NavigateBack, - Quit, - SetAuthEmail(Option), -} - -/// Screen identifiers. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ScreenId { - Auth, - Endpoints, - EndpointDetail(String), // slug - Tunnel, - Listen, - RequestDetail(String), // request ID - Search, - Send, - Usage, - Update, -} - -/// Trait that all TUI screens implement. -pub trait Screen { - /// Handle a key event. Return an action if needed. - fn handle_key(&mut self, key: &KeyEvent) -> Option; - - /// Handle an async message. - fn handle_message(&mut self, msg: Message); - - /// Render the screen. - fn render(&mut self, frame: &mut Frame, area: Rect); - - /// Called when the screen becomes active. - fn on_enter(&mut self, client: &ApiClient, tx: mpsc::UnboundedSender); - - /// Called when leaving the screen. - fn on_leave(&mut self) {} - - /// Current breadcrumb segments. - fn breadcrumb(&self) -> Vec<&str>; - - /// Status bar key hints. - fn status_keys(&self) -> Vec<(&str, &str)>; - - /// Tick counter for animations (called on each Tick event). - fn tick(&mut self) {} - - /// For downcasting. - fn as_any_mut(&mut self) -> &mut dyn std::any::Any; -} diff --git a/apps/cli-rs/src/tui/screens/request_detail.rs b/apps/cli-rs/src/tui/screens/request_detail.rs deleted file mode 100644 index ac9fc2bd9..000000000 --- a/apps/cli-rs/src/tui/screens/request_detail.rs +++ /dev/null @@ -1,475 +0,0 @@ -use crossterm::event::KeyEvent; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Padding, Paragraph, Wrap}, -}; -use tokio::sync::mpsc; - -use crate::api::ApiClient; -use crate::tui::widgets::spinner::Spinner; -use crate::tui::{keys, theme}; -use crate::types::{CapturedRequest, LiveResponse, ParsedEmail}; -use crate::util::format::{format_bytes, format_timestamp}; -use crate::util::live::live_response_lines; -use crate::util::parsed::{Tone, parsed_lines}; - -use super::{Action, Message, Screen}; - -#[derive(Clone, Copy, PartialEq)] -enum Tab { - Overview, - Headers, - Body, -} - -const TABS: &[(&str, Tab)] = &[ - ("Overview", Tab::Overview), - ("Headers", Tab::Headers), - ("Body", Tab::Body), -]; - -pub struct RequestDetailScreen { - request_id: String, - request: Option, - active_tab: Tab, - scroll: u16, - loading: bool, - error: Option, - tx: Option>, - client: Option, - tasks: Vec>, - tick: usize, -} - -impl RequestDetailScreen { - pub fn new(request_id: String) -> Self { - Self { - request_id, - request: None, - active_tab: Tab::Overview, - scroll: 0, - loading: true, - error: None, - tx: None, - client: None, - tasks: Vec::new(), - tick: 0, - } - } -} - -impl Screen for RequestDetailScreen { - fn handle_key(&mut self, key: &KeyEvent) -> Option { - if keys::is_back(key) { - return Some(Action::NavigateBack); - } - if keys::is_quit(key) { - return Some(Action::Quit); - } - - // Tab switching with Tab key or 1/2/3 - if keys::is_tab(key) { - self.active_tab = match self.active_tab { - Tab::Overview => Tab::Headers, - Tab::Headers => Tab::Body, - Tab::Body => Tab::Overview, - }; - self.scroll = 0; - return None; - } - if keys::is_backtab(key) { - self.active_tab = match self.active_tab { - Tab::Overview => Tab::Body, - Tab::Headers => Tab::Overview, - Tab::Body => Tab::Headers, - }; - self.scroll = 0; - return None; - } - if keys::is_char(key, '1') { - self.active_tab = Tab::Overview; - self.scroll = 0; - return None; - } - if keys::is_char(key, '2') { - self.active_tab = Tab::Headers; - self.scroll = 0; - return None; - } - if keys::is_char(key, '3') { - self.active_tab = Tab::Body; - self.scroll = 0; - return None; - } - - // Scrolling - if keys::is_up(key) { - self.scroll = self.scroll.saturating_sub(1); - return None; - } - if keys::is_down(key) { - self.scroll += 1; - return None; - } - - None - } - - fn handle_message(&mut self, msg: Message) { - if let Message::RequestLoaded(result) = msg { - self.loading = false; - match result { - Ok(req) => self.request = Some(req), - Err(e) => self.error = Some(e.to_string()), - } - } - } - - fn render(&mut self, frame: &mut Frame, area: Rect) { - if self.loading { - frame.render_widget( - Spinner::new(self.tick, "Loading request..."), - Rect::new(area.x + 2, area.y + 1, area.width.saturating_sub(4), 1), - ); - return; - } - - if let Some(ref msg) = self.error { - let p = Paragraph::new(Line::from(vec![ - Span::styled(" Error: ", theme::style_danger()), - Span::styled(msg.as_str(), theme::style_dim()), - ])); - frame.render_widget(p, area); - return; - } - - let req = match &self.request { - Some(r) => r, - None => return, - }; - - let chunks = Layout::vertical([ - Constraint::Length(3), // Tab bar - Constraint::Min(0), // Content - ]) - .split(area); - - // Tab bar - render_tabs(frame, chunks[0], self.active_tab); - - // Content based on active tab - let content_area = chunks[1]; - match self.active_tab { - Tab::Overview => render_overview(frame, content_area, req, self.scroll), - Tab::Headers => render_headers(frame, content_area, req, self.scroll), - Tab::Body => render_body(frame, content_area, req, self.scroll), - } - } - - fn on_enter(&mut self, client: &ApiClient, tx: mpsc::UnboundedSender) { - self.tx = Some(tx.clone()); - self.client = Some(client.clone()); - self.loading = true; - - let id = self.request_id.clone(); - let client = client.clone(); - let handle = tokio::spawn(async move { - let result = client.get_request(&id).await; - let _ = tx.send(Message::RequestLoaded(result)); - }); - self.tasks.push(handle); - } - - fn on_leave(&mut self) { - for handle in self.tasks.drain(..) { - handle.abort(); - } - self.tx = None; - } - - fn breadcrumb(&self) -> Vec<&str> { - vec!["Request"] - } - - fn status_keys(&self) -> Vec<(&str, &str)> { - vec![ - ("tab", "switch tab"), - ("1-3", "jump tab"), - ("↑↓", "scroll"), - ("esc", "back"), - ] - } - - fn tick(&mut self) { - self.tick += 1; - } - - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self - } -} - -fn render_tabs(frame: &mut Frame, area: Rect, active: Tab) { - let mut spans: Vec = vec![Span::raw(" ")]; - - for (i, (label, tab)) in TABS.iter().enumerate() { - if i > 0 { - spans.push(Span::styled(" │ ", theme::style_muted())); - } - - if *tab == active { - spans.push(Span::styled( - format!(" {label} "), - Style::default() - .fg(theme::SURFACE) - .bg(theme::PRIMARY) - .add_modifier(Modifier::BOLD), - )); - } else { - spans.push(Span::styled(format!(" {label} "), theme::style_dim())); - } - } - - let block = Block::default() - .borders(Borders::BOTTOM) - .border_style(Style::default().fg(theme::BORDER)); - - let inner = block.inner(area); - frame.render_widget(block, area); - if inner.height > 0 { - frame.render_widget(Paragraph::new(Line::from(spans)), inner); - } -} - -fn render_overview(frame: &mut Frame, area: Rect, req: &CapturedRequest, scroll: u16) { - let method_style = Style::default() - .fg(theme::method_color(&req.method)) - .add_modifier(Modifier::BOLD); - - let mut lines = vec![ - Line::from(""), - Line::from(vec![ - Span::styled(" Method: ", theme::style_muted()), - Span::styled(&req.method, method_style), - ]), - Line::from(vec![ - Span::styled(" Path: ", theme::style_muted()), - Span::styled(&req.path, theme::style_bold()), - ]), - Line::from(vec![ - Span::styled(" IP: ", theme::style_muted()), - Span::styled(&req.ip, theme::style()), - ]), - Line::from(vec![ - Span::styled(" Size: ", theme::style_muted()), - Span::styled(format_bytes(req.size), theme::style()), - ]), - Line::from(vec![ - Span::styled(" Received: ", theme::style_muted()), - Span::styled(format_timestamp(req.received_at), theme::style()), - ]), - ]; - - if let Some(ref ct) = req.content_type { - lines.push(Line::from(vec![ - Span::styled(" Content-Type: ", theme::style_muted()), - Span::styled(ct.as_str(), theme::style()), - ])); - } - - if !req.query_params.is_empty() { - lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - " Query Parameters", - theme::style_primary_bold(), - ))); - for (k, v) in &req.query_params { - lines.push(Line::from(vec![ - Span::styled(format!(" {k}"), theme::style_bold()), - Span::styled(" = ", theme::style_muted()), - Span::styled(v.as_str(), theme::style()), - ])); - } - } - - if let Some(ref parsed) = req.parsed { - lines.extend(parsed_overview(parsed, area.width as usize)); - } - - if let Some(ref live) = req.live_response { - lines.extend(live_overview(live)); - } - - lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled(" ID: ", theme::style_muted()), - Span::styled(&req.id, theme::style_dim()), - ])); - - let p = Paragraph::new(lines).scroll((scroll, 0)); - frame.render_widget(p, area); -} - -/// The overview's "Parsed" section for an email: what the endpoint's layouts -/// found, the same lines `whk requests get` prints. -fn parsed_overview(parsed: &ParsedEmail, width: usize) -> Vec> { - let mut lines = vec![ - Line::from(""), - Line::from(Span::styled(" Parsed", theme::style_primary_bold())), - ]; - // The panel's width, less a line's indent (it neither wraps nor scrolls sideways). - for line in parsed_lines(parsed, width.saturating_sub(6)) { - let mut spans = vec![Span::raw(" ".repeat(line.depth + 2))]; - spans.extend(line.spans.into_iter().map(|(tone, text)| { - let style = match tone { - Tone::Plain => theme::style(), - Tone::Strong => theme::style_bold(), - Tone::Muted => theme::style_muted(), - Tone::Danger => theme::style_danger(), - }; - Span::styled(text, style) - })); - lines.push(Line::from(spans)); - } - lines -} - -/// The overview's "Live Response" section: what the sender got while -/// `whk tunnel --live` relayed the request, the lines `whk requests get` -/// prints. -fn live_overview(live: &LiveResponse) -> Vec> { - let mut lines = vec![ - Line::from(""), - Line::from(Span::styled(" Live Response", theme::style_primary_bold())), - ]; - for text in live_response_lines(live) { - lines.push(Line::from(Span::styled( - format!(" {text}"), - theme::style(), - ))); - } - lines -} - -fn render_headers(frame: &mut Frame, area: Rect, req: &CapturedRequest, scroll: u16) { - let mut headers: Vec<_> = req.headers.iter().collect(); - headers.sort_by_key(|(k, _)| k.to_lowercase()); - - let mut lines = vec![Line::from("")]; - for (k, v) in headers { - lines.push(Line::from(vec![ - Span::styled(format!(" {k}"), theme::style_bold()), - Span::styled(": ", theme::style_muted()), - Span::styled(v.as_str(), theme::style()), - ])); - } - - if lines.len() == 1 { - lines.push(Line::from(Span::styled( - " No headers.", - theme::style_muted(), - ))); - } - - let p = Paragraph::new(lines).scroll((scroll, 0)); - frame.render_widget(p, area); -} - -fn render_body(frame: &mut Frame, area: Rect, req: &CapturedRequest, scroll: u16) { - let body = match &req.body { - Some(b) if !b.is_empty() => b.clone(), - _ => { - let p = Paragraph::new(Line::from(Span::styled(" No body.", theme::style_muted()))); - frame.render_widget(p, area); - return; - } - }; - - // Try to pretty-print JSON - let formatted = if let Ok(val) = serde_json::from_str::(&body) { - serde_json::to_string_pretty(&val).unwrap_or(body) - } else { - body - }; - - let lines: Vec = std::iter::once(Line::from("")) - .chain( - formatted - .lines() - .map(|l| Line::from(Span::styled(format!(" {l}"), theme::style()))), - ) - .collect(); - - let block = Block::default().padding(Padding::new(0, 0, 0, 0)); - - let p = Paragraph::new(lines) - .block(block) - .wrap(Wrap { trim: false }) - .scroll((scroll, 0)); - frame.render_widget(p, area); -} - -#[cfg(test)] -mod tests { - use super::*; - - fn text(line: &Line) -> String { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect() - } - - #[test] - fn overview_shows_an_emails_parsed_result() { - let request = crate::util::parsed::tests::acme_order(); - let lines: Vec = parsed_overview(request.parsed.as_ref().unwrap(), 120) - .iter() - .map(text) - .collect(); - assert_eq!(lines[1], " Parsed"); - assert_eq!(lines[2], " Layout: Acme orders"); - assert!(lines.contains(&" po: not found".to_string())); - assert!(lines.contains(&" sku qty price".to_string())); - assert!(lines.contains(&" scan.pdf pdf, is encrypted".to_string())); - } - - #[test] - fn the_overview_screen_draws_the_parsed_section() { - use ratatui::{Terminal, backend::TestBackend}; - let request = crate::util::parsed::tests::acme_order(); - let mut terminal = Terminal::new(TestBackend::new(100, 80)).unwrap(); - terminal - .draw(|frame| render_overview(frame, frame.area(), &request, 0)) - .unwrap(); - let buffer = terminal.backend().buffer(); - let rows: Vec = (0..buffer.area.height) - .map(|y| { - (0..buffer.area.width) - .map(|x| buffer[(x, y)].symbol()) - .collect::() - .trim_end() - .to_string() - }) - .collect(); - assert!(rows.iter().any(|row| row == " Parsed"), "{rows:#?}"); - assert!(rows.iter().any(|row| row == " Layout: Acme orders")); - assert!(rows.iter().any(|row| row == " po: not found")); - } - - #[test] - fn overview_shows_what_a_live_relay_answered() { - let live: LiveResponse = serde_json::from_value(serde_json::json!({ - "status": 201, "headers": {"content-type": "application/json"}, - "body": "{}", "bodyEncoding": "utf8", "ms": 7, "outcome": "answered" - })) - .unwrap(); - let lines: Vec = live_overview(&live).iter().map(text).collect(); - assert_eq!(lines[1], " Live Response"); - assert_eq!(lines[2], " Status: 201 (your app answered in 7 ms)"); - assert_eq!(lines[3], " content-type: application/json"); - } -} diff --git a/apps/cli-rs/src/tui/screens/search.rs b/apps/cli-rs/src/tui/screens/search.rs deleted file mode 100644 index 138d0eaeb..000000000 --- a/apps/cli-rs/src/tui/screens/search.rs +++ /dev/null @@ -1,471 +0,0 @@ -use crossterm::event::{KeyCode, KeyEvent}; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - style::Style, - text::{Line, Span}, - widgets::{Block, Borders, Padding, Paragraph, Row, Table, TableState}, -}; -use tokio::sync::mpsc; - -use crate::api::ApiClient; -use crate::tui::widgets::spinner::Spinner; -use crate::tui::{keys, theme}; -use crate::types::CapturedRequest; -use crate::util::format::{format_bytes, format_timestamp}; - -use super::{Action, Message, Screen, ScreenId}; - -#[derive(Clone, Copy, PartialEq)] -enum Field { - Query, - Method, - Slug, -} - -const FIELDS: &[Field] = &[Field::Query, Field::Method, Field::Slug]; - -enum State { - Editing, - Loading, - Results, - Error(String), -} - -pub struct SearchScreen { - state: State, - active_field: Field, - query: String, - method: String, - slug: String, - results: Vec, - total: u64, - table_state: TableState, - tx: Option>, - client: Option, - tasks: Vec>, - tick: usize, -} - -impl Default for SearchScreen { - fn default() -> Self { - Self::new() - } -} - -impl SearchScreen { - pub fn new() -> Self { - Self { - state: State::Editing, - active_field: Field::Query, - query: String::new(), - method: String::new(), - slug: String::new(), - results: Vec::new(), - total: 0, - table_state: TableState::default(), - tx: None, - client: None, - tasks: Vec::new(), - tick: 0, - } - } - - fn active_input(&mut self) -> &mut String { - match self.active_field { - Field::Query => &mut self.query, - Field::Method => &mut self.method, - Field::Slug => &mut self.slug, - } - } - - fn run_search(&mut self) { - if let (Some(tx), Some(client)) = (&self.tx, &self.client) { - self.state = State::Loading; - let tx = tx.clone(); - let client = client.clone(); - let q = if self.query.is_empty() { - None - } else { - Some(self.query.clone()) - }; - let method = if self.method.is_empty() { - None - } else { - Some(self.method.clone()) - }; - let slug = if self.slug.is_empty() { - None - } else { - Some(self.slug.clone()) - }; - - let handle = tokio::spawn(async move { - // The search route returns one page and no total, so the - // total comes from the count route, fetched alongside. - let (result, count) = tokio::join!( - client.search_requests( - slug.as_deref(), - method.as_deref(), - q.as_deref(), - None, - None, - Some(50), - None, - Some("desc"), - ), - client.count_requests( - slug.as_deref(), - method.as_deref(), - q.as_deref(), - None, - None - ) - ); - - match result { - Ok(sr) => { - // Reuse RequestsLoaded for simplicity - let _ = tx.send(Message::RequestsLoaded(Ok(crate::types::RequestList { - requests: sr.requests.into_iter().map(CapturedRequest::from).collect(), - count: count.ok().map(|c| c.count), - }))); - } - Err(e) => { - let _ = tx.send(Message::RequestsLoaded(Err(e))); - } - } - }); - self.tasks.push(handle); - } - } -} - -impl Screen for SearchScreen { - fn handle_key(&mut self, key: &KeyEvent) -> Option { - if keys::is_quit(key) { - return Some(Action::Quit); - } - - match &self.state { - State::Editing => { - if keys::is_back(key) { - return Some(Action::NavigateBack); - } - - match key.code { - KeyCode::Tab => { - let idx = FIELDS - .iter() - .position(|f| *f == self.active_field) - .unwrap_or(0); - self.active_field = FIELDS[(idx + 1) % FIELDS.len()]; - return None; - } - KeyCode::BackTab => { - let idx = FIELDS - .iter() - .position(|f| *f == self.active_field) - .unwrap_or(0); - self.active_field = FIELDS[(idx + FIELDS.len() - 1) % FIELDS.len()]; - return None; - } - KeyCode::Enter => { - self.run_search(); - return None; - } - KeyCode::Char(c) => { - self.active_input().push(c); - return None; - } - KeyCode::Backspace => { - self.active_input().pop(); - return None; - } - _ => {} - } - } - State::Results => { - if keys::is_back(key) { - self.state = State::Editing; - return None; - } - - if keys::is_up(key) { - let i = self.table_state.selected().unwrap_or(0); - self.table_state.select(Some(i.saturating_sub(1))); - return None; - } - if keys::is_down(key) { - let i = self.table_state.selected().unwrap_or(0); - let max = self.results.len().saturating_sub(1); - self.table_state.select(Some((i + 1).min(max))); - return None; - } - if keys::is_enter(key) { - if let Some(i) = self.table_state.selected() - && let Some(req) = self.results.get(i) - { - return Some(Action::Navigate(ScreenId::RequestDetail(req.id.clone()))); - } - return None; - } - - // '/' to go back to editing - if keys::is_char(key, '/') { - self.state = State::Editing; - return None; - } - } - State::Error(_) => { - if keys::is_back(key) || keys::is_enter(key) { - self.state = State::Editing; - return None; - } - } - State::Loading => {} - } - - None - } - - fn handle_message(&mut self, msg: Message) { - if let Message::RequestsLoaded(result) = msg { - match result { - Ok(list) => { - self.results = list.requests; - self.total = list.count.unwrap_or(self.results.len() as u64); - if !self.results.is_empty() { - self.table_state.select(Some(0)); - } - self.state = State::Results; - } - Err(e) => { - self.state = State::Error(e.to_string()); - } - } - } - } - - fn render(&mut self, frame: &mut Frame, area: Rect) { - let chunks = Layout::vertical([ - Constraint::Length(7), // Search fields - Constraint::Min(0), // Results - ]) - .split(area); - - // Search fields - render_search_fields( - frame, - chunks[0], - &self.query, - &self.method, - &self.slug, - self.active_field, - matches!(self.state, State::Editing), - ); - - // Results area - match &self.state { - State::Loading => { - frame.render_widget( - Spinner::new(self.tick, "Searching..."), - Rect::new( - chunks[1].x + 2, - chunks[1].y + 1, - chunks[1].width.saturating_sub(4), - 1, - ), - ); - } - State::Results => { - render_results( - frame, - chunks[1], - &self.results, - self.total, - &mut self.table_state, - ); - } - State::Error(msg) => { - let p = Paragraph::new(Line::from(vec![ - Span::styled(" Error: ", theme::style_danger()), - Span::styled(msg.as_str(), theme::style_dim()), - ])); - frame.render_widget(p, chunks[1]); - } - State::Editing => { - if !self.results.is_empty() { - render_results( - frame, - chunks[1], - &self.results, - self.total, - &mut self.table_state, - ); - } else { - let p = Paragraph::new(Line::from(Span::styled( - " Press Enter to search.", - theme::style_muted(), - ))); - frame.render_widget(p, chunks[1]); - } - } - } - } - - fn on_enter(&mut self, client: &ApiClient, tx: mpsc::UnboundedSender) { - self.tx = Some(tx); - self.client = Some(client.clone()); - } - - fn on_leave(&mut self) { - for handle in self.tasks.drain(..) { - handle.abort(); - } - self.tx = None; - } - - fn breadcrumb(&self) -> Vec<&str> { - vec!["Search"] - } - - fn status_keys(&self) -> Vec<(&str, &str)> { - match &self.state { - State::Editing => vec![("tab", "next field"), ("enter", "search"), ("esc", "back")], - State::Results => vec![ - ("↑↓", "navigate"), - ("enter", "inspect"), - ("/", "edit"), - ("esc", "back"), - ], - _ => vec![("esc", "back")], - } - } - - fn tick(&mut self) { - self.tick += 1; - } - - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self - } -} - -fn render_search_fields( - frame: &mut Frame, - area: Rect, - query: &str, - method: &str, - slug: &str, - active: Field, - editing: bool, -) { - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::BORDER)) - .title(Span::styled(" Search Requests ", theme::style_bold())) - .padding(Padding::horizontal(1)); - - let inner = block.inner(area); - frame.render_widget(block, area); - - if inner.height < 3 { - return; - } - - let fields = [ - ("Query: ", query, Field::Query), - ("Method: ", method, Field::Method), - ("Slug: ", slug, Field::Slug), - ]; - - for (i, (label, value, field)) in fields.iter().enumerate() { - if i as u16 >= inner.height { - break; - } - let y = inner.y + i as u16; - let is_active = editing && active == *field; - - let cursor = if is_active { "█" } else { "" }; - let label_style = if is_active { - theme::style_primary() - } else { - theme::style_muted() - }; - let value_style = if is_active { - theme::style_bold() - } else { - theme::style() - }; - - let line = Line::from(vec![ - Span::styled(if is_active { "▸ " } else { " " }, label_style), - Span::styled(*label, label_style), - Span::styled(*value, value_style), - Span::styled(cursor, theme::style_primary()), - ]); - - frame.render_widget(Paragraph::new(line), Rect::new(inner.x, y, inner.width, 1)); - } -} - -fn render_results( - frame: &mut Frame, - area: Rect, - results: &[CapturedRequest], - total: u64, - table_state: &mut TableState, -) { - if results.is_empty() { - let p = Paragraph::new(Line::from(Span::styled( - " No matching requests found.", - theme::style_muted(), - ))); - frame.render_widget(p, area); - return; - } - - let header = Row::new(vec![" TIME", "METHOD", "PATH", "SIZE"]).style(theme::style_muted()); - - let rows: Vec = results - .iter() - .map(|r| { - let time = format_timestamp(r.received_at); - Row::new(vec![ - format!(" {}", time.get(11..19).unwrap_or("??:??:??")), - r.method.clone(), - r.path.clone(), - format_bytes(r.size), - ]) - .style(Style::default().fg(theme::TEXT)) - }) - .collect(); - - let widths = [ - Constraint::Length(12), - Constraint::Length(8), - Constraint::Min(20), - Constraint::Length(10), - ]; - - let table = Table::new(rows, widths) - .header(header) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::BORDER)) - .title(Span::styled( - // The screen shows one page; say so when more matches exist. - if total > results.len() as u64 { - format!(" Results (first {} of {total}) ", results.len()) - } else { - format!(" Results ({total}) ") - }, - theme::style_bold(), - )), - ) - .row_highlight_style(theme::style_highlight()); - - frame.render_stateful_widget(table, area, table_state); -} diff --git a/apps/cli-rs/src/tui/screens/send.rs b/apps/cli-rs/src/tui/screens/send.rs deleted file mode 100644 index 83e4d1d74..000000000 --- a/apps/cli-rs/src/tui/screens/send.rs +++ /dev/null @@ -1,348 +0,0 @@ -use crossterm::event::{KeyCode, KeyEvent}; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - style::Style, - text::{Line, Span}, - widgets::{Block, Borders, Padding, Paragraph, Wrap}, -}; -use tokio::sync::mpsc; - -use crate::api::ApiClient; -use crate::tui::widgets::spinner::Spinner; -use crate::tui::{keys, theme}; -use crate::types::{SendResponse, SendWebhookRequest}; - -use super::{Action, Message, Screen}; - -#[derive(Clone, Copy, PartialEq)] -enum Field { - Slug, - Method, - Body, -} - -const FIELDS: &[Field] = &[Field::Slug, Field::Method, Field::Body]; - -enum State { - Editing, - Sending, - Done(SendResponse), - Error(String), -} - -pub struct SendScreen { - state: State, - active_field: Field, - slug: String, - method: String, - body: String, - tx: Option>, - client: Option, - tasks: Vec>, - tick: usize, -} - -impl Default for SendScreen { - fn default() -> Self { - Self::new() - } -} - -impl SendScreen { - pub fn new() -> Self { - Self { - state: State::Editing, - active_field: Field::Slug, - slug: String::new(), - method: "POST".into(), - body: String::new(), - tx: None, - client: None, - tasks: Vec::new(), - tick: 0, - } - } - - fn active_input(&mut self) -> &mut String { - match self.active_field { - Field::Slug => &mut self.slug, - Field::Method => &mut self.method, - Field::Body => &mut self.body, - } - } - - fn send_webhook(&mut self) { - if self.slug.is_empty() { - self.state = State::Error("Slug is required.".into()); - return; - } - - if let (Some(tx), Some(client)) = (&self.tx, &self.client) { - self.state = State::Sending; - let tx = tx.clone(); - let client = client.clone(); - let req = SendWebhookRequest { - method: self.method.clone(), - slug: self.slug.clone(), - path: None, - headers: None, - body: if self.body.is_empty() { - None - } else { - Some(self.body.clone()) - }, - }; - - let handle = tokio::spawn(async move { - let result = client.send_webhook(&req).await; - let _ = tx.send(Message::SendResult(result)); - }); - self.tasks.push(handle); - } - } -} - -impl Screen for SendScreen { - fn handle_key(&mut self, key: &KeyEvent) -> Option { - if keys::is_quit(key) { - return Some(Action::Quit); - } - - match &self.state { - State::Editing => { - if keys::is_back(key) { - return Some(Action::NavigateBack); - } - match key.code { - KeyCode::Tab => { - let idx = FIELDS - .iter() - .position(|f| *f == self.active_field) - .unwrap_or(0); - self.active_field = FIELDS[(idx + 1) % FIELDS.len()]; - return None; - } - KeyCode::BackTab => { - let idx = FIELDS - .iter() - .position(|f| *f == self.active_field) - .unwrap_or(0); - self.active_field = FIELDS[(idx + FIELDS.len() - 1) % FIELDS.len()]; - return None; - } - KeyCode::Enter => { - self.send_webhook(); - return None; - } - KeyCode::Char(c) => { - self.active_input().push(c); - return None; - } - KeyCode::Backspace => { - self.active_input().pop(); - return None; - } - _ => {} - } - } - State::Done(_) | State::Error(_) => { - if keys::is_enter(key) || keys::is_back(key) { - self.state = State::Editing; - return None; - } - } - State::Sending => {} - } - - None - } - - fn handle_message(&mut self, msg: Message) { - if let Message::SendResult(result) = msg { - match result { - Ok(resp) => self.state = State::Done(resp), - Err(e) => self.state = State::Error(e.to_string()), - } - } - } - - fn render(&mut self, frame: &mut Frame, area: Rect) { - let chunks = Layout::vertical([ - Constraint::Length(9), // Form - Constraint::Min(0), // Result - ]) - .split(area); - - // Form - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::BORDER)) - .title(Span::styled(" Send Test Webhook ", theme::style_bold())) - .padding(Padding::horizontal(1)); - - let inner = block.inner(chunks[0]); - frame.render_widget(block, chunks[0]); - - let fields = [ - ("Slug: ", &self.slug as &str, Field::Slug), - ("Method: ", &self.method, Field::Method), - ("Body: ", &self.body, Field::Body), - ]; - - let editing = matches!(self.state, State::Editing); - - for (i, (label, value, field)) in fields.iter().enumerate() { - if i as u16 >= inner.height { - break; - } - let y = inner.y + i as u16; - let is_active = editing && self.active_field == *field; - - let cursor = if is_active { "█" } else { "" }; - let label_style = if is_active { - theme::style_primary() - } else { - theme::style_muted() - }; - let value_style = if is_active { - theme::style_bold() - } else { - theme::style() - }; - - let line = Line::from(vec![ - Span::styled(if is_active { "▸ " } else { " " }, label_style), - Span::styled(*label, label_style), - Span::styled(*value, value_style), - Span::styled(cursor, theme::style_primary()), - ]); - - frame.render_widget(Paragraph::new(line), Rect::new(inner.x, y, inner.width, 1)); - } - - // Result area - match &self.state { - State::Sending => { - frame.render_widget( - Spinner::new(self.tick, "Sending..."), - Rect::new( - chunks[1].x + 2, - chunks[1].y + 1, - chunks[1].width.saturating_sub(4), - 1, - ), - ); - } - State::Done(resp) => { - let status_color = if resp.status < 400 { - theme::SUCCESS - } else { - theme::DANGER - }; - let mut lines = vec![ - Line::from(""), - Line::from(vec![ - Span::styled(" Status: ", theme::style_muted()), - Span::styled( - format!("{} {}", resp.status, resp.status_text), - Style::default().fg(status_color), - ), - ]), - ]; - - if let Some(ref body) = resp.body - && !body.is_empty() - { - lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - " Response:", - theme::style_muted(), - ))); - - // Pretty-print JSON if possible - let formatted = if let Ok(val) = serde_json::from_str::(body) - { - serde_json::to_string_pretty(&val).unwrap_or_else(|_| body.clone()) - } else { - body.clone() - }; - for line in formatted.lines().take(20) { - lines.push(Line::from(Span::styled( - format!(" {line}"), - theme::style_dim(), - ))); - } - } - - lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - " Press Enter to send another.", - theme::style_muted(), - ))); - - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(status_color)) - .title(Span::styled( - " Response ", - Style::default().fg(status_color), - )); - - frame.render_widget( - Paragraph::new(lines) - .block(block) - .wrap(Wrap { trim: false }), - chunks[1], - ); - } - State::Error(msg) => { - let p = Paragraph::new(vec![ - Line::from(""), - Line::from(vec![ - Span::styled(" ✗ ", theme::style_danger()), - Span::styled(msg.as_str(), theme::style_dim()), - ]), - Line::from(""), - Line::from(Span::styled( - " Press Enter to try again.", - theme::style_muted(), - )), - ]); - frame.render_widget(p, chunks[1]); - } - State::Editing => {} - } - } - - fn on_enter(&mut self, client: &ApiClient, tx: mpsc::UnboundedSender) { - self.tx = Some(tx); - self.client = Some(client.clone()); - } - - fn on_leave(&mut self) { - for handle in self.tasks.drain(..) { - handle.abort(); - } - self.tx = None; - } - - fn breadcrumb(&self) -> Vec<&str> { - vec!["Send"] - } - - fn status_keys(&self) -> Vec<(&str, &str)> { - match &self.state { - State::Editing => vec![("tab", "next field"), ("enter", "send"), ("esc", "back")], - _ => vec![("enter", "continue"), ("esc", "back")], - } - } - - fn tick(&mut self) { - self.tick += 1; - } - - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self - } -} diff --git a/apps/cli-rs/src/tui/screens/tunnel.rs b/apps/cli-rs/src/tui/screens/tunnel.rs deleted file mode 100644 index 0f90c361f..000000000 --- a/apps/cli-rs/src/tui/screens/tunnel.rs +++ /dev/null @@ -1,388 +0,0 @@ -use crossterm::event::{KeyCode, KeyEvent}; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - style::Style, - text::{Line, Span}, - widgets::{Block, Borders, Padding, Paragraph}, -}; -use std::collections::HashMap; -use std::sync::Arc; -use tokio::sync::mpsc; - -use crate::api::ApiClient; -use crate::api::stream::join_stream; -use crate::tui::widgets::request_list::{RequestList, RequestListState}; -use crate::tui::widgets::spinner::Spinner; -use crate::tui::{keys, theme}; -use crate::tunnel::{EmailOptions, Tunnel, parse_target}; -use crate::types::{CreateEndpointRequest, ForwardResult, SseEvent}; - -use super::{Action, Message, Screen, ScreenId}; - -enum State { - Input, - Connecting, - Active, - Error(String), -} - -pub struct TunnelScreen { - state: State, - input: String, - slug: Option, - webhook_url_str: Option, - target_url: Option, - /// Built once the endpoint exists; emails go as webhooks.cc's email JSON - /// (`whk tunnel --email-format` picks a mail provider's shape). - tunnel: Option>, - requests: RequestListState, - forward_results: HashMap, - /// Stream status line (set while reconnecting, cleared on reconnect). - stream_status: Option, - webhook_url: String, - tx: Option>, - client: Option, - tasks: Vec>, - tick: usize, -} - -impl TunnelScreen { - pub fn new(webhook_url: String) -> Self { - Self { - state: State::Input, - input: String::new(), - slug: None, - webhook_url_str: None, - target_url: None, - tunnel: None, - requests: RequestListState::new(), - forward_results: HashMap::new(), - stream_status: None, - webhook_url, - tx: None, - client: None, - tasks: Vec::new(), - tick: 0, - } - } -} - -impl Screen for TunnelScreen { - fn handle_key(&mut self, key: &KeyEvent) -> Option { - match &self.state { - State::Input => { - match key.code { - KeyCode::Enter => { - match parse_target(&self.input) { - Ok(target) => { - self.target_url = Some(target); - self.state = State::Connecting; - self.start_tunnel(); - } - Err(e) => { - self.state = State::Error(e.to_string()); - } - } - return None; - } - KeyCode::Esc => return Some(Action::NavigateBack), - KeyCode::Char(c) - if c.is_ascii_alphanumeric() - || c == '/' - || c == '-' - || c == '_' - || c == '.' - || c == ':' => - { - self.input.push(c); - return None; - } - KeyCode::Backspace => { - self.input.pop(); - return None; - } - _ => {} - } - if keys::is_quit(key) { - return Some(Action::Quit); - } - } - State::Active => { - if keys::is_back(key) || keys::is_quit(key) { - // Cleanup: delete ephemeral endpoint - if let (Some(slug), Some(client)) = (&self.slug, &self.client) { - let slug = slug.clone(); - let client = client.clone(); - tokio::spawn(async move { - let _ = client.delete_endpoint(&slug).await; - }); - } - return Some(Action::NavigateBack); - } - - if keys::is_up(key) { - self.requests.select_prev(); - return None; - } - if keys::is_down(key) { - self.requests.select_next(); - return None; - } - if keys::is_enter(key) - && let Some(req) = self.requests.selected_item() - { - return Some(Action::Navigate(ScreenId::RequestDetail(req.id.clone()))); - } - } - State::Connecting => { - if keys::is_back(key) { - return Some(Action::NavigateBack); - } - } - State::Error(_) => { - if keys::is_enter(key) || keys::is_back(key) { - self.state = State::Input; - return None; - } - } - } - None - } - - fn handle_message(&mut self, msg: Message) { - match msg { - Message::EndpointCreated(Ok(ep)) => { - let slug = ep.slug.clone(); - let url = format!("{}/w/{}", self.webhook_url, slug); - self.slug = Some(slug.clone()); - self.webhook_url_str = Some(url); - self.state = State::Active; - if let (Some(target), Some(client)) = (&self.target_url, &self.client) { - self.tunnel = Tunnel::new(target.clone(), HashMap::new()) - .ok() - .map(|t| Arc::new(t.with_emails(client.clone(), EmailOptions::default()))); - } - - // Start SSE stream - if let (Some(tx), Some(client)) = (&self.tx, &self.client) { - let tx = tx.clone(); - let client = client.clone(); - let stream_slug = slug.clone(); - let handle = tokio::spawn(async move { - let (sse_tx, mut sse_rx) = mpsc::channel(64); - let stream_handle = tokio::spawn(async move { - client.stream_requests(&stream_slug, sse_tx).await - }); - - while let Some(event) = sse_rx.recv().await { - if tx.send(Message::SseEvent(event)).is_err() { - // UI is gone: stop streaming. - stream_handle.abort(); - return; - } - } - // Channel closed: the stream task finished. Surface its result. - let result = join_stream(stream_handle, false).await; - let _ = tx.send(Message::StreamClosed(result)); - }); - self.tasks.push(handle); - } - } - Message::EndpointCreated(Err(e)) => { - self.state = State::Error(e.to_string()); - } - Message::SseEvent(SseEvent::Reconnecting { - attempt, - delay_ms, - reason, - }) => { - let secs = (delay_ms + 500) / 1000; - self.stream_status = Some(format!( - "Reconnecting in {secs}s (attempt {attempt}): {reason}" - )); - } - Message::SseEvent(SseEvent::Connected) => { - self.stream_status = None; - } - Message::StreamClosed(Err(e)) => { - self.stream_status = None; - self.state = State::Error(format!("Stream failed: {e:#}")); - } - Message::SseEvent(SseEvent::Request(req)) => { - let req_id = req.id.clone(); - let req_for_fwd = (*req).clone(); - self.requests.push(*req); - - // Forward the request (emails are rendered through the API first) - if let Some(ref tunnel) = self.tunnel { - let tunnel = tunnel.clone(); - if let Some(ref tx) = self.tx { - let tx = tx.clone(); - let rid = req_id.clone(); - let handle = tokio::spawn(async move { - let result = tunnel.forward(&req_for_fwd).await; - let _ = tx.send(Message::ForwardResult { - request_id: rid, - result, - }); - }); - self.tasks.push(handle); - } - } - } - Message::SseEvent(SseEvent::EndpointDeleted) => { - self.state = State::Error("Endpoint was deleted.".into()); - } - Message::ForwardResult { request_id, result } => { - self.forward_results.insert(request_id, result); - } - _ => {} - } - } - - fn render(&mut self, frame: &mut Frame, area: Rect) { - match &self.state { - State::Input => { - let chunks = Layout::vertical([ - Constraint::Length(3), - Constraint::Length(5), - Constraint::Min(0), - ]) - .split(area); - - let hint = Paragraph::new(Line::from(vec![ - Span::styled(" Enter port", theme::style_dim()), - Span::styled(" (e.g. 8080 or 3000/api/webhooks)", theme::style_muted()), - ])); - frame.render_widget(hint, chunks[0]); - - let input_block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::PRIMARY)) - .title(Span::styled(" Target ", theme::style_primary_bold())) - .padding(Padding::horizontal(1)); - - let input_text = Paragraph::new(Line::from(vec![ - Span::styled("localhost:", theme::style_dim()), - Span::styled(&self.input, theme::style_bold()), - Span::styled("█", theme::style_primary()), - ])) - .block(input_block); - - frame.render_widget(input_text, chunks[1]); - } - State::Connecting => { - frame.render_widget( - Spinner::new(self.tick, "Creating endpoint and connecting..."), - Rect::new(area.x + 2, area.y + 1, area.width.saturating_sub(4), 1), - ); - } - State::Active => { - let chunks = Layout::vertical([ - Constraint::Length(4), // Connection info - Constraint::Min(8), // Request list - ]) - .split(area); - - // Connection info - let url = self.webhook_url_str.as_deref().unwrap_or("—"); - let target = self.target_url.as_deref().unwrap_or("—"); - - let mut lines = vec![ - Line::from(vec![ - Span::styled(" ● ", theme::style_success()), - Span::styled("Tunnel active", theme::style_success()), - ]), - Line::from(vec![ - Span::styled(" Webhook URL: ", theme::style_muted()), - Span::styled(url, theme::style_bold()), - ]), - Line::from(vec![ - Span::styled(" Forwarding to: ", theme::style_muted()), - Span::styled(target, theme::style()), - ]), - ]; - if let Some(status) = &self.stream_status { - lines.push(Line::from(vec![ - Span::styled(" ● ", theme::style_primary()), - Span::styled(status.as_str(), theme::style_dim()), - ])); - } - let info = Paragraph::new(lines); - frame.render_widget(info, chunks[0]); - - // Request list with forward status - let list = RequestList::new("Requests").show_forward_status(); - frame.render_stateful_widget(list, chunks[1], &mut self.requests); - } - State::Error(msg) => { - let p = Paragraph::new(vec![ - Line::from(""), - Line::from(vec![ - Span::styled(" ✗ ", theme::style_danger()), - Span::styled(msg.as_str(), theme::style_dim()), - ]), - Line::from(""), - Line::from(Span::styled( - " Press Enter to try again.", - theme::style_muted(), - )), - ]); - frame.render_widget(p, area); - } - } - } - - fn on_enter(&mut self, client: &ApiClient, tx: mpsc::UnboundedSender) { - self.tx = Some(tx); - self.client = Some(client.clone()); - } - - fn on_leave(&mut self) { - for handle in self.tasks.drain(..) { - handle.abort(); - } - self.tx = None; - } - - fn breadcrumb(&self) -> Vec<&str> { - vec!["Tunnel"] - } - - fn status_keys(&self) -> Vec<(&str, &str)> { - match &self.state { - State::Input => vec![("enter", "connect"), ("esc", "back")], - State::Active => vec![("↑↓", "navigate"), ("enter", "inspect"), ("esc", "stop")], - _ => vec![("esc", "back")], - } - } - - fn tick(&mut self) { - self.tick += 1; - } - - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self - } -} - -impl TunnelScreen { - fn start_tunnel(&mut self) { - if let (Some(tx), Some(client)) = (&self.tx, &self.client) { - let tx = tx.clone(); - let client = client.clone(); - let handle = tokio::spawn(async move { - let req = CreateEndpointRequest { - name: None, - is_ephemeral: Some(true), - expires_at: None, - mock_response: None, - }; - let result = client.create_endpoint(&req).await; - let _ = tx.send(Message::EndpointCreated(result)); - }); - self.tasks.push(handle); - } - } -} diff --git a/apps/cli-rs/src/tui/screens/update.rs b/apps/cli-rs/src/tui/screens/update.rs deleted file mode 100644 index 969bcc076..000000000 --- a/apps/cli-rs/src/tui/screens/update.rs +++ /dev/null @@ -1,268 +0,0 @@ -use crossterm::event::KeyEvent; -use ratatui::{ - Frame, - layout::Rect, - style::Style, - text::{Line, Span}, - widgets::{Block, Borders, Padding, Paragraph}, -}; -use tokio::sync::mpsc; - -use crate::api::{ApiClient, update}; -use crate::tui::widgets::spinner::Spinner; -use crate::tui::{keys, theme}; - -use super::{Action, Message, Screen}; - -enum State { - Checking, - UpToDate, - Available(update::Release), - Applying, - Done(String), - Error(String), -} - -pub struct UpdateScreen { - state: State, - version: String, - tx: Option>, - tasks: Vec>, - tick: usize, -} - -// We reuse UsageLoaded message to carry update results since we don't -// want to add more message variants. Use a simple channel instead. -// Actually, let's just use tokio::spawn with a flag. - -impl Default for UpdateScreen { - fn default() -> Self { - Self::new() - } -} - -impl UpdateScreen { - pub fn new() -> Self { - Self { - state: State::Checking, - version: env!("WHK_VERSION").to_string(), - tx: None, - tasks: Vec::new(), - tick: 0, - } - } -} - -impl Screen for UpdateScreen { - fn handle_key(&mut self, key: &KeyEvent) -> Option { - if keys::is_back(key) { - return Some(Action::NavigateBack); - } - if keys::is_quit(key) { - return Some(Action::Quit); - } - - match &self.state { - State::Available(release) if keys::is_char(key, 'u') => { - let release = release.clone(); - self.state = State::Applying; - if let Some(ref tx) = self.tx { - let tx = tx.clone(); - let handle = tokio::spawn(async move { - match update::apply(&release).await { - Ok(()) => { - let _ = tx.send(Message::UpdateResult(Ok(release.version))); - } - Err(e) => { - let _ = tx.send(Message::UpdateResult(Err(e))); - } - } - }); - self.tasks.push(handle); - } - } - State::Done(_) | State::Error(_) | State::UpToDate if keys::is_enter(key) => { - return Some(Action::NavigateBack); - } - _ => {} - } - - None - } - - fn handle_message(&mut self, msg: Message) { - match msg { - Message::UpdateCheck(Ok(None)) => { - self.state = State::UpToDate; - } - Message::UpdateCheck(Ok(Some(release))) => { - self.state = State::Available(release); - } - Message::UpdateCheck(Err(e)) => { - self.state = State::Error(e.to_string()); - } - Message::UpdateResult(Ok(version)) => { - self.state = State::Done(version); - } - Message::UpdateResult(Err(e)) => { - self.state = State::Error(e.to_string()); - } - _ => {} - } - } - - fn render(&mut self, frame: &mut Frame, area: Rect) { - let content = Rect::new( - area.x + 2, - area.y + 1, - area.width.saturating_sub(4), - area.height.saturating_sub(2), - ); - - match &self.state { - State::Checking => { - frame.render_widget(Spinner::new(self.tick, "Checking for updates..."), content); - } - State::UpToDate => { - let lines = vec![ - Line::from(""), - Line::from(vec![ - Span::styled(" ✓ ", theme::style_success()), - Span::styled( - format!("Already on latest version (v{})", self.version), - theme::style(), - ), - ]), - Line::from(""), - Line::from(Span::styled( - " Press Enter to go back.", - theme::style_muted(), - )), - ]; - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::SUCCESS)) - .title(Span::styled(" Up to date ", theme::style_success())) - .padding(Padding::vertical(1)); - frame.render_widget(Paragraph::new(lines).block(block), content); - } - State::Available(release) => { - let lines = vec![ - Line::from(""), - Line::from(vec![ - Span::styled(" Current: ", theme::style_muted()), - Span::styled(format!("v{}", self.version), theme::style_dim()), - ]), - Line::from(vec![ - Span::styled(" Latest: ", theme::style_muted()), - Span::styled(&release.version, theme::style_primary_bold()), - ]), - Line::from(""), - Line::from(Span::styled(" Press u to update.", theme::style())), - ]; - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::PRIMARY)) - .title(Span::styled( - " Update Available ", - theme::style_primary_bold(), - )) - .padding(Padding::vertical(1)); - frame.render_widget(Paragraph::new(lines).block(block), content); - } - State::Applying => { - frame.render_widget( - Spinner::new(self.tick, "Downloading and installing..."), - content, - ); - } - State::Done(version) => { - let lines = vec![ - Line::from(""), - Line::from(vec![ - Span::styled(" ✓ ", theme::style_success()), - Span::styled(format!("Updated to {version}"), theme::style_bold()), - ]), - Line::from(""), - Line::from(Span::styled( - " Restart whk to use the new version.", - theme::style_muted(), - )), - Line::from(""), - Line::from(Span::styled( - " Press Enter to go back.", - theme::style_muted(), - )), - ]; - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::SUCCESS)) - .title(Span::styled(" Updated ", theme::style_success())) - .padding(Padding::vertical(1)); - frame.render_widget(Paragraph::new(lines).block(block), content); - } - State::Error(msg) => { - let lines = vec![ - Line::from(""), - Line::from(vec![ - Span::styled(" ✗ ", theme::style_danger()), - Span::styled(msg.as_str(), theme::style_dim()), - ]), - Line::from(""), - Line::from(Span::styled( - " Press Enter to go back.", - theme::style_muted(), - )), - ]; - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::DANGER)) - .title(Span::styled(" Error ", theme::style_danger())) - .padding(Padding::vertical(1)); - frame.render_widget(Paragraph::new(lines).block(block), content); - } - } - } - - fn on_enter(&mut self, _client: &ApiClient, tx: mpsc::UnboundedSender) { - self.tx = Some(tx.clone()); - - if self.version == "dev" { - self.state = State::Error("Cannot update a dev build.".into()); - return; - } - - let version = self.version.clone(); - let handle = tokio::spawn(async move { - let result = update::check(&version).await; - let _ = tx.send(Message::UpdateCheck(result)); - }); - self.tasks.push(handle); - } - - fn on_leave(&mut self) { - for handle in self.tasks.drain(..) { - handle.abort(); - } - self.tx = None; - } - - fn breadcrumb(&self) -> Vec<&str> { - vec!["Update"] - } - - fn status_keys(&self) -> Vec<(&str, &str)> { - match &self.state { - State::Available(_) => vec![("u", "update"), ("esc", "back")], - _ => vec![("esc", "back")], - } - } - - fn tick(&mut self) { - self.tick += 1; - } - - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self - } -} diff --git a/apps/cli-rs/src/tui/screens/usage.rs b/apps/cli-rs/src/tui/screens/usage.rs deleted file mode 100644 index 5ee7da5e1..000000000 --- a/apps/cli-rs/src/tui/screens/usage.rs +++ /dev/null @@ -1,489 +0,0 @@ -use crossterm::event::KeyEvent; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Gauge, Padding, Paragraph}, -}; -use tokio::sync::mpsc; - -use crate::api::ApiClient; -use crate::tui::widgets::spinner::Spinner; -use crate::tui::{keys, theme}; -use crate::types::UsageInfo; - -use super::{Action, Message, Screen}; - -enum State { - Loading, - Loaded, - Error(String), -} - -pub struct UsageScreen { - state: State, - usage: Option, - tx: Option>, - client: Option, - tasks: Vec>, - tick: usize, -} - -impl Default for UsageScreen { - fn default() -> Self { - Self::new() - } -} - -impl UsageScreen { - pub fn new() -> Self { - Self { - state: State::Loading, - usage: None, - tx: None, - client: None, - tasks: Vec::new(), - tick: 0, - } - } -} - -impl Screen for UsageScreen { - fn handle_key(&mut self, key: &KeyEvent) -> Option { - if keys::is_back(key) { - return Some(Action::NavigateBack); - } - if keys::is_quit(key) { - return Some(Action::Quit); - } - if keys::is_char(key, 'r') { - self.load_usage(); - } - None - } - - fn handle_message(&mut self, msg: Message) { - if let Message::UsageLoaded(result) = msg { - match result { - Ok(usage) => { - self.usage = Some(usage); - self.state = State::Loaded; - } - Err(e) => { - self.state = State::Error(e.to_string()); - } - } - } - } - - fn render(&mut self, frame: &mut Frame, area: Rect) { - if let State::Loading = &self.state { - frame.render_widget( - Spinner::new(self.tick, "Loading usage info..."), - Rect::new(area.x + 2, area.y + 1, area.width.saturating_sub(4), 1), - ); - return; - } - - if let State::Error(msg) = &self.state { - let p = Paragraph::new(Line::from(vec![ - Span::styled(" Error: ", theme::style_danger()), - Span::styled(msg.as_str(), theme::style_dim()), - ])); - frame.render_widget(p, area); - return; - } - - let usage = match &self.usage { - Some(u) => u, - None => return, - }; - - let allowances = allowance_rows(usage); - // Each row is a label line and a gauge, with a blank line between rows. - let allowance_height = if allowances.is_empty() { - 0 - } else { - // Only what the title and the request card leave: on a short - // terminal this card shrinks first, never the request card. - ((allowances.len() * 3 - 1 + 4) as u16).min(area.height.saturating_sub(2 + 8)) - }; - let chunks = Layout::vertical([ - Constraint::Length(2), // Title - Constraint::Length(8), // Usage card - Constraint::Length(allowance_height), // Other allowances - Constraint::Min(0), // Spacer - ]) - .split(area); - - // Plan badge - let plan_style = if usage.plan == "pro" { - Style::default() - .fg(theme::SURFACE) - .bg(theme::PRIMARY) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - .fg(theme::TEXT) - .bg(theme::MUTED) - .add_modifier(Modifier::BOLD) - }; - - let title_line = Line::from(vec![ - Span::raw(" "), - Span::styled(format!(" {} ", usage.plan.to_uppercase()), plan_style), - Span::styled(" Plan", theme::style_bold()), - ]); - frame.render_widget(Paragraph::new(title_line), chunks[0]); - - // Usage card - let card = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::BORDER)) - .title(Span::styled(" Request Usage ", theme::style_bold())) - .padding(Padding::new(2, 2, 1, 1)); - - let card_inner = card.inner(chunks[1]); - frame.render_widget(card, chunks[1]); - - if card_inner.height >= 4 { - // Usage stats - let stats = Line::from(vec![ - Span::styled(format!("{}", usage.used), theme::style_primary_bold()), - Span::styled(format!(" / {} requests", usage.limit), theme::style_dim()), - Span::styled( - format!(" ({} remaining)", usage.remaining), - theme::style_muted(), - ), - ]); - frame.render_widget( - Paragraph::new(stats), - Rect::new(card_inner.x, card_inner.y, card_inner.width, 1), - ); - - // Progress bar - let ratio = if usage.limit > 0 { - (usage.used as f64 / usage.limit as f64).min(1.0) - } else { - 0.0 - }; - - let bar_color = bar_color(ratio); - - let gauge = Gauge::default() - .ratio(ratio) - .gauge_style(Style::default().fg(bar_color).bg(theme::SURFACE_RAISED)) - .label(""); - - frame.render_widget( - gauge, - Rect::new(card_inner.x, card_inner.y + 2, card_inner.width, 1), - ); - - // Period end - if let Some(pe) = usage.period_end { - let period_line = Line::from(vec![ - Span::styled("Period ends: ", theme::style_muted()), - Span::styled(crate::util::format::format_timestamp(pe), theme::style()), - ]); - frame.render_widget( - Paragraph::new(period_line), - Rect::new(card_inner.x, card_inner.y + 4, card_inner.width, 1), - ); - } - } - - if !allowances.is_empty() { - let card = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::BORDER)) - .title(Span::styled(" Other Allowances ", theme::style_bold())) - .padding(Padding::new(2, 2, 1, 1)); - let inner = card.inner(chunks[2]); - frame.render_widget(card, chunks[2]); - // A short terminal loses the blank lines, then the gauges, before it - // loses a row: the counts stay readable at one line each. - let rows = allowances.len(); - if (inner.height as usize) < rows { - // Too short even for a line each: every allowance on one line. - if inner.height > 0 { - // Short names and bare counts, so it fits an 80-column terminal. - let summary = allowances - .iter() - .map(|row| format!("{} {}", row.short, row.amount.replace(" / ", "/"))) - .collect::>() - .join(" "); - frame.render_widget( - Paragraph::new(Line::from(Span::styled(summary, theme::style()))), - Rect::new(inner.x, inner.y, inner.width, 1), - ); - } - return; - } - let stride: u16 = if inner.height as usize >= rows * 3 - 1 { - 3 - } else if inner.height as usize >= rows * 2 { - 2 - } else { - 1 - }; - for (i, row) in allowances.iter().enumerate() { - let y = inner.y + (i as u16) * stride; - if y >= inner.y + inner.height { - break; - } - let line = Line::from(vec![ - Span::styled(format!("{:<14}", row.label), theme::style_bold()), - Span::styled(row.amount.clone(), theme::style()), - Span::styled(format!(" {}", row.when), theme::style_muted()), - ]); - frame.render_widget(Paragraph::new(line), Rect::new(inner.x, y, inner.width, 1)); - let gauge = Gauge::default() - .ratio(row.ratio) - .gauge_style( - Style::default() - .fg(bar_color(row.ratio)) - .bg(theme::SURFACE_RAISED), - ) - .label(""); - if stride > 1 { - frame.render_widget(gauge, Rect::new(inner.x, y + 1, inner.width, 1)); - } - } - } - } - - fn on_enter(&mut self, client: &ApiClient, tx: mpsc::UnboundedSender) { - self.tx = Some(tx); - self.client = Some(client.clone()); - self.load_usage(); - } - - fn on_leave(&mut self) { - for handle in self.tasks.drain(..) { - handle.abort(); - } - self.tx = None; - } - - fn breadcrumb(&self) -> Vec<&str> { - vec!["Usage"] - } - - fn status_keys(&self) -> Vec<(&str, &str)> { - vec![("r", "refresh"), ("esc", "back")] - } - - fn tick(&mut self) { - self.tick += 1; - } - - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self - } -} - -impl UsageScreen { - fn load_usage(&mut self) { - self.state = State::Loading; - if let (Some(tx), Some(client)) = (&self.tx, &self.client) { - let tx = tx.clone(); - let client = client.clone(); - let handle = tokio::spawn(async move { - let result = client.get_usage().await; - let _ = tx.send(Message::UsageLoaded(result)); - }); - self.tasks.push(handle); - } - } -} - -fn bar_color(ratio: f64) -> ratatui::style::Color { - if ratio > 0.9 { - theme::DANGER - } else if ratio > 0.7 { - theme::ACCENT - } else { - theme::SUCCESS - } -} - -/// One allowance beside requests, as the screen draws it. -struct AllowanceRow { - label: &'static str, - /// The name in the one-line summary of a very short terminal. - short: &'static str, - ratio: f64, - amount: String, - when: String, -} - -fn ratio(used: u64, limit: u64) -> f64 { - if limit > 0 { - (used as f64 / limit as f64).min(1.0) - } else if used > 0 { - 1.0 - } else { - 0.0 - } -} - -/// Parsed pages, preview data and AI assists, each with when it frees up; -/// ones the server did not report are left out. -fn allowance_rows(usage: &UsageInfo) -> Vec { - let mut rows = Vec::new(); - if let Some(pages) = &usage.parsed_pages { - rows.push(AllowanceRow { - label: "Parsed pages", - short: "Pages", - ratio: ratio(pages.used, pages.limit), - amount: format!("{} / {} pages", pages.used, pages.limit), - when: match &pages.resets_at { - Some(at) => format!("resets {}", at.display()), - None => "a month from your first parse".to_string(), - }, - }); - } - if let Some(preview) = &usage.preview_data { - let at = preview.resets_at.as_ref().map(|t| t.display()); - rows.push(AllowanceRow { - label: "Preview data", - short: "Preview", - ratio: if preview.over { - 1.0 - } else { - ratio(preview.bytes, preview.limit_bytes) - }, - amount: format!( - "{} / {}", - crate::cli::share::data(preview.bytes), - crate::cli::share::data(preview.limit_bytes) - ), - when: match (preview.over, at) { - (true, Some(at)) => format!("previews paused until {at}"), - (true, None) => "previews paused".to_string(), - (false, Some(at)) => format!("resets {at}"), - (false, None) => String::new(), - }, - }); - } - if let Some(assists) = &usage.ai_assists { - rows.push(AllowanceRow { - label: "AI assists", - short: "AI", - ratio: ratio(assists.used, assists.allowed), - amount: format!("{} / {}", assists.used, assists.allowed), - when: match (&assists.next_at, assists.used >= assists.allowed) { - (Some(next), true) => format!("next one back {}", next.display()), - _ => "over the last 30 days".to_string(), - }, - }); - } - rows -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn the_screen_draws_the_other_allowances() { - use ratatui::{Terminal, backend::TestBackend}; - let usage: UsageInfo = serde_json::from_str( - r#"{"used":31420,"limit":50000,"remaining":18580,"plan":"pro", - "parsedPages":{"used":87,"limit":100,"creditPages":0,"resetsAt":null}, - "previewData":{"bytes":1024,"limitBytes":1073741824,"over":false}, - "aiAssists":{"used":3,"allowed":20,"nextAt":null}}"#, - ) - .unwrap(); - let mut screen = UsageScreen::new(); - screen.usage = Some(usage); - screen.state = State::Loaded; - let mut terminal = Terminal::new(TestBackend::new(90, 30)).unwrap(); - terminal - .draw(|frame| screen.render(frame, frame.area())) - .unwrap(); - let buffer = terminal.backend().buffer(); - let text: String = (0..buffer.area.height) - .map(|y| { - (0..buffer.area.width) - .map(|x| buffer[(x, y)].symbol()) - .collect::() - + "\n" - }) - .collect(); - assert!(text.contains("Other Allowances"), "{text}"); - assert!( - text.contains("Parsed pages 87 / 100 pages a month from your first parse"), - "{text}" - ); - assert!(text.contains("Preview data 1.0 KB / 1.00 GB"), "{text}"); - assert!( - text.contains("AI assists 3 / 20 over the last 30 days"), - "{text}" - ); - } - - #[test] - fn a_short_terminal_still_shows_every_allowance() { - use ratatui::{Terminal, backend::TestBackend}; - let usage: UsageInfo = serde_json::from_str( - r#"{"used":1,"limit":50,"remaining":49,"plan":"free", - "parsedPages":{"used":2,"limit":20}, - "previewData":{"bytes":0,"limitBytes":1073741824}, - "aiAssists":{"used":0,"allowed":5}}"#, - ) - .unwrap(); - let mut screen = UsageScreen::new(); - screen.usage = Some(usage); - screen.state = State::Loaded; - // Title 2 + request card 8 leaves 7 lines: an inner card of 3. - let mut terminal = Terminal::new(TestBackend::new(90, 17)).unwrap(); - terminal - .draw(|frame| screen.render(frame, frame.area())) - .unwrap(); - let buffer = terminal.backend().buffer(); - let text: String = (0..buffer.area.height) - .map(|y| { - (0..buffer.area.width) - .map(|x| buffer[(x, y)].symbol()) - .collect::() - + "\n" - }) - .collect(); - for label in ["Parsed pages", "Preview data", "AI assists"] { - assert!(text.contains(label), "{label} missing:\n{text}"); - } - - // An 80x20 terminal less the TUI's header and status bar: 16 lines, - // a card of two inner lines, so the allowances share one line. - let mut terminal = Terminal::new(TestBackend::new(80, 16)).unwrap(); - terminal - .draw(|frame| screen.render(frame, frame.area())) - .unwrap(); - let buffer = terminal.backend().buffer(); - let text: String = (0..buffer.area.height) - .map(|y| { - (0..buffer.area.width) - .map(|x| buffer[(x, y)].symbol()) - .collect::() - + "\n" - }) - .collect(); - assert!( - text.contains("1 / 50 requests"), - "request card squeezed:\n{text}" - ); - for part in ["Pages 2/20 pages", "Preview 0 B/1.00 GB", "AI 0/5"] { - assert!(text.contains(part), "{part} missing at 80x16:\n{text}"); - } - } - - #[test] - fn an_older_server_draws_no_allowance_card() { - let usage: UsageInfo = - serde_json::from_str(r#"{"used":1,"limit":50,"remaining":49,"plan":"free"}"#).unwrap(); - assert!(allowance_rows(&usage).is_empty()); - } -} diff --git a/apps/cli-rs/src/tui/theme.rs b/apps/cli-rs/src/tui/theme.rs deleted file mode 100644 index 034b0686f..000000000 --- a/apps/cli-rs/src/tui/theme.rs +++ /dev/null @@ -1,73 +0,0 @@ -use ratatui::style::{Color, Modifier, Style}; - -// Neobrutalism palette — bold, high-contrast -pub const PRIMARY: Color = Color::Rgb(255, 107, 53); // #FF6B35 orange -pub const ACCENT: Color = Color::Rgb(252, 191, 73); // #FCBF49 yellow -pub const SUCCESS: Color = Color::Rgb(46, 196, 182); // #2EC4B6 teal -pub const DANGER: Color = Color::Rgb(231, 29, 54); // #E71D36 red -pub const MUTED: Color = Color::Rgb(107, 114, 128); // #6B7280 gray -pub const SURFACE: Color = Color::Rgb(17, 24, 39); // #111827 dark bg -pub const SURFACE_RAISED: Color = Color::Rgb(31, 41, 55); // #1F2937 slightly lighter -pub const BORDER: Color = Color::Rgb(55, 65, 81); // #374151 border gray -pub const TEXT: Color = Color::Rgb(243, 244, 246); // #F3F4F6 light text -pub const TEXT_DIM: Color = Color::Rgb(156, 163, 175); // #9CA3AF dimmed text - -// Method colors -pub const METHOD_GET: Color = Color::Rgb(16, 185, 129); // #10B981 green -pub const METHOD_POST: Color = Color::Rgb(59, 130, 246); // #3B82F6 blue -pub const METHOD_PUT: Color = Color::Rgb(245, 158, 11); // #F59E0B amber -pub const METHOD_DELETE: Color = Color::Rgb(239, 68, 68); // #EF4444 red -pub const METHOD_PATCH: Color = Color::Rgb(168, 85, 247); // #A855F7 purple - -pub fn method_color(method: &str) -> Color { - match method.to_uppercase().as_str() { - "GET" => METHOD_GET, - "POST" => METHOD_POST, - "PUT" => METHOD_PUT, - "DELETE" => METHOD_DELETE, - "PATCH" => METHOD_PATCH, - "HEAD" => METHOD_GET, - "OPTIONS" => MUTED, - _ => TEXT, - } -} - -pub fn style() -> Style { - Style::default().fg(TEXT) -} - -pub fn style_dim() -> Style { - Style::default().fg(TEXT_DIM) -} - -pub fn style_bold() -> Style { - Style::default().fg(TEXT).add_modifier(Modifier::BOLD) -} - -pub fn style_primary() -> Style { - Style::default().fg(PRIMARY) -} - -pub fn style_primary_bold() -> Style { - Style::default().fg(PRIMARY).add_modifier(Modifier::BOLD) -} - -pub fn style_success() -> Style { - Style::default().fg(SUCCESS) -} - -pub fn style_danger() -> Style { - Style::default().fg(DANGER) -} - -pub fn style_muted() -> Style { - Style::default().fg(MUTED) -} - -pub fn style_surface() -> Style { - Style::default().bg(SURFACE) -} - -pub fn style_highlight() -> Style { - Style::default().bg(Color::Rgb(55, 65, 81)).fg(TEXT) -} diff --git a/apps/cli-rs/src/tui/widgets/header.rs b/apps/cli-rs/src/tui/widgets/header.rs deleted file mode 100644 index 39f20f5cd..000000000 --- a/apps/cli-rs/src/tui/widgets/header.rs +++ /dev/null @@ -1,80 +0,0 @@ -use ratatui::{ - buffer::Buffer, - layout::Rect, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Padding, Widget}, -}; - -use crate::tui::theme; - -/// Top header bar with branding and breadcrumb navigation. -pub struct Header<'a> { - breadcrumb: Vec<&'a str>, - auth_status: Option<&'a str>, -} - -impl<'a> Header<'a> { - pub fn new(breadcrumb: Vec<&'a str>) -> Self { - Self { - breadcrumb, - auth_status: None, - } - } - - pub fn auth_status(mut self, email: Option<&'a str>) -> Self { - self.auth_status = email; - self - } -} - -impl Widget for Header<'_> { - fn render(self, area: Rect, buf: &mut Buffer) { - let block = Block::default() - .borders(Borders::BOTTOM) - .border_style(Style::default().fg(theme::BORDER)) - .padding(Padding::horizontal(1)); - - let inner = block.inner(area); - block.render(area, buf); - - if inner.height == 0 { - return; - } - - // Build breadcrumb: "whk" > "Endpoints" > "abc-123" - let mut spans = vec![Span::styled( - " whk ", - Style::default() - .fg(theme::SURFACE) - .bg(theme::PRIMARY) - .add_modifier(Modifier::BOLD), - )]; - - for (i, crumb) in self.breadcrumb.iter().enumerate() { - spans.push(Span::styled(" > ", theme::style_muted())); - let style = if i == self.breadcrumb.len() - 1 { - theme::style_bold() - } else { - theme::style_dim() - }; - spans.push(Span::styled(*crumb, style)); - } - - // Auth status on the right - if let Some(email) = self.auth_status { - let right_text = format!(" {} ", email); - let right_len = right_text.len() as u16; - let left_len: u16 = spans.iter().map(|s| s.width() as u16).sum(); - - if left_len + right_len + 2 < inner.width { - let gap = inner.width.saturating_sub(left_len + right_len); - spans.push(Span::raw(" ".repeat(gap as usize))); - spans.push(Span::styled(format!("● {email}"), theme::style_success())); - } - } - - let line = Line::from(spans); - buf.set_line(inner.x, inner.y, &line, inner.width); - } -} diff --git a/apps/cli-rs/src/tui/widgets/mod.rs b/apps/cli-rs/src/tui/widgets/mod.rs deleted file mode 100644 index 9d67dce2a..000000000 --- a/apps/cli-rs/src/tui/widgets/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod header; -pub mod request_list; -pub mod spinner; -pub mod status_bar; diff --git a/apps/cli-rs/src/tui/widgets/request_list.rs b/apps/cli-rs/src/tui/widgets/request_list.rs deleted file mode 100644 index 243210f04..000000000 --- a/apps/cli-rs/src/tui/widgets/request_list.rs +++ /dev/null @@ -1,194 +0,0 @@ -use ratatui::{ - buffer::Buffer, - layout::Rect, - style::Style, - text::{Line, Span}, - widgets::{Block, Borders, Padding, StatefulWidget, Widget}, -}; - -use crate::cli::output::sanitize_line; -use crate::tui::theme; -use crate::types::CapturedRequest; -use crate::util::format::format_bytes; - -/// State for the scrollable request list. -pub struct RequestListState { - pub selected: usize, - pub offset: usize, - pub items: Vec, -} - -impl Default for RequestListState { - fn default() -> Self { - Self::new() - } -} - -impl RequestListState { - pub fn new() -> Self { - Self { - selected: 0, - offset: 0, - items: Vec::new(), - } - } - - pub fn select_next(&mut self) { - if !self.items.is_empty() { - self.selected = (self.selected + 1).min(self.items.len() - 1); - } - } - - pub fn select_prev(&mut self) { - self.selected = self.selected.saturating_sub(1); - } - - pub fn selected_item(&self) -> Option<&CapturedRequest> { - self.items.get(self.selected) - } - - pub fn push(&mut self, req: CapturedRequest) { - self.items.insert(0, req); - // Keep selection stable — after insert at front, the previously selected - // item has shifted down by 1, so increment to keep pointing at it - if !self.items.is_empty() && self.items.len() > 1 { - self.selected += 1; - } - } -} - -/// Widget that renders a scrollable list of captured requests. -pub struct RequestList<'a> { - title: &'a str, - show_forward_status: bool, -} - -impl<'a> RequestList<'a> { - pub fn new(title: &'a str) -> Self { - Self { - title, - show_forward_status: false, - } - } - - pub fn show_forward_status(mut self) -> Self { - self.show_forward_status = true; - self - } -} - -impl StatefulWidget for RequestList<'_> { - type State = RequestListState; - - fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { - let block = Block::default() - .title(Span::styled( - format!(" {} ", self.title), - theme::style_bold(), - )) - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::BORDER)) - .padding(Padding::horizontal(1)); - - let inner = block.inner(area); - block.render(area, buf); - - if inner.height == 0 || inner.width == 0 || state.items.is_empty() { - if state.items.is_empty() { - let msg = Span::styled(" Waiting for requests...", theme::style_muted()); - if inner.height > 0 { - buf.set_span(inner.x, inner.y, &msg, inner.width); - } - } - return; - } - - let visible_height = inner.height as usize; - - // Adjust scroll offset to keep selection visible - if state.selected < state.offset { - state.offset = state.selected; - } - if state.selected >= state.offset + visible_height { - state.offset = state.selected - visible_height + 1; - } - - for (i, idx) in (state.offset..state.items.len()) - .take(visible_height) - .enumerate() - { - let req = &state.items[idx]; - let is_selected = idx == state.selected; - let y = inner.y + i as u16; - - // Time - let time = chrono::DateTime::from_timestamp_millis(req.received_at) - .map(|dt| { - dt.with_timezone(&chrono::Local) - .format("%H:%M:%S") - .to_string() - }) - .unwrap_or_else(|| "??:??:??".to_string()); - - // Method with color - let method_style = Style::default().fg(theme::method_color(&req.method)); - let method = format!("{:<7}", req.method); - - // Path (truncated to fit) - let size_str = format_bytes(req.size); - - let bg = if is_selected { - theme::SURFACE_RAISED - } else { - theme::SURFACE - }; - - // Render row - let indicator = if is_selected { "▸ " } else { " " }; - let indicator_style = if is_selected { - Style::default().fg(theme::PRIMARY).bg(bg) - } else { - Style::default().fg(theme::SURFACE).bg(bg) - }; - - let mut spans = vec![ - Span::styled(indicator, indicator_style), - Span::styled(&time, Style::default().fg(theme::TEXT_DIM).bg(bg)), - Span::styled(" ", Style::default().bg(bg)), - Span::styled(&method, method_style.bg(bg)), - Span::styled( - sanitize_line(&req.path), - Style::default().fg(theme::TEXT).bg(bg), - ), - ]; - // An email's path is its recipient; its subject says which one it is. - if req.is_email() { - let subject = req - .email_subject() - .unwrap_or_else(|| "(no subject)".to_string()); - spans.push(Span::styled(" ", Style::default().bg(bg))); - spans.push(Span::styled( - sanitize_line(&subject), - theme::style_bold().bg(bg), - )); - } - spans.push(Span::styled(" ", Style::default().bg(bg))); - spans.push(Span::styled( - &size_str, - Style::default().fg(theme::MUTED).bg(bg), - )); - let line = Line::from(spans); - - buf.set_line(inner.x, y, &line, inner.width); - - // Fill remaining width with bg - let rendered_width: u16 = line.width() as u16; - if rendered_width < inner.width && inner.width > 0 { - let end = inner.x + inner.width.saturating_sub(1); - for x in (inner.x + rendered_width)..=end { - buf[(x, y)].set_bg(bg); - } - } - } - } -} diff --git a/apps/cli-rs/src/tui/widgets/spinner.rs b/apps/cli-rs/src/tui/widgets/spinner.rs deleted file mode 100644 index 0b9f8c4b7..000000000 --- a/apps/cli-rs/src/tui/widgets/spinner.rs +++ /dev/null @@ -1,36 +0,0 @@ -use ratatui::{buffer::Buffer, layout::Rect, style::Style, text::Span, widgets::Widget}; - -use crate::tui::theme; - -const FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - -pub struct Spinner<'a> { - tick: usize, - label: &'a str, - style: Style, -} - -impl<'a> Spinner<'a> { - pub fn new(tick: usize, label: &'a str) -> Self { - Self { - tick, - label, - style: theme::style_primary(), - } - } -} - -impl Widget for Spinner<'_> { - fn render(self, area: Rect, buf: &mut Buffer) { - if area.height == 0 || area.width < 4 { - return; - } - - let frame = FRAMES[self.tick % FRAMES.len()]; - let spinner = Span::styled(frame, self.style); - let label = Span::styled(format!(" {}", self.label), theme::style_dim()); - - buf.set_span(area.x, area.y, &spinner, 2); - buf.set_span(area.x + 2, area.y, &label, area.width.saturating_sub(2)); - } -} diff --git a/apps/cli-rs/src/tui/widgets/status_bar.rs b/apps/cli-rs/src/tui/widgets/status_bar.rs deleted file mode 100644 index 1a40d982c..000000000 --- a/apps/cli-rs/src/tui/widgets/status_bar.rs +++ /dev/null @@ -1,68 +0,0 @@ -use ratatui::{ - buffer::Buffer, - layout::Rect, - style::Style, - text::{Line, Span}, - widgets::{Block, Borders, Padding, Widget}, -}; - -use crate::tui::theme; - -/// Bottom status bar showing keybinding hints. -pub struct StatusBar<'a> { - keys: Vec<(&'a str, &'a str)>, - right: Option<&'a str>, -} - -impl<'a> StatusBar<'a> { - pub fn new(keys: Vec<(&'a str, &'a str)>) -> Self { - Self { keys, right: None } - } - - pub fn right(mut self, text: &'a str) -> Self { - self.right = Some(text); - self - } -} - -impl Widget for StatusBar<'_> { - fn render(self, area: Rect, buf: &mut Buffer) { - let block = Block::default() - .borders(Borders::TOP) - .border_style(Style::default().fg(theme::BORDER)) - .padding(Padding::horizontal(1)); - - let inner = block.inner(area); - block.render(area, buf); - - if inner.height == 0 { - return; - } - - let mut spans: Vec = Vec::new(); - - for (i, (key, desc)) in self.keys.iter().enumerate() { - if i > 0 { - spans.push(Span::styled(" ", theme::style_muted())); - } - spans.push(Span::styled( - format!(" {key} "), - Style::default().fg(theme::SURFACE).bg(theme::MUTED), - )); - spans.push(Span::styled(format!(" {desc}"), theme::style_dim())); - } - - if let Some(right) = self.right { - let left_len: u16 = spans.iter().map(|s| s.width() as u16).sum(); - let right_len = right.len() as u16; - let gap = inner.width.saturating_sub(left_len + right_len + 1); - if gap > 0 { - spans.push(Span::raw(" ".repeat(gap as usize))); - spans.push(Span::styled(right, theme::style_muted())); - } - } - - let line = Line::from(spans); - buf.set_line(inner.x, inner.y, &line, inner.width); - } -} diff --git a/apps/cli-rs/src/types/mod.rs b/apps/cli-rs/src/types/mod.rs index c79eedf58..4db75fe28 100644 --- a/apps/cli-rs/src/types/mod.rs +++ b/apps/cli-rs/src/types/mod.rs @@ -802,10 +802,33 @@ pub struct RenderedEmail { // SSE events // --------------------------------------------------------------------------- +/// A request from the live stream, and the JSON it came in, which +/// `whk listen --json` prints as is. +#[derive(Debug, Clone)] +pub struct Streamed { + pub request: CapturedRequest, + pub raw: serde_json::Value, +} + +impl std::ops::Deref for Streamed { + type Target = CapturedRequest; + fn deref(&self) -> &CapturedRequest { + &self.request + } +} + +impl Streamed { + pub fn parse(data: &str) -> Option { + let raw: serde_json::Value = serde_json::from_str(data).ok()?; + let request = serde_json::from_value(raw.clone()).ok()?; + Some(Self { request, raw }) + } +} + #[derive(Debug, Clone)] pub enum SseEvent { Connected, - Request(Box), + Request(Box), EndpointDeleted, /// The server rotated the stream (it closes every connection after 30 minutes). /// The stream layer reconnects transparently; consumers can ignore this. diff --git a/apps/cli-rs/tests/integration.rs b/apps/cli-rs/tests/integration.rs index 1ed416d4f..2e1c4a7df 100644 --- a/apps/cli-rs/tests/integration.rs +++ b/apps/cli-rs/tests/integration.rs @@ -124,7 +124,7 @@ async fn test_endpoint_update_mock_response() { .expect("update failed"); assert_eq!(updated.name.as_deref(), Some("mock-test-updated")); assert!(updated.mock_response.is_some()); - assert_eq!(updated.mock_response.unwrap().status, 201); + assert_eq!(updated.mock_response.as_ref().unwrap().status, 201); // Cleanup client.delete_endpoint(&ep.slug).await.ok(); diff --git a/apps/web/changelog/web/cli-for-agents-docs.json b/apps/web/changelog/web/cli-for-agents-docs.json new file mode 100644 index 000000000..06a4adeff --- /dev/null +++ b/apps/web/changelog/web/cli-for-agents-docs.json @@ -0,0 +1,7 @@ +{ + "title": "CLI docs for scripts and agents", + "items": [ + "The CLI docs describe whk's JSON output, its exit statuses and error codes, whk wait and whk api", + "The interactive TUI is gone from the docs: whk prints its commands when run on its own" + ] +} diff --git a/content/docs/cli/commands.mdx b/content/docs/cli/commands.mdx index c9c91a04f..32ab8beec 100644 --- a/content/docs/cli/commands.mdx +++ b/content/docs/cli/commands.mdx @@ -3,7 +3,7 @@ title: "Command Reference" description: "Complete command reference for the webhooks.cc CLI, including authentication, endpoint management, tunneling, replay, the agent sandbox, and updates." section: cli order: 2 -lastUpdated: 2026-11-12 +lastUpdated: 2026-11-13 schema: none keywords: ["whk commands", "CLI reference", "webhook CLI commands"] seo: @@ -13,19 +13,7 @@ seo: All available commands for the `whk` CLI. -Run `whk` with no arguments to launch the interactive TUI, or use the subcommands below for scriptable, plain-text output. - -## whk (interactive) - -Launch the interactive TUI. Provides a menu-driven interface for all CLI features with real-time request streaming, color-coded methods, and a request detail viewer. - -```bash -whk -``` - -| Flag | Description | -| --------- | ------------------------------------------------------------ | -| `--nogui` | Disable the TUI and print help instead (also: `WHK_NOGUI=1`) | +Run `whk` with no arguments to print the list of commands. Every command prints plain text, or JSON with `--json`, and reports failures with the [exit statuses](/docs/cli#scripts-ci-and-ai-agents) listed in `whk --help`. ## Global flags @@ -33,7 +21,7 @@ These work with every subcommand. | Flag | Description | | --------------- | ------------------------------------------------------------------- | -| `--json` | Print JSON instead of plain text, where the command supports it | +| `--json` | Print the API's answers as JSON (NDJSON for streams) and failures as `{"error": {...}}` on stderr | | `--api-url` | Use another API base URL (also: `WHK_API_URL`) | | `--webhook-url` | Use another webhook receiver URL (also: `WHK_WEBHOOK_URL`) | | `--no-color` | Disable colored output | @@ -133,7 +121,7 @@ whk update-endpoint --mock-status 202 --mock-body '{"ok":true}' ## favorite -Mark an endpoint as a favourite, or remove it again with `unfavorite`. Favourites come first in `whk list`, in the interactive TUI's endpoint lists, and in the dashboard's endpoint switcher. A favourite is yours alone: you can favourite an endpoint shared with you through a team without changing it for anyone else. Both commands are idempotent, and `favourite` and `unfavourite` work as aliases. They need an account, not an agent sandbox. +Mark an endpoint as a favourite, or remove it again with `unfavorite`. Favourites come first in `whk list` and in the dashboard's endpoint switcher. A favourite is yours alone: you can favourite an endpoint shared with you through a team without changing it for anyone else. Both commands are idempotent, and `favourite` and `unfavourite` work as aliases. They need an account, not an agent sandbox. ```bash whk favorite @@ -144,15 +132,16 @@ With `--json` they print `{ "slug": "", "favorite": true }` (or `false`). ## delete -Delete an endpoint. Prompts for confirmation unless `--force` is set. +Delete an endpoint. In a terminal it asks first; without one (a script, CI, an agent) it stops with `confirmation_required` (exit 9) unless `--yes` is set. ```bash whk delete +whk delete --yes ``` -| Flag | Description | -| ------------- | ---------------------------- | -| `--force, -f` | Skip the confirmation prompt | +| Flag | Description | +| ----------- | --------------------------------------------- | +| `--yes, -y` | Delete without asking (`--force` still works) | ## tunnel @@ -227,19 +216,38 @@ whk names password --clear ## listen -Stream incoming requests for an endpoint to the terminal without forwarding them. +Stream incoming requests and emails for an endpoint without forwarding them: one line each, or one JSON object per line (NDJSON) with `--json`. ```bash whk listen ``` -When signature verification is configured, listen shows verification status inline: +Each line has the time, the method, the path (for an email, the address and the subject), the signature check when verification is configured, the size and the request id for `whk requests get`: ``` -14:32:01 POST /webhook ✓ stripe 423 B -14:32:05 POST /webhook ✗ stripe 423 B +14:32:01 POST /webhook ✓ stripe 423 B 6f1c... +14:32:05 POST /webhook ✗ stripe 423 B 9a2e... +``` + +## wait + +Wait for the next request or email at an endpoint, print it, and exit. Start it before or after triggering the sender: requests from the last 5 minutes count unless `--since` says otherwise. When nothing matching arrives in time it exits with `timeout` (exit 8). + +```bash +whk wait --json +whk wait --kind email --timeout 2m --json +whk wait --method POST --path /stripe --count 3 --json ``` +| Flag | Description | +| --- | --- | +| `--timeout` | How long to wait (default `30s`) | +| `--count` | How many matching requests to wait for (1 to 100); more than one prints a JSON array | +| `--since` | `now` (only what arrives after the command starts), a duration back such as `2m`, or a timestamp in milliseconds | +| `--method` | Only this HTTP method | +| `--kind` | `http` or `email` | +| `--path` | Only requests whose path (for an email, the recipient address) contains this text | + ## requests List, read, search, count, clear, and export captured requests. Run `whk requests --help` for each command's flags. @@ -249,7 +257,7 @@ whk requests list # newest first, --limit (default 25), --since, whk requests get whk requests search -q invoice --slug --from 1h # across retained requests: --method, --to, --limit, --offset, --order whk requests count --slug --method POST # same filters as search -whk requests clear # --before