-Command-line client for the [WaveKat platform](https://github.com/wavekat/wavekat-platform).
+[](https://crates.io/crates/wavekat-cli)
-> [!WARNING]
-> Early development. The auth model is intentionally minimal in v1 (paste a
-> session cookie). A proper device-code flow lands together with the export
-> feature on the platform side.
+Command-line client (`wk`) for the [WaveKat platform](https://platform.wavekat.com).
+
+> [!NOTE]
+> Early development. `wk login` now uses a browser-based loopback OAuth
+> handshake (no more pasting cookies). Export commands are still pending.
## What it does today
| Command | What it calls | What it shows |
|---------|---------------|---------------|
-| `wk login` | `GET /api/me` to verify | stores `{base_url, session_cookie}` under your config dir |
-| `wk logout` | — | removes the stored credentials |
+| `wk login` | loopback OAuth + `GET /api/me` | stores `{base_url, token}` under your config dir |
+| `wk logout` | `POST /api/auth/cli/tokens/revoke-current` | revokes the token server-side and removes the local file |
| `wk me` | `GET /api/me` | your login, id, name, email, role |
| `wk projects list` | `GET /api/projects` | paginated table of projects you can see |
-| `wk projects show ` | `GET /api/projects/{id}` | project detail (raw JSON) |
-| `wk annotations list ` | `GET /api/projects/{id}/annotations` | paginated annotations (raw JSON) |
+| `wk projects show ` | `GET /api/projects/{id}` | project summary (`--json` for raw) |
+| `wk annotations list ` | `GET /api/projects/{id}/annotations` | paginated table with inline ASR (`--json` for raw) |
-Pagination on every list (`--page`, `--page-size`). Filters on annotations:
-`--label`, `--review-status`, `--file-id`, `--created-by`. Run any command
-with `--help` for the full set.
+Pagination on every list (`--page`, `--page-size`, default page size 20).
+The footer shows the current page and prints a ready-to-paste `Next:`
+line when more pages exist. Filters on annotations: `--label`,
+`--review-status`, `--file-id`, `--created-by`. Run any command with
+`--help` for the full set.
## Install
@@ -40,33 +47,48 @@ Not yet — these will land once the first tagged release is cut. Releases will
ship prebuilt binaries for macOS (Apple Silicon + Intel) and Linux (x86_64 +
aarch64).
-## Sign in (v1: paste-the-cookie)
-
-The platform today only authenticates browser sessions via a signed
-`wk_session` cookie set after GitHub OAuth. Until the platform exposes a
-CLI-friendly auth flow (planned alongside the dataset export feature), `wk`
-reuses your browser session.
+## Sign in
```sh
-wk login --base-url https://platform.wavekat.com
+wk login
+# (or: wk login --base-url https://platform.wavekat.com)
```
-You'll be told to:
+What happens:
+
+1. `wk` binds an ephemeral port on `127.0.0.1`.
+2. Your default browser opens to `/cli-login`. If you're not
+ already signed in, you're bounced through the normal "Sign in with
+ GitHub" flow first and come back automatically.
+3. You click **Authorize** on the platform's confirmation page.
+4. The platform redirects the browser to the loopback URL with a freshly
+ minted token; `wk` captures it, verifies against `/api/me`, and writes
+ it to your config file.
+5. The browser tab shows "You can close this tab" and you're done in your
+ terminal.
-1. Open the platform URL in your browser and sign in with GitHub.
-2. Open dev tools → Application → Cookies → the platform origin.
-3. Copy the value of the `wk_session` cookie and paste it at the prompt
- (input is hidden).
+The token is a long-lived `wkcli_…` bearer credential. You can list and
+revoke tokens from your platform profile page; `wk logout` revokes the
+current token before clearing the local file.
-`wk login` calls `/api/me` to verify the cookie before storing it. The cookie
-has a 7-day TTL; you'll need to re-run `wk login` after that.
+### Headless / SSH
-You can also pass it non-interactively:
+If no browser is available on the local machine, run:
```sh
-WK_BASE_URL=https://platform.wavekat.com WK_SESSION='…' wk login
-# or
-wk login --base-url … --session …
+wk login --no-browser
+```
+
+`wk` prints the authorization URL — open it on any browser that can
+reach the loopback port (typically with `ssh -L 1234:127.0.0.1:1234
+remote-host`, then open the URL the CLI prints).
+
+### CI / pre-minted token
+
+Pre-mint a token from the SPA (or the API), then:
+
+```sh
+WK_TOKEN='wkcli_…' WK_BASE_URL='https://platform.wavekat.com' wk login
```
### Where credentials are stored
@@ -91,7 +113,11 @@ wk projects list --page-size 5
wk projects list --json | jq '.projects[].name'
-wk annotations list --label end_of_turn --review-status approved \
+# Default: human-readable table with the ASR snippet under each row.
+wk annotations list --label end_of_turn --review-status approved
+
+# Pipe raw JSON into jq for scripting.
+wk annotations list --label end_of_turn --review-status approved --json \
| jq '.annotations | length'
```
@@ -100,14 +126,10 @@ wk annotations list --label end_of_turn --review-status approved \
The next milestone for the CLI is the **dataset export** feature, landing
together with the matching platform changes. It will add:
-- Proper device-code login (no more cookie pasting).
- `wk exports create` / `list` / `show` / `download`.
- A built-in adapter that materialises the canonical snapshot into the
HuggingFace `datasets` format Pipecat `smart-turn` consumes.
-See the platform's [docs/06-export.md](https://github.com/wavekat/wavekat-platform/blob/main/docs/06-export.md)
-for the design.
-
## License
Apache-2.0. See [LICENSE](LICENSE).
diff --git a/release-plz.toml b/release-plz.toml
new file mode 100644
index 0000000..68387a3
--- /dev/null
+++ b/release-plz.toml
@@ -0,0 +1,3 @@
+[workspace]
+git_tag_enable = true
+git_release_enable = true
diff --git a/src/client.rs b/src/client.rs
index 46109ba..4403dc7 100644
--- a/src/client.rs
+++ b/src/client.rs
@@ -1,5 +1,5 @@
use anyhow::{anyhow, Context, Result};
-use reqwest::header::{HeaderMap, HeaderValue, COOKIE};
+use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, COOKIE};
use serde::de::DeserializeOwned;
use serde::Serialize;
@@ -18,11 +18,26 @@ impl Client {
pub fn new(cfg: &AuthConfig) -> Result {
let mut headers = HeaderMap::new();
- let cookie = format!("wk_session={}", cfg.session_cookie);
- headers.insert(
- COOKIE,
- HeaderValue::from_str(&cookie).context("session cookie contained invalid bytes")?,
- );
+ // Prefer the bearer token (new flow). Fall back to the legacy
+ // session cookie so existing auth.json files keep working until
+ // the user re-runs `wk login`.
+ if let Some(token) = cfg.token.as_deref() {
+ let value = format!("Bearer {token}");
+ headers.insert(
+ AUTHORIZATION,
+ HeaderValue::from_str(&value).context("token contained invalid bytes")?,
+ );
+ } else if let Some(cookie) = cfg.session_cookie.as_deref() {
+ let value = format!("wk_session={cookie}");
+ headers.insert(
+ COOKIE,
+ HeaderValue::from_str(&value).context("session cookie contained invalid bytes")?,
+ );
+ } else {
+ return Err(anyhow!(
+ "no credentials in config — run `wk login` to authenticate"
+ ));
+ }
let inner = reqwest::Client::builder()
.default_headers(headers)
.user_agent(concat!("wavekat-cli/", env!("CARGO_PKG_VERSION")))
@@ -48,6 +63,27 @@ impl Client {
decode(url, resp).await
}
+ pub async fn post_empty(&self, path: &str) -> Result<()> {
+ let url = self.url(path);
+ let resp = self
+ .inner
+ .post(&url)
+ .send()
+ .await
+ .with_context(|| format!("POST {url}"))?;
+ let status = resp.status();
+ if !status.is_success() {
+ let text = resp.text().await.unwrap_or_default();
+ let snippet = if text.len() > 500 {
+ &text[..500]
+ } else {
+ &text
+ };
+ return Err(anyhow!("{} {}: {}", status.as_u16(), url, snippet));
+ }
+ Ok(())
+ }
+
pub async fn get_json_query(
&self,
path: &str,
diff --git a/src/commands/annotations.rs b/src/commands/annotations.rs
index dfdb5cf..a8daef5 100644
--- a/src/commands/annotations.rs
+++ b/src/commands/annotations.rs
@@ -1,8 +1,9 @@
use anyhow::Result;
use clap::{Args as ClapArgs, Subcommand};
-use serde::Serialize;
+use serde::{Deserialize, Serialize};
use crate::client::Client;
+use crate::style;
#[derive(Subcommand)]
pub enum Cmd {
@@ -30,6 +31,9 @@ pub struct ListArgs {
/// Filter to a labeller's user id
#[arg(long)]
created_by: Option,
+ /// Print raw JSON instead of a table
+ #[arg(long)]
+ json: bool,
}
#[derive(Serialize, Default)]
@@ -47,6 +51,29 @@ struct ListQuery {
created_by: Option,
}
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct Annotation {
+ id: String,
+ file_name: Option,
+ label_key: String,
+ label_value: i64,
+ start_sec: f64,
+ end_sec: f64,
+ review_status: Option,
+ asr_text: Option,
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct ListResponse {
+ annotations: Vec,
+ page: u32,
+ page_size: u32,
+ total: u32,
+ total_pages: u32,
+}
+
pub async fn run(cmd: Cmd) -> Result<()> {
let client = Client::from_config()?;
match cmd {
@@ -64,7 +91,154 @@ async fn list(client: &Client, args: ListArgs) -> Result<()> {
file_id: args.file_id,
created_by: args.created_by,
};
- let v: serde_json::Value = client.get_json_query(&path, &query).await?;
- println!("{}", serde_json::to_string_pretty(&v)?);
+ if args.json {
+ let v: serde_json::Value = client.get_json_query(&path, &query).await?;
+ println!("{}", serde_json::to_string_pretty(&v)?);
+ return Ok(());
+ }
+ let resp: ListResponse = client.get_json_query(&path, &query).await?;
+ if resp.annotations.is_empty() {
+ println!("No annotations.");
+ return Ok(());
+ }
+ println!(
+ "{} {} {} {} {}",
+ style::bold(&format!("{:<10}", "ID")),
+ style::bold(&format!("{:<24}", "FILE")),
+ style::bold(&format!("{:<18}", "LABEL")),
+ style::bold(&format!("{:<16}", "RANGE")),
+ style::bold("REVIEW"),
+ );
+ for a in &resp.annotations {
+ let id_short = a.id.get(..8).unwrap_or(&a.id);
+ let file = truncate(a.file_name.as_deref().unwrap_or("-"), 24);
+ let label_text = truncate(&format!("{}={}", a.label_key, a.label_value), 18);
+ let range = format!("{:.1}–{:.1}s", a.start_sec, a.end_sec);
+ // Pad in raw bytes first, then style — ANSI codes don't count toward
+ // the visible width, so styling has to wrap an already-padded cell.
+ println!(
+ "{} {file:<24} {} {} {}",
+ style::dim(&format!("{id_short:<10}")),
+ style::cyan(&format!("{label_text:<18}")),
+ style::dim(&format!("{range:<16}")),
+ style::review(a.review_status.as_deref()),
+ );
+ if let Some(text) = a.asr_text.as_deref() {
+ let trimmed = text.trim();
+ if !trimmed.is_empty() {
+ println!(" {}", style::dim(&truncate_width(trimmed, 80)));
+ }
+ }
+ }
+ println!(
+ "\n{}",
+ style::dim(&format!(
+ "Page {}/{} · {} annotation(s) total · pageSize {}",
+ resp.page, resp.total_pages, resp.total, resp.page_size
+ )),
+ );
+ if resp.page < resp.total_pages {
+ println!(
+ "{} wk annotations list {} --page {}{}",
+ style::dim("Next:"),
+ args.project_id,
+ resp.page + 1,
+ if resp.page_size != 20 {
+ format!(" --page-size {}", resp.page_size)
+ } else {
+ String::new()
+ },
+ );
+ }
Ok(())
}
+
+fn truncate(s: &str, n: usize) -> String {
+ if s.chars().count() > n {
+ let mut out: String = s.chars().take(n.saturating_sub(1)).collect();
+ out.push('…');
+ out
+ } else {
+ s.to_string()
+ }
+}
+
+/// Truncate by *visible* terminal width (CJK chars count as 2). Stops as
+/// soon as appending the next char would push us over `cols - 1`, leaving
+/// room for the trailing ellipsis. Falls back to no-op when the input
+/// already fits.
+fn truncate_width(s: &str, cols: usize) -> String {
+ use unicode_width::UnicodeWidthChar;
+ let mut total = 0usize;
+ for c in s.chars() {
+ total += c.width().unwrap_or(0);
+ if total > cols {
+ let budget = cols.saturating_sub(1);
+ let mut used = 0usize;
+ let mut out = String::new();
+ for c in s.chars() {
+ let w = c.width().unwrap_or(0);
+ if used + w > budget {
+ break;
+ }
+ out.push(c);
+ used += w;
+ }
+ out.push('…');
+ return out;
+ }
+ }
+ s.to_string()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use unicode_width::UnicodeWidthStr;
+
+ #[test]
+ fn truncate_passes_short_strings() {
+ assert_eq!(truncate("hi", 5), "hi");
+ assert_eq!(truncate("", 5), "");
+ // Boundary: exactly n chars must not be truncated.
+ assert_eq!(truncate("abcde", 5), "abcde");
+ }
+
+ #[test]
+ fn truncate_clips_long_strings_with_ellipsis() {
+ let out = truncate("abcdefghij", 5);
+ assert_eq!(out.chars().count(), 5);
+ assert!(out.ends_with('…'));
+ assert_eq!(out, "abcd…");
+ }
+
+ #[test]
+ fn truncate_counts_chars_not_bytes() {
+ // Multi-byte chars should be counted once, not by byte length.
+ let out = truncate("日本語テキスト", 4);
+ assert_eq!(out.chars().count(), 4);
+ assert!(out.ends_with('…'));
+ }
+
+ #[test]
+ fn truncate_width_passes_when_fits() {
+ assert_eq!(truncate_width("hello", 80), "hello");
+ }
+
+ #[test]
+ fn truncate_width_respects_visible_columns() {
+ let out = truncate_width("abcdefghij", 5);
+ assert!(out.ends_with('…'));
+ assert!(out.width() <= 5);
+ assert_eq!(out, "abcd…");
+ }
+
+ #[test]
+ fn truncate_width_treats_cjk_as_double_width() {
+ // Each CJK char is width 2; budget of 5 fits at most two chars
+ // plus the ellipsis (2 + 2 + 1 = 5).
+ let out = truncate_width("日本語テキスト", 5);
+ assert!(out.ends_with('…'));
+ assert!(out.width() <= 5);
+ }
+}
diff --git a/src/commands/login.rs b/src/commands/login.rs
index b7f0fcf..0012d38 100644
--- a/src/commands/login.rs
+++ b/src/commands/login.rs
@@ -1,26 +1,59 @@
-use anyhow::{anyhow, Context, Result};
+// `wk login` — loopback OAuth flow.
+//
+// The CLI:
+// 1. Binds a TCP listener on 127.0.0.1:.
+// 2. Generates a one-shot CSRF state.
+// 3. Opens the platform's `/cli-login` page in the user's default
+// browser, with the loopback URL and state as query params.
+// 4. Blocks on `accept()` until the platform redirects the browser
+// back to the loopback URL with `?token=…&state=…` (success) or
+// `?error=…&state=…` (cancel/error).
+// 5. Verifies the token by calling `/api/me`, persists it, and
+// returns control.
+//
+// The loopback HTTP server is intentionally hand-rolled (std::net):
+// one request, one response, no concurrency, no need to drag in a
+// framework. The handler reads at most a small fixed amount per
+// connection so a stray local probe can't tie us up.
+//
+// `--no-browser` falls back to printing the URL for the user to open
+// manually — useful on a remote host where no browser is available.
+// `--token` skips the dance entirely (e.g. for CI), accepting a
+// pre-minted `wkcli_…` token.
+
+use anyhow::{anyhow, bail, Context, Result};
use clap::Args as ClapArgs;
-use std::io::{self, Write};
+use rand::RngCore;
+use std::io::{BufRead, BufReader, Read, Write};
+use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener, TcpStream};
+use std::time::Duration;
use crate::client::Client;
use crate::config::{self, AuthConfig};
+use crate::style;
+
+const DEFAULT_BASE_URL: &str = "https://platform.wavekat.com";
-/// `wk login` arguments.
-///
-/// In v1 we don't have a CLI-friendly auth endpoint on the platform yet, so
-/// the simplest path is "paste your session cookie". A proper device-code
-/// flow lands together with the export feature on the platform side.
#[derive(ClapArgs)]
pub struct Args {
/// Base URL of the WaveKat platform (e.g. https://platform.wavekat.com).
- /// If omitted, the previously stored value is reused, or you'll be prompted.
+ /// If omitted, the previously stored value is reused, then the public
+ /// platform URL.
#[arg(long, env = "WK_BASE_URL")]
base_url: Option,
- /// Value of the `wk_session` cookie. Read from `WK_SESSION` if set,
- /// otherwise prompted for interactively (input is hidden).
- #[arg(long, env = "WK_SESSION")]
- session: Option,
+ /// Skip opening the browser; print the URL instead. Useful on a remote
+ /// host. The CLI still listens on a loopback port — open the URL on
+ /// any browser that can reach this machine on that port (typically via
+ /// SSH port-forward).
+ #[arg(long)]
+ no_browser: bool,
+
+ /// Pre-minted `wkcli_…` bearer token. Skips the browser handshake
+ /// entirely and just verifies + saves the token. Intended for CI.
+ /// Read from `WK_TOKEN` if set.
+ #[arg(long, env = "WK_TOKEN")]
+ token: Option,
}
pub async fn run(args: Args) -> Result<()> {
@@ -29,54 +62,342 @@ pub async fn run(args: Args) -> Result<()> {
let base_url = args
.base_url
.or_else(|| existing.as_ref().map(|c| c.base_url.clone()))
- .map(|s| s.trim_end_matches('/').to_string())
- .map(Ok)
- .unwrap_or_else(|| prompt_line("Base URL"))?;
+ .unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
+ .trim_end_matches('/')
+ .to_string();
- println!(
- "\nTo sign in:\n 1. Open {base_url} in your browser and sign in via GitHub.\n 2. Open dev tools → Application → Cookies → {base_url}.\n 3. Copy the value of the `wk_session` cookie.\n"
- );
-
- let session = match args.session {
- Some(s) => s,
- None => rpassword::prompt_password("wk_session cookie value: ")
- .context("reading session cookie")?,
+ let token = match args.token {
+ Some(t) => t.trim().to_string(),
+ None => browser_handshake(&base_url, args.no_browser)?,
};
- let session = session.trim().to_string();
- if session.is_empty() {
- return Err(anyhow!("session cookie cannot be empty"));
+ if token.is_empty() {
+ bail!("got an empty token from the platform");
}
let cfg = AuthConfig {
base_url,
- session_cookie: session,
+ token: Some(token),
+ session_cookie: None,
};
- // Verify the cookie before persisting it. /api/me is the cheapest
- // identity check the platform exposes and matches what the web app uses.
+ // Verify against /api/me before persisting — keeps a typo or a
+ // half-broken handshake from poisoning the saved config.
let client = Client::new(&cfg)?;
let me: serde_json::Value = client
.get_json("/api/me")
.await
- .context("verifying credentials against /api/me")?;
+ .context("verifying token against /api/me")?;
let login = me.get("login").and_then(|v| v.as_str()).unwrap_or("?");
let role = me.get("role").and_then(|v| v.as_str()).unwrap_or("?");
config::save(&cfg)?;
let path = config::auth_path()?;
- println!("Signed in as {login} (role: {role}).");
- println!("Credentials saved to {}", path.display());
+ println!(
+ "{} Signed in as {} ({} {}).",
+ style::green("✓"),
+ style::bold(login),
+ style::dim("role:"),
+ style::role(role),
+ );
+ println!(
+ "{} {}",
+ style::dim("Credentials saved to"),
+ style::dim(&path.display().to_string()),
+ );
Ok(())
}
-fn prompt_line(label: &str) -> Result {
- print!("{label}: ");
- io::stdout().flush()?;
- let mut buf = String::new();
- io::stdin().read_line(&mut buf)?;
- let buf = buf.trim().to_string();
- if buf.is_empty() {
- return Err(anyhow!("{label} cannot be empty"));
+fn browser_handshake(base_url: &str, no_browser: bool) -> Result {
+ // Bind to an ephemeral port on loopback only — never on 0.0.0.0,
+ // since anything bound to a non-loopback interface could be reached
+ // by another host on the network for the brief window we listen.
+ let listener = TcpListener::bind(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0))
+ .context("binding loopback listener for the OAuth handshake")?;
+ let port = listener.local_addr()?.port();
+
+ let state = random_state();
+ let name = client_name();
+ let callback = format!("http://127.0.0.1:{port}/callback");
+
+ let auth_url = format!(
+ "{base_url}/cli-login?callback={cb}&state={state}&name={name}",
+ cb = url::form_urlencoded::byte_serialize(callback.as_bytes()).collect::(),
+ state = url::form_urlencoded::byte_serialize(state.as_bytes()).collect::(),
+ name = url::form_urlencoded::byte_serialize(name.as_bytes()).collect::(),
+ );
+
+ if no_browser {
+ println!("Open this URL in any browser to finish signing in:\n {auth_url}\n");
+ } else {
+ println!("Opening {base_url} in your browser to sign in…");
+ if let Err(e) = webbrowser::open(&auth_url) {
+ eprintln!("(couldn't open the browser automatically: {e})");
+ println!("Open this URL manually:\n {auth_url}\n");
+ }
+ }
+ println!("Waiting for the browser to redirect back (Ctrl-C to cancel)…");
+
+ // 5 minutes is generous — if a user takes longer than that to log in
+ // they probably got distracted. Re-running `wk login` is cheap.
+ listener
+ .set_nonblocking(false)
+ .context("listener: set_blocking")?;
+ let deadline = std::time::Instant::now() + Duration::from_secs(5 * 60);
+
+ loop {
+ if std::time::Instant::now() > deadline {
+ bail!("timed out waiting for the browser to complete the login");
+ }
+ let (stream, _) = listener
+ .accept()
+ .context("accepting browser callback connection")?;
+ match handle_callback(stream, &state) {
+ Ok(Some(token)) => return Ok(token),
+ Ok(None) => continue, // probe / preflight; keep listening
+ Err(e) => {
+ // Surface but don't abort — a stray request shouldn't break
+ // the real one. (e.g. devtools sending a HEAD probe.)
+ eprintln!("(ignored bad callback request: {e})");
+ continue;
+ }
+ }
+ }
+}
+
+fn handle_callback(mut stream: TcpStream, expected_state: &str) -> Result