Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .github/workflows/release-plz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: Release-plz

on:
push:
branches: [main]
workflow_dispatch:

jobs:
release-plz-release:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: read
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: release-plz/action@v0.5
with:
command: release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}

release-plz-pr:
needs: release-plz-release
if: always()
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
concurrency:
group: release-plz-${{ github.ref }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: release-plz/action@v0.5
id: release-plz
with:
command: release-pr
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Job summary
if: always()
run: |
echo "## Release PR" >> "$GITHUB_STEP_SUMMARY"
if [ "${{ steps.release-plz.outputs.prs_created }}" = "true" ]; then
echo "Release PR created." >> "$GITHUB_STEP_SUMMARY"
else
echo "No release PR created." >> "$GITHUB_STEP_SUMMARY"
fi
5 changes: 4 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,14 @@ path = "src/main.rs"
anyhow = "1"
clap = { version = "4", features = ["derive", "env"] }
dirs = "5"
rand = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "gzip", "stream"] }
rpassword = "7"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
unicode-width = "0.2"
url = "2"
webbrowser = "1"

[profile.release]
opt-level = "z"
Expand Down
94 changes: 58 additions & 36 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,26 +1,33 @@
# wavekat-cli (`wk`)
<p align="center">
<a href="https://github.com/wavekat/wavekat-cli">
<img src="https://github.com/wavekat/wavekat-brand/raw/main/assets/banners/wavekat-cli-narrow.svg" alt="WaveKat CLI">
</a>
</p>

Command-line client for the [WaveKat platform](https://github.com/wavekat/wavekat-platform).
[![Crates.io](https://img.shields.io/crates/v/wavekat-cli.svg)](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 <id>` | `GET /api/projects/{id}` | project detail (raw JSON) |
| `wk annotations list <project-id>` | `GET /api/projects/{id}/annotations` | paginated annotations (raw JSON) |
| `wk projects show <id>` | `GET /api/projects/{id}` | project summary (`--json` for raw) |
| `wk annotations list <project-id>` | `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

Expand All @@ -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 `<platform>/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
Expand All @@ -91,7 +113,11 @@ wk projects list --page-size 5

wk projects list --json | jq '.projects[].name'

wk annotations list <project-id> --label end_of_turn --review-status approved \
# Default: human-readable table with the ASR snippet under each row.
wk annotations list <project-id> --label end_of_turn --review-status approved

# Pipe raw JSON into jq for scripting.
wk annotations list <project-id> --label end_of_turn --review-status approved --json \
| jq '.annotations | length'
```

Expand All @@ -100,14 +126,10 @@ wk annotations list <project-id> --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).
3 changes: 3 additions & 0 deletions release-plz.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[workspace]
git_tag_enable = true
git_release_enable = true
48 changes: 42 additions & 6 deletions src/client.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -18,11 +18,26 @@ impl Client {

pub fn new(cfg: &AuthConfig) -> Result<Self> {
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")))
Expand All @@ -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<T: DeserializeOwned, Q: Serialize + ?Sized>(
&self,
path: &str,
Expand Down
Loading