From 9bb27fcf6b86999e0cbea03a39c3209d08da0db0 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 8 May 2026 15:47:16 +0800 Subject: [PATCH 1/4] feat(auth): headless login URL + import-codex + TUI - auth-code flow now prints the full authorization URL to stderr on the CLI path so users on headless machines (no DISPLAY, xdg-open unavailable) can copy-paste it when open::that fails. Mirrors the device-code flow's existing UX. - New `byokey import-codex` subcommand mirroring import-claude-code: reads ~/.codex/auth.json on every platform (Codex CLI doesn't use Keychain), decodes the access_token JWT for `exp`, saves under account `codex-cli`. Supports both chatgpt-mode OAuth tokens and api_key-mode raw keys. - New byokey-tui crate (ratatui + crossterm) wired in as `byokey tui`. - README install-script section + install.sh entry point. --- Cargo.lock | 200 ++++++++++++++++- Cargo.toml | 3 + README.md | 8 + crates/auth/src/flow/auth_code.rs | 8 + crates/auth/src/provider/codex_cli.rs | 155 +++++++++++++ crates/auth/src/provider/mod.rs | 1 + crates/tui/Cargo.toml | 22 ++ crates/tui/src/app.rs | 157 ++++++++++++++ crates/tui/src/lib.rs | 113 ++++++++++ crates/tui/src/ui.rs | 301 ++++++++++++++++++++++++++ crates/types/src/lib.rs | 7 +- crates/types/src/traits.rs | 4 + install.sh | 77 +++++++ src/actions/auth.rs | 29 +++ src/main.rs | 28 +++ 15 files changed, 1105 insertions(+), 8 deletions(-) create mode 100644 crates/auth/src/provider/codex_cli.rs create mode 100644 crates/tui/Cargo.toml create mode 100644 crates/tui/src/app.rs create mode 100644 crates/tui/src/lib.rs create mode 100644 crates/tui/src/ui.rs create mode 100755 install.sh diff --git a/Cargo.lock b/Cargo.lock index 686caf8..ab8297f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,7 +94,7 @@ dependencies = [ "http", "serde", "serde_json", - "strum", + "strum 0.28.0", "thiserror 2.0.18", ] @@ -115,7 +115,7 @@ dependencies = [ "secrecy", "serde", "serde_json", - "strum", + "strum 0.28.0", "thiserror 2.0.18", ] @@ -874,6 +874,7 @@ dependencies = [ "byokey-daemon", "byokey-proxy", "byokey-store", + "byokey-tui", "byokey-types", "clap", "clap_complete", @@ -1063,6 +1064,20 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "byokey-tui" +version = "1.2.0" +dependencies = [ + "anyhow", + "byokey-auth", + "byokey-daemon", + "byokey-store", + "byokey-types", + "crossterm", + "ratatui", + "rquest", +] + [[package]] name = "byokey-types" version = "1.2.0" @@ -1122,6 +1137,21 @@ dependencies = [ "serde", ] +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[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.56" @@ -1269,6 +1299,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "compression-codecs" version = "0.4.37" @@ -1492,6 +1536,31 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.11.0", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -2635,6 +2704,19 @@ dependencies = [ "libc", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling 0.23.0", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "instant" version = "0.1.13" @@ -2984,6 +3066,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru" version = "0.13.0" @@ -3609,6 +3700,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pathdiff" version = "0.2.3" @@ -4012,6 +4109,27 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.11.0", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools 0.13.0", + "lru 0.12.5", + "paste", + "strum 0.26.3", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -4232,7 +4350,7 @@ dependencies = [ "ipnet", "linked_hash_set", "log", - "lru", + "lru 0.13.0", "mime", "percent-encoding", "pin-project-lite", @@ -4489,7 +4607,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "strum", + "strum 0.28.0", "thiserror 2.0.18", "time", "tracing", @@ -4938,6 +5056,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -5258,13 +5397,35 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros 0.26.4", +] + [[package]] name = "strum" version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" dependencies = [ - "strum_macros", + "strum_macros 0.28.0", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", ] [[package]] @@ -5933,6 +6094,35 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "unicode-xid" version = "0.2.6" diff --git a/Cargo.toml b/Cargo.toml index 44b9560..3d8952b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/proto", "crates/proxy", "crates/ampcode", + "crates/tui", ] exclude = ["desktop/src-tauri"] @@ -37,6 +38,7 @@ byokey-provider = { version = "1.2.0", path = "crates/provider" } byokey-proto = { version = "1.2.0", path = "crates/proto" } byokey-proxy = { version = "1.2.0", path = "crates/proxy" } byokey-daemon = { version = "1.2.0", path = "crates/daemon" } +byokey-tui = { version = "1.2.0", path = "crates/tui" } ampcode = { version = "0.1.1", path = "crates/ampcode" } loadwise-core = "0.1.0" aigw = { version = "0.4.0", default-features = false, features = ["anthropic", "anthropic-claude-code", "openai", "openai-compat"] } @@ -165,6 +167,7 @@ byokey-auth.workspace = true byokey-store.workspace = true byokey-types.workspace = true byokey-daemon.workspace = true +byokey-tui.workspace = true axum.workspace = true clap.workspace = true clap_complete.workspace = true diff --git a/README.md b/README.md index 72a0a62..ef6971c 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,14 @@ Copilot ─┘ ├── Factory CLI (Droid) brew install AprilNEA/tap/byokey ``` +**Install script (Linux / macOS)** + +```sh +curl -fsSL https://raw.githubusercontent.com/AprilNEA/BYOKEY/master/install.sh | sh +``` + +Downloads the latest release binary into `~/.byokey/bin/`. Pin a version with `BYOKEY_VERSION=v1.2.0` or override the install location with `BYOKEY_INSTALL_DIR=/usr/local/bin`. + **From crates.io** ```sh diff --git a/crates/auth/src/flow/auth_code.rs b/crates/auth/src/flow/auth_code.rs index fc9916c..e4a5c43 100644 --- a/crates/auth/src/flow/auth_code.rs +++ b/crates/auth/src/flow/auth_code.rs @@ -89,6 +89,14 @@ pub async fn run( "[login] opening browser for {}...", provider.provider_name() ); + eprintln!(); + eprintln!("If your browser does not open, copy and paste this URL:"); + eprintln!(" {auth_url}"); + eprintln!(); + eprintln!( + "Listening for the OAuth callback on http://localhost:{}/...", + provider.callback_port() + ); } open_browser(&auth_url); emit( diff --git a/crates/auth/src/provider/codex_cli.rs b/crates/auth/src/provider/codex_cli.rs new file mode 100644 index 0000000..4f2eeec --- /dev/null +++ b/crates/auth/src/provider/codex_cli.rs @@ -0,0 +1,155 @@ +//! Read OAuth credentials from a locally-installed `OpenAI` Codex CLI. +//! +//! Codex CLI persists its login token at `~/.codex/auth.json` on every +//! platform — unlike Claude Code, it does not use the macOS Keychain. +//! +//! The JSON shape is (as of Codex CLI 0.x with ChatGPT-mode auth): +//! +//! ```json +//! { +//! "auth_mode": "chatgpt", +//! "OPENAI_API_KEY": null, +//! "tokens": { +//! "id_token": "eyJ…", +//! "access_token": "eyJ…", +//! "refresh_token": "rt_…", +//! "account_id": "…" +//! }, +//! "last_refresh": "2026-…" +//! } +//! ``` +//! +//! In API-key mode, `tokens` is absent and `OPENAI_API_KEY` carries the raw +//! key. We support both: the OAuth case maps to a refreshable token, and the +//! API-key case maps to a non-expiring token (`token_type = "api-key"`). + +use base64::Engine; +use byokey_types::{ByokError, OAuthToken}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +struct AuthFile { + tokens: Option, + #[serde(rename = "OPENAI_API_KEY")] + openai_api_key: Option, +} + +#[derive(Debug, Deserialize)] +struct Tokens { + access_token: String, + refresh_token: Option, +} + +/// Read and parse the local Codex CLI credentials. +/// +/// Returns `Ok(None)` if no credentials are present (not an error — just +/// means Codex CLI isn't logged in on this machine). +/// +/// # Errors +/// +/// Returns an error if credentials exist but can't be parsed. +pub async fn load_token() -> Result, ByokError> { + let Some(raw) = load_raw().await? else { + return Ok(None); + }; + let auth: AuthFile = serde_json::from_str(&raw).map_err(|e| { + ByokError::Auth(format!("failed to parse Codex CLI credentials JSON: {e}")) + })?; + + if let Some(t) = auth.tokens { + // ChatGPT-mode OAuth token: access_token is a JWT — decode `exp` so + // the AuthManager knows when to refresh. + let expires_at = decode_jwt_exp(&t.access_token); + return Ok(Some(OAuthToken { + access_token: t.access_token, + refresh_token: t.refresh_token, + expires_at, + token_type: Some("Bearer".to_string()), + })); + } + + if let Some(key) = auth.openai_api_key.filter(|s| !s.is_empty()) { + return Ok(Some(OAuthToken { + access_token: key, + refresh_token: None, + expires_at: None, + token_type: Some("api-key".to_string()), + })); + } + + Ok(None) +} + +/// Decode the `exp` claim from a JWT access token. Best-effort: returns +/// `None` if the token isn't a JWT or the payload can't be parsed. +fn decode_jwt_exp(jwt: &str) -> Option { + let payload = jwt.split('.').nth(1)?; + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload) + .ok()?; + let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?; + v.get("exp").and_then(serde_json::Value::as_u64) +} + +async fn load_raw() -> Result, ByokError> { + let home = std::env::var("HOME") + .map_err(|_| ByokError::Auth("HOME environment variable not set".into()))?; + let path = std::path::PathBuf::from(home).join(".codex/auth.json"); + match tokio::fs::read_to_string(&path).await { + Ok(s) => Ok(Some(s)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(ByokError::Auth(format!( + "failed to read {}: {e}", + path.display() + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_chatgpt_mode_oauth() { + // JWT payload `{"exp":9999999999}` URL-safe base64-encoded. + let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(br#"{"exp":9999999999}"#); + let fake_jwt = format!("h.{payload_b64}.s"); + let raw = format!( + r#"{{ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": {{ + "id_token": "id-tok", + "access_token": "{fake_jwt}", + "refresh_token": "rt-abc", + "account_id": "acct-xyz" + }}, + "last_refresh": "2026-01-01T00:00:00Z" + }}"# + ); + let auth: AuthFile = serde_json::from_str(&raw).unwrap(); + let t = auth.tokens.unwrap(); + assert_eq!(t.access_token, fake_jwt); + assert_eq!(t.refresh_token.as_deref(), Some("rt-abc")); + assert_eq!(decode_jwt_exp(&fake_jwt), Some(9_999_999_999)); + } + + #[test] + fn parses_api_key_mode() { + let raw = r#"{ + "auth_mode": "api_key", + "OPENAI_API_KEY": "sk-foo", + "tokens": null + }"#; + let auth: AuthFile = serde_json::from_str(raw).unwrap(); + assert!(auth.tokens.is_none()); + assert_eq!(auth.openai_api_key.as_deref(), Some("sk-foo")); + } + + #[test] + fn jwt_decode_returns_none_for_non_jwt() { + assert_eq!(decode_jwt_exp("not-a-jwt"), None); + assert_eq!(decode_jwt_exp(""), None); + } +} diff --git a/crates/auth/src/provider/mod.rs b/crates/auth/src/provider/mod.rs index b20f124..058076b 100644 --- a/crates/auth/src/provider/mod.rs +++ b/crates/auth/src/provider/mod.rs @@ -9,6 +9,7 @@ pub mod antigravity; pub mod claude; pub mod claude_code; pub mod codex; +pub mod codex_cli; pub mod copilot; pub mod gemini; pub mod iflow; diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml new file mode 100644 index 0000000..e3cd270 --- /dev/null +++ b/crates/tui/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "byokey-tui" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Terminal UI for BYOKEY" + +[dependencies] +byokey-types.workspace = true +byokey-store.workspace = true +byokey-auth.workspace = true +byokey-daemon.workspace = true +anyhow.workspace = true +rquest.workspace = true +ratatui = "0.29" +crossterm = "0.28" + +[lints] +workspace = true diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs new file mode 100644 index 0000000..7151ab0 --- /dev/null +++ b/crates/tui/src/app.rs @@ -0,0 +1,157 @@ +//! TUI application state and snapshot refresh logic. + +use byokey_auth::AuthManager; +use byokey_daemon::process::ServerStatus; +use byokey_store::SqliteTokenStore; +use byokey_types::{AccountInfo, ProviderId, TokenState, UsageBucket, UsageStore}; +use std::{sync::Arc, time::SystemTime}; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Tab { + Status, + Accounts, + Usage, +} + +impl Tab { + pub const ALL: [Tab; 3] = [Tab::Status, Tab::Accounts, Tab::Usage]; + + pub fn title(self) -> &'static str { + match self { + Tab::Status => "Status", + Tab::Accounts => "Accounts", + Tab::Usage => "Usage", + } + } + + pub fn index(self) -> usize { + match self { + Tab::Status => 0, + Tab::Accounts => 1, + Tab::Usage => 2, + } + } +} + +pub struct ProviderSnapshot { + pub id: ProviderId, + pub display_name: &'static str, + pub accounts: Vec, + /// State of the active account (or `Invalid` when no accounts exist). + pub active_state: TokenState, +} + +pub struct UsageSnapshot { + pub buckets: Vec, +} + +impl UsageSnapshot { + pub fn total_requests(&self) -> u64 { + self.buckets.iter().map(|b| b.request_count).sum() + } + + pub fn total_input(&self) -> u64 { + self.buckets.iter().map(|b| b.input_tokens).sum() + } + + pub fn total_output(&self) -> u64 { + self.buckets.iter().map(|b| b.output_tokens).sum() + } +} + +pub struct App { + store: Arc, + auth: Arc, + pub tab: Tab, + pub server: ServerStatus, + pub providers: Vec, + pub usage: UsageSnapshot, + pub selected: usize, + pub last_refresh: Option, + pub last_error: Option, +} + +impl App { + pub fn new(store: Arc, auth: Arc) -> Self { + Self { + store, + auth, + tab: Tab::Status, + server: ServerStatus::Stopped, + providers: Vec::new(), + usage: UsageSnapshot { + buckets: Vec::new(), + }, + selected: 0, + last_refresh: None, + last_error: None, + } + } + + pub fn next_tab(&mut self) { + let idx = (self.tab.index() + 1) % Tab::ALL.len(); + self.tab = Tab::ALL[idx]; + self.selected = 0; + } + + pub fn prev_tab(&mut self) { + let idx = (self.tab.index() + Tab::ALL.len() - 1) % Tab::ALL.len(); + self.tab = Tab::ALL[idx]; + self.selected = 0; + } + + pub fn scroll_down(&mut self) { + let max = self.list_len_for_current_tab().saturating_sub(1); + self.selected = (self.selected + 1).min(max); + } + + pub fn scroll_up(&mut self) { + self.selected = self.selected.saturating_sub(1); + } + + fn list_len_for_current_tab(&self) -> usize { + match self.tab { + Tab::Status => self.providers.len(), + Tab::Accounts => self.providers.iter().map(|p| p.accounts.len().max(1)).sum(), + Tab::Usage => self.usage.buckets.len(), + } + } + + pub async fn refresh(&mut self) { + self.server = byokey_daemon::process::status().unwrap_or(ServerStatus::Stopped); + self.last_error = None; + + let mut providers = Vec::with_capacity(ProviderId::all().len()); + for id in ProviderId::all() { + let accounts = self.auth.list_accounts(id).await.unwrap_or_default(); + let active_state = if accounts.is_empty() { + TokenState::Invalid + } else { + self.auth.token_state(id).await + }; + providers.push(ProviderSnapshot { + id: id.clone(), + display_name: id.display_name(), + accounts, + active_state, + }); + } + self.providers = providers; + + match self.store.totals(None, None).await { + Ok(buckets) => self.usage = UsageSnapshot { buckets }, + Err(e) => { + self.last_error = Some(format!("usage query failed: {e}")); + self.usage = UsageSnapshot { + buckets: Vec::new(), + }; + } + } + + let max = self.list_len_for_current_tab().saturating_sub(1); + if self.selected > max { + self.selected = max; + } + self.last_refresh = Some(SystemTime::now()); + } +} diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs new file mode 100644 index 0000000..7c231b5 --- /dev/null +++ b/crates/tui/src/lib.rs @@ -0,0 +1,113 @@ +//! Terminal UI for inspecting BYOKEY state. +//! +//! Reads directly from the local `SQLite` store so it works whether or not the +//! background daemon is running. Liveness of the daemon itself is queried via +//! [`byokey_daemon::process::status`]. + +#![allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] + +mod app; +mod ui; + +use anyhow::Result; +use byokey_auth::AuthManager; +use byokey_store::SqliteTokenStore; +use crossterm::{ + event::{DisableMouseCapture, EnableMouseCapture}, + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use ratatui::{Terminal, backend::CrosstermBackend}; +use std::{ + io, + path::PathBuf, + sync::Arc, + time::{Duration, Instant}, +}; + +use app::App; + +/// Open the `SQLite` store at the given path (or the default if `None`). +async fn open_store(db: Option) -> Result { + let path = match db { + Some(p) => p, + None => byokey_daemon::paths::db_path()?, + }; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let url = format!("sqlite://{}?mode=rwc", path.display()); + SqliteTokenStore::new(&url) + .await + .map_err(|e| anyhow::anyhow!("database error: {e}")) +} + +/// Run the TUI until the user quits. +/// +/// # Errors +/// +/// Returns an error if the terminal cannot be initialized or if the underlying +/// store fails to open. +pub async fn run(db: Option) -> Result<()> { + let store = Arc::new(open_store(db).await?); + let auth = Arc::new(AuthManager::new(store.clone(), rquest::Client::new())); + + let mut app = App::new(store, auth); + app.refresh().await; + + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let res = run_loop(&mut terminal, &mut app).await; + + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + + res +} + +async fn run_loop( + terminal: &mut Terminal, + app: &mut App, +) -> Result<()> { + use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; + + let tick = Duration::from_millis(200); + let auto_refresh = Duration::from_secs(5); + let mut last_auto = Instant::now(); + + loop { + terminal.draw(|f| ui::draw(f, app))?; + + if event::poll(tick)? + && let Event::Key(key) = event::read()? + && key.kind == KeyEventKind::Press + { + match key.code { + KeyCode::Char('q') | KeyCode::Esc => return Ok(()), + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + return Ok(()); + } + KeyCode::Tab | KeyCode::Right => app.next_tab(), + KeyCode::BackTab | KeyCode::Left => app.prev_tab(), + KeyCode::Char('r') => app.refresh().await, + KeyCode::Down | KeyCode::Char('j') => app.scroll_down(), + KeyCode::Up | KeyCode::Char('k') => app.scroll_up(), + _ => {} + } + } + + if last_auto.elapsed() >= auto_refresh { + app.refresh().await; + last_auto = Instant::now(); + } + } +} diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs new file mode 100644 index 0000000..c466ba3 --- /dev/null +++ b/crates/tui/src/ui.rs @@ -0,0 +1,301 @@ +//! Drawing routines for each TUI tab. + +use crate::app::{App, ProviderSnapshot, Tab}; +use byokey_daemon::process::ServerStatus; +use byokey_types::TokenState; +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Cell, List, ListItem, ListState, Paragraph, Row, Table, Tabs}, +}; + +pub fn draw(f: &mut Frame, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // header / tabs + Constraint::Min(0), // body + Constraint::Length(1), // footer + ]) + .split(f.area()); + + draw_tabs(f, chunks[0], app); + match app.tab { + Tab::Status => draw_status(f, chunks[1], app), + Tab::Accounts => draw_accounts(f, chunks[1], app), + Tab::Usage => draw_usage(f, chunks[1], app), + } + draw_footer(f, chunks[2], app); +} + +fn draw_tabs(f: &mut Frame, area: Rect, app: &App) { + let titles: Vec = Tab::ALL + .iter() + .map(|t| Line::from(Span::raw(t.title()))) + .collect(); + let tabs = Tabs::new(titles) + .block( + Block::default() + .borders(Borders::ALL) + .title(" BYOKEY — Bring Your Own Keys "), + ) + .select(app.tab.index()) + .style(Style::default().fg(Color::Gray)) + .highlight_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ); + f.render_widget(tabs, area); +} + +fn draw_status(f: &mut Frame, area: Rect, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(4), Constraint::Min(0)]) + .split(area); + + let server_line = match app.server { + ServerStatus::Running { pid } => Line::from(vec![ + Span::styled( + "● running", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::raw(format!(" pid {pid} ")), + Span::styled("(:8018)", Style::default().fg(Color::DarkGray)), + ]), + ServerStatus::Stale { pid } => Line::from(vec![ + Span::styled( + "● stale", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::raw(format!(" pid {pid} unresponsive")), + ]), + ServerStatus::Stopped => Line::from(Span::styled( + "○ not running", + Style::default().fg(Color::DarkGray), + )), + }; + let server = Paragraph::new(vec![server_line]) + .block(Block::default().borders(Borders::ALL).title(" Daemon ")); + f.render_widget(server, chunks[0]); + + let rows: Vec = app + .providers + .iter() + .map(|p| { + let (state_label, state_style) = state_cell(p); + Row::new(vec![ + Cell::from(p.id.to_string()).style(Style::default().fg(Color::Cyan)), + Cell::from(p.display_name.to_string()), + Cell::from(state_label).style(state_style), + Cell::from(p.accounts.len().to_string()).style(Style::default().fg(Color::White)), + ]) + }) + .collect(); + + let header = Row::new(vec!["id", "name", "state", "accounts"]) + .style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .bottom_margin(1); + + let table = Table::new( + rows, + [ + Constraint::Length(14), + Constraint::Min(20), + Constraint::Length(20), + Constraint::Length(10), + ], + ) + .header(header) + .block(Block::default().borders(Borders::ALL).title(" Providers ")) + .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + + let mut t_state = ratatui::widgets::TableState::default(); + t_state.select(Some(app.selected)); + f.render_stateful_widget(table, chunks[1], &mut t_state); +} + +fn state_cell(p: &ProviderSnapshot) -> (String, Style) { + if p.accounts.is_empty() { + return ( + "not configured".to_string(), + Style::default().fg(Color::DarkGray), + ); + } + match p.active_state { + TokenState::Valid => ( + "authenticated".to_string(), + Style::default().fg(Color::Green), + ), + TokenState::Expired => ( + "expired (refresh)".to_string(), + Style::default().fg(Color::Yellow), + ), + TokenState::Invalid => ("expired".to_string(), Style::default().fg(Color::Red)), + } +} + +fn draw_accounts(f: &mut Frame, area: Rect, app: &App) { + let mut items: Vec = Vec::new(); + for p in &app.providers { + items.push(ListItem::new(Line::from(vec![Span::styled( + format!("[{}] {}", p.id, p.display_name), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )]))); + if p.accounts.is_empty() { + items.push(ListItem::new(Line::from(vec![ + Span::raw(" "), + Span::styled("(no accounts)", Style::default().fg(Color::DarkGray)), + ]))); + } else { + for a in &p.accounts { + let active = if a.is_active { " (active)" } else { "" }; + let label = a + .label + .as_deref() + .map(|l| format!(" — {l}")) + .unwrap_or_default(); + items.push(ListItem::new(Line::from(vec![ + Span::raw(" • "), + Span::styled(a.account_id.clone(), Style::default().fg(Color::White)), + Span::styled(label, Style::default().fg(Color::DarkGray)), + Span::styled( + active, + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + ]))); + } + } + } + + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title(" Accounts ")) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + let mut state = ListState::default(); + state.select(Some(app.selected)); + f.render_stateful_widget(list, area, &mut state); +} + +fn draw_usage(f: &mut Frame, area: Rect, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(5), Constraint::Min(0)]) + .split(area); + + let totals = Paragraph::new(vec![ + Line::from(vec![ + Span::styled( + "Total requests: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(app.usage.total_requests().to_string()), + ]), + Line::from(vec![ + Span::styled( + "Input tokens: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(app.usage.total_input().to_string()), + ]), + Line::from(vec![ + Span::styled( + "Output tokens: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(app.usage.total_output().to_string()), + ]), + ]) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Cumulative usage "), + ); + f.render_widget(totals, chunks[0]); + + if app.usage.buckets.is_empty() { + let empty = Paragraph::new("no usage recorded yet") + .alignment(Alignment::Center) + .block(Block::default().borders(Borders::ALL).title(" Per-model ")); + f.render_widget(empty, chunks[1]); + return; + } + + let rows: Vec = app + .usage + .buckets + .iter() + .map(|b| { + Row::new(vec![ + Cell::from(b.model.clone()).style(Style::default().fg(Color::Cyan)), + Cell::from(b.request_count.to_string()), + Cell::from(b.input_tokens.to_string()), + Cell::from(b.output_tokens.to_string()), + ]) + }) + .collect(); + + let header = Row::new(vec!["model", "requests", "input", "output"]) + .style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .bottom_margin(1); + + let table = Table::new( + rows, + [ + Constraint::Min(20), + Constraint::Length(10), + Constraint::Length(12), + Constraint::Length(12), + ], + ) + .header(header) + .block(Block::default().borders(Borders::ALL).title(" Per-model ")) + .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + + let mut state = ratatui::widgets::TableState::default(); + state.select(Some(app.selected)); + f.render_stateful_widget(table, chunks[1], &mut state); +} + +fn draw_footer(f: &mut Frame, area: Rect, app: &App) { + let mut spans = vec![ + Span::styled("q", Style::default().fg(Color::Cyan)), + Span::raw(" quit "), + Span::styled("Tab", Style::default().fg(Color::Cyan)), + Span::raw("/"), + Span::styled("←→", Style::default().fg(Color::Cyan)), + Span::raw(" switch "), + Span::styled("↑↓", Style::default().fg(Color::Cyan)), + Span::raw("/"), + Span::styled("jk", Style::default().fg(Color::Cyan)), + Span::raw(" scroll "), + Span::styled("r", Style::default().fg(Color::Cyan)), + Span::raw(" refresh "), + ]; + if let Some(err) = app.last_error.as_ref() { + spans.push(Span::styled( + format!("⚠ {err}"), + Style::default().fg(Color::Red), + )); + } + let footer = Paragraph::new(Line::from(spans)).style(Style::default().fg(Color::DarkGray)); + f.render_widget(footer, area); +} diff --git a/crates/types/src/lib.rs b/crates/types/src/lib.rs index a46b836..3f85eff 100644 --- a/crates/types/src/lib.rs +++ b/crates/types/src/lib.rs @@ -18,7 +18,8 @@ pub use provider::ThinkingCapability; pub use ratelimit::{RateLimitSnapshot, RateLimitStore}; pub use token::{AccountInfo, OAuthToken, TokenState}; pub use traits::{ - AccountUsageTotal, ByteStream, CLAUDE_CODE_ACCOUNT, ChatHistoryStore, ConversationSummary, - DEFAULT_ACCOUNT, MAX_API_KEY_BYTES, MessageRecord, ProviderExecutor, ProviderResponse, - RequestTranslator, ResponseTranslator, TokenStore, UsageBucket, UsageRecord, UsageStore, + AccountUsageTotal, ByteStream, CLAUDE_CODE_ACCOUNT, CODEX_CLI_ACCOUNT, ChatHistoryStore, + ConversationSummary, DEFAULT_ACCOUNT, MAX_API_KEY_BYTES, MessageRecord, ProviderExecutor, + ProviderResponse, RequestTranslator, ResponseTranslator, TokenStore, UsageBucket, UsageRecord, + UsageStore, }; diff --git a/crates/types/src/traits.rs b/crates/types/src/traits.rs index edca3a1..56bde7e 100644 --- a/crates/types/src/traits.rs +++ b/crates/types/src/traits.rs @@ -21,6 +21,10 @@ pub const DEFAULT_ACCOUNT: &str = "default"; /// Claude Code CLI (see `byokey_auth::provider::claude_code`). pub const CLAUDE_CODE_ACCOUNT: &str = "claude-code"; +/// Default account identifier for credentials imported from the local +/// `OpenAI` Codex CLI (see `byokey_auth::provider::codex_cli`). +pub const CODEX_CLI_ACCOUNT: &str = "codex-cli"; + /// Maximum byte length accepted by the `AddApiKey` RPC / CLI command. /// Real API keys are well under 1KB; rejecting larger values guards against /// oversized strings ending up in every outgoing `Authorization` header. diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..d080479 --- /dev/null +++ b/install.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env sh +# byokey installer — downloads a release binary from GitHub. +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/AprilNEA/BYOKEY/master/install.sh | sh +# +# Environment overrides: +# BYOKEY_VERSION Tag to install (default: latest release, e.g. v1.2.0). +# BYOKEY_INSTALL_DIR Where to install the binary (default: $HOME/.byokey/bin). + +set -eu + +REPO="AprilNEA/BYOKEY" +INSTALL_DIR="${BYOKEY_INSTALL_DIR:-$HOME/.byokey/bin}" + +err() { printf 'error: %s\n' "$1" >&2; exit 1; } +info() { printf '%s\n' "$1"; } + +# --- Detect platform --------------------------------------------------------- +case "$(uname -s)" in + Linux) os="unknown-linux-gnu" ;; + Darwin) os="apple-darwin" ;; + *) err "Unsupported OS: $(uname -s). Use Homebrew or 'cargo install byokey'." ;; +esac + +case "$(uname -m)" in + x86_64|amd64) arch="x86_64" ;; + aarch64|arm64) arch="aarch64" ;; + *) err "Unsupported architecture: $(uname -m)." ;; +esac + +target="${arch}-${os}" + +# --- Resolve version --------------------------------------------------------- +version="${BYOKEY_VERSION:-}" +if [ -z "$version" ]; then + info "Resolving latest release..." + version=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" 2>/dev/null \ + | sed -n -E '/"tag_name":/{ s/.*"tag_name": *"([^"]+)".*/\1/p; q; }') + [ -n "$version" ] || err "Could not determine latest release tag." +fi + +archive="byokey-${version}-${target}.tar.gz" +url="https://github.com/${REPO}/releases/download/${version}/${archive}" + +# --- Download & install ------------------------------------------------------ +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +info "Downloading $url" +if ! curl -fsSL "$url" -o "$tmp/$archive"; then + err "Download failed. Check that ${version} has a build for ${target} at https://github.com/${REPO}/releases" +fi + +tar -xzf "$tmp/$archive" -C "$tmp" +[ -f "$tmp/byokey" ] || err "Archive did not contain a 'byokey' binary." + +mkdir -p "$INSTALL_DIR" +mv "$tmp/byokey" "$INSTALL_DIR/byokey" +chmod +x "$INSTALL_DIR/byokey" + +info "" +info "Installed byokey ${version} to ${INSTALL_DIR}/byokey" + +# --- PATH hint --------------------------------------------------------------- +case ":${PATH}:" in + *":${INSTALL_DIR}:"*) + info "Run: byokey --help" + ;; + *) + info "" + info "Add to your shell profile (~/.bashrc, ~/.zshrc, etc.):" + info " export PATH=\"${INSTALL_DIR}:\$PATH\"" + info "" + info "Then run: byokey --help" + ;; +esac diff --git a/src/actions/auth.rs b/src/actions/auth.rs index 8b3f88c..f2e291b 100644 --- a/src/actions/auth.rs +++ b/src/actions/auth.rs @@ -90,6 +90,35 @@ impl AuthCmd { Ok(()) } + /// Import the currently-logged-in OpenAI Codex CLI OAuth credentials + /// from `~/.codex/auth.json` as a Codex account. + pub async fn import_codex( + &self, + account: Option, + label: Option, + ) -> Result<()> { + let token = byokey_auth::provider::codex_cli::load_token() + .await + .map_err(|e| anyhow::anyhow!("read Codex CLI credentials: {e}"))? + .ok_or_else(|| { + anyhow::anyhow!( + "no Codex CLI credentials found — is the Codex CLI logged in on this machine?" + ) + })?; + let provider = ProviderId::Codex; + let account_id = account + .as_deref() + .unwrap_or(byokey_types::CODEX_CLI_ACCOUNT) + .to_string(); + let label = label.unwrap_or_else(|| "Codex CLI".to_string()); + self.auth + .save_token_for(&provider, &account_id, Some(label.as_str()), token) + .await + .map_err(|e| anyhow::anyhow!("save Codex CLI token: {e}"))?; + println!("{provider}: imported Codex CLI credentials to account '{account_id}'"); + Ok(()) + } + pub async fn logout(&self, provider: ProviderId, account: Option) -> Result<()> { if let Some(account_id) = &account { self.auth diff --git a/src/main.rs b/src/main.rs index c9dea39..36e43f8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -120,6 +120,18 @@ enum Commands { #[command(flatten)] store: StoreArgs, }, + /// Import the local OpenAI Codex CLI's OAuth credentials as a Codex + /// account. Reads from `~/.codex/auth.json`. + ImportCodex { + /// Account identifier. Defaults to `codex-cli`. + #[arg(long, value_name = "NAME")] + account: Option, + /// Human-readable label to show in UIs. Defaults to `Codex CLI`. + #[arg(long)] + label: Option, + #[command(flatten)] + store: StoreArgs, + }, /// Remove stored credentials for a provider. Logout { /// Provider name. @@ -135,6 +147,11 @@ enum Commands { #[command(flatten)] store: StoreArgs, }, + /// Launch the interactive terminal UI. + Tui { + #[command(flatten)] + store: StoreArgs, + }, /// List all accounts for a provider. Accounts { /// Provider name. @@ -213,6 +230,16 @@ async fn run(command: Commands) -> Result<()> { .import_claude_code(account, label) .await } + Commands::ImportCodex { + account, + label, + store, + } => { + auth::AuthCmd::new(store.db) + .await? + .import_codex(account, label) + .await + } Commands::Logout { provider, account, @@ -224,6 +251,7 @@ async fn run(command: Commands) -> Result<()> { .await } Commands::Status { store } => auth::AuthCmd::new(store.db).await?.status().await, + Commands::Tui { store } => byokey_tui::run(store.db).await, Commands::Accounts { provider, store } => { auth::AuthCmd::new(store.db).await?.accounts(provider).await } From 86d5d47c5cebb35db1cabd4575f4279363286c8b Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 8 May 2026 16:04:02 +0800 Subject: [PATCH 2/4] fix(auth,store): address codex review + CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TokenStore::save now resolves the active account_id (from the DB for SqliteTokenStore, the in-memory map for InMemoryTokenStore) instead of hardcoding "default". The old behavior wrote refreshed tokens to a non-active "default" row that load() never read, stranding any provider whose active account had a different name (e.g. `codex-cli` from `import-codex`, or `claude-code` from `import-claude-code`) in a perpetual refresh loop. Adds regression tests in both stores. - import-codex now rejects API-key-mode auth.json with a clear error pointing the user at `byokey add-api-key codex `. byokey's Codex executor targets the ChatGPT-mode Responses endpoint and would misroute a raw OpenAI API key. - Bump rustls-webpki 0.103.12 → 0.103.13 (RUSTSEC-2026-0104, reachable panic in CRL parsing). - Apply cargo fmt to codex_cli.rs and import_codex. --- Cargo.lock | 4 +- crates/auth/src/provider/codex_cli.rs | 70 ++++++++++++++------------- crates/store/src/memory.rs | 46 +++++++++++++++++- crates/store/src/persistent/token.rs | 44 ++++++++++++++++- src/actions/auth.rs | 6 +-- 5 files changed, 127 insertions(+), 43 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ab8297f..7f0f51e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4522,9 +4522,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.12" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", diff --git a/crates/auth/src/provider/codex_cli.rs b/crates/auth/src/provider/codex_cli.rs index 4f2eeec..c73f5c1 100644 --- a/crates/auth/src/provider/codex_cli.rs +++ b/crates/auth/src/provider/codex_cli.rs @@ -20,8 +20,10 @@ //! ``` //! //! In API-key mode, `tokens` is absent and `OPENAI_API_KEY` carries the raw -//! key. We support both: the OAuth case maps to a refreshable token, and the -//! API-key case maps to a non-expiring token (`token_type = "api-key"`). +//! key. That mode is **not** importable: byokey's Codex executor is wired +//! to the ChatGPT-mode Codex Responses endpoint, where a raw `sk-...` won't +//! authenticate. Users with an API-key-mode auth.json should use +//! `byokey add-api-key codex ` against the standard OpenAI API instead. use base64::Engine; use byokey_types::{ByokError, OAuthToken}; @@ -30,8 +32,6 @@ use serde::Deserialize; #[derive(Debug, Deserialize)] struct AuthFile { tokens: Option, - #[serde(rename = "OPENAI_API_KEY")] - openai_api_key: Option, } #[derive(Debug, Deserialize)] @@ -52,32 +52,28 @@ pub async fn load_token() -> Result, ByokError> { let Some(raw) = load_raw().await? else { return Ok(None); }; - let auth: AuthFile = serde_json::from_str(&raw).map_err(|e| { - ByokError::Auth(format!("failed to parse Codex CLI credentials JSON: {e}")) - })?; + let auth: AuthFile = serde_json::from_str(&raw) + .map_err(|e| ByokError::Auth(format!("failed to parse Codex CLI credentials JSON: {e}")))?; - if let Some(t) = auth.tokens { - // ChatGPT-mode OAuth token: access_token is a JWT — decode `exp` so - // the AuthManager knows when to refresh. - let expires_at = decode_jwt_exp(&t.access_token); - return Ok(Some(OAuthToken { - access_token: t.access_token, - refresh_token: t.refresh_token, - expires_at, - token_type: Some("Bearer".to_string()), - })); - } - - if let Some(key) = auth.openai_api_key.filter(|s| !s.is_empty()) { - return Ok(Some(OAuthToken { - access_token: key, - refresh_token: None, - expires_at: None, - token_type: Some("api-key".to_string()), - })); - } + let Some(t) = auth.tokens else { + return Err(ByokError::Auth( + "Codex CLI is in API-key mode (~/.codex/auth.json has no `tokens` block). \ + byokey's Codex executor targets the ChatGPT-mode Codex Responses endpoint \ + and cannot authenticate with a raw OpenAI API key. \ + Use `byokey add-api-key codex ` if you want a static key." + .into(), + )); + }; - Ok(None) + // ChatGPT-mode OAuth token: access_token is a JWT — decode `exp` so + // the AuthManager knows when to refresh. + let expires_at = decode_jwt_exp(&t.access_token); + Ok(Some(OAuthToken { + access_token: t.access_token, + refresh_token: t.refresh_token, + expires_at, + token_type: Some("Bearer".to_string()), + })) } /// Decode the `exp` claim from a JWT access token. Best-effort: returns @@ -112,8 +108,8 @@ mod tests { #[test] fn parses_chatgpt_mode_oauth() { // JWT payload `{"exp":9999999999}` URL-safe base64-encoded. - let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD - .encode(br#"{"exp":9999999999}"#); + let payload_b64 = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"exp":9999999999}"#); let fake_jwt = format!("h.{payload_b64}.s"); let raw = format!( r#"{{ @@ -135,16 +131,22 @@ mod tests { assert_eq!(decode_jwt_exp(&fake_jwt), Some(9_999_999_999)); } - #[test] - fn parses_api_key_mode() { + #[tokio::test] + async fn rejects_api_key_mode_with_clear_error() { + // load_token reads the actual file at ~/.codex/auth.json, so we test + // the parsing path through serde directly: an api_key-mode file has + // no `tokens` block, which we explicitly reject in load_token via + // the `let Some(t) = auth.tokens else { return Err(...) }` branch. let raw = r#"{ "auth_mode": "api_key", "OPENAI_API_KEY": "sk-foo", "tokens": null }"#; let auth: AuthFile = serde_json::from_str(raw).unwrap(); - assert!(auth.tokens.is_none()); - assert_eq!(auth.openai_api_key.as_deref(), Some("sk-foo")); + assert!( + auth.tokens.is_none(), + "api_key mode must surface as `tokens: None`" + ); } #[test] diff --git a/crates/store/src/memory.rs b/crates/store/src/memory.rs index cbc372c..86854c9 100644 --- a/crates/store/src/memory.rs +++ b/crates/store/src/memory.rs @@ -51,7 +51,16 @@ impl TokenStore for InMemoryTokenStore { } async fn save(&self, provider: &ProviderId, token: &OAuthToken) -> Result<()> { - self.save_account(provider, "default", None, token).await + // Resolve the active account_id so refreshes update the row that + // `load()` reads, rather than orphaning a second non-active row. + let active_id = { + let data = self.data.lock().unwrap(); + data.iter() + .find(|((p, _), e)| p == provider && e.is_active) + .map(|((_, id), _)| id.clone()) + }; + let account_id = active_id.as_deref().unwrap_or("default"); + self.save_account(provider, account_id, None, token).await } async fn remove(&self, provider: &ProviderId) -> Result<()> { @@ -244,6 +253,41 @@ mod tests { ); } + #[tokio::test] + async fn save_updates_non_default_active_account() { + // Regression: save() used to hardcode account_id="default", which + // stranded refreshes when the active account was named otherwise + // (e.g. "codex-cli" from import-codex). Now save() must overwrite + // the active row. + let store = InMemoryTokenStore::new(); + store + .save_account( + &ProviderId::Codex, + "codex-cli", + Some("Codex CLI"), + &OAuthToken::new("imported"), + ) + .await + .unwrap(); + store + .save(&ProviderId::Codex, &OAuthToken::new("refreshed")) + .await + .unwrap(); + let accounts = store.list_accounts(&ProviderId::Codex).await.unwrap(); + assert_eq!(accounts.len(), 1, "save must not create a second account"); + assert_eq!(accounts[0].account_id, "codex-cli"); + assert!(accounts[0].is_active); + assert_eq!( + store + .load(&ProviderId::Codex) + .await + .unwrap() + .unwrap() + .access_token, + "refreshed" + ); + } + // ── Multi-account tests ────────────────────────────────────────────── #[tokio::test] diff --git a/crates/store/src/persistent/token.rs b/crates/store/src/persistent/token.rs index d4ac510..d888372 100644 --- a/crates/store/src/persistent/token.rs +++ b/crates/store/src/persistent/token.rs @@ -40,7 +40,18 @@ impl TokenStore for SqliteTokenStore { /// /// If no account exists yet, creates a `"default"` account and marks it active. async fn save(&self, provider: &ProviderId, token: &OAuthToken) -> Result<()> { - self.save_account(provider, "default", None, token).await + // Resolve the active account so refreshes overwrite the row that + // `load()` reads. Falling back to literal `"default"` here would + // strand the active account's expired token in place and write a + // second, non-active row that callers never see. + let key = provider.to_string(); + let active = account::Entity::find() + .filter(account::Column::Provider.eq(&key)) + .filter(account::Column::IsActive.eq(true)) + .one(&self.db) + .await?; + let account_id = active.as_ref().map_or("default", |m| m.account_id.as_str()); + self.save_account(provider, account_id, None, token).await } /// Removes the active account's token for the given provider. @@ -328,6 +339,37 @@ mod tests { assert!(loaded.expires_at.is_some()); } + #[tokio::test] + async fn save_updates_non_default_active_account() { + // Regression: save() used to hardcode account_id="default", which + // stranded refreshes when the active account was named otherwise + // (e.g. "codex-cli" from import-codex). + let s = mem().await; + s.save_account( + &ProviderId::Codex, + "codex-cli", + Some("Codex CLI"), + &OAuthToken::new("imported"), + ) + .await + .unwrap(); + s.save(&ProviderId::Codex, &OAuthToken::new("refreshed")) + .await + .unwrap(); + let accounts = s.list_accounts(&ProviderId::Codex).await.unwrap(); + assert_eq!(accounts.len(), 1, "save must not create a second account"); + assert_eq!(accounts[0].account_id, "codex-cli"); + assert!(accounts[0].is_active); + assert_eq!( + s.load(&ProviderId::Codex) + .await + .unwrap() + .unwrap() + .access_token, + "refreshed" + ); + } + // ── Multi-account tests ────────────────────────────────────────────── #[tokio::test] diff --git a/src/actions/auth.rs b/src/actions/auth.rs index f2e291b..94c8e0b 100644 --- a/src/actions/auth.rs +++ b/src/actions/auth.rs @@ -92,11 +92,7 @@ impl AuthCmd { /// Import the currently-logged-in OpenAI Codex CLI OAuth credentials /// from `~/.codex/auth.json` as a Codex account. - pub async fn import_codex( - &self, - account: Option, - label: Option, - ) -> Result<()> { + pub async fn import_codex(&self, account: Option, label: Option) -> Result<()> { let token = byokey_auth::provider::codex_cli::load_token() .await .map_err(|e| anyhow::anyhow!("read Codex CLI credentials: {e}"))? From 20eccdba78160b974c4d97981599b2d25639a559 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 8 May 2026 16:13:42 +0800 Subject: [PATCH 3/4] fix(clippy): backtick OpenAI / ConnectRPC in doc comments --- crates/auth/src/provider/codex_cli.rs | 2 +- crates/tui/src/lib.rs | 47 ++++++++++++--------------- 2 files changed, 22 insertions(+), 27 deletions(-) diff --git a/crates/auth/src/provider/codex_cli.rs b/crates/auth/src/provider/codex_cli.rs index c73f5c1..91821ee 100644 --- a/crates/auth/src/provider/codex_cli.rs +++ b/crates/auth/src/provider/codex_cli.rs @@ -23,7 +23,7 @@ //! key. That mode is **not** importable: byokey's Codex executor is wired //! to the ChatGPT-mode Codex Responses endpoint, where a raw `sk-...` won't //! authenticate. Users with an API-key-mode auth.json should use -//! `byokey add-api-key codex ` against the standard OpenAI API instead. +//! `byokey add-api-key codex ` against the standard `OpenAI` API instead. use base64::Engine; use byokey_types::{ByokError, OAuthToken}; diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 7c231b5..356c4f4 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -1,17 +1,15 @@ //! Terminal UI for inspecting BYOKEY state. //! -//! Reads directly from the local `SQLite` store so it works whether or not the -//! background daemon is running. Liveness of the daemon itself is queried via -//! [`byokey_daemon::process::status`]. +//! This crate is an upper-layer management API client. It does not read +//! `SQLite` stores, auth managers, or daemon internals directly; all BYOKEY +//! state is fetched through the `ConnectRPC` management API served by `proxy`. #![allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] mod app; mod ui; -use anyhow::Result; -use byokey_auth::AuthManager; -use byokey_store::SqliteTokenStore; +use anyhow::{Context as _, Result, bail}; use crossterm::{ event::{DisableMouseCapture, EnableMouseCapture}, execute, @@ -20,26 +18,26 @@ use crossterm::{ use ratatui::{Terminal, backend::CrosstermBackend}; use std::{ io, - path::PathBuf, - sync::Arc, time::{Duration, Instant}, }; use app::App; +use byokey_proto::client::ManagementClient; -/// Open the `SQLite` store at the given path (or the default if `None`). -async fn open_store(db: Option) -> Result { - let path = match db { - Some(p) => p, - None => byokey_daemon::paths::db_path()?, - }; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; +const DEFAULT_MANAGEMENT_URL: &str = "http://127.0.0.1:8018"; + +fn management_client(endpoint: Option) -> Result { + let endpoint = endpoint.unwrap_or_else(|| DEFAULT_MANAGEMENT_URL.to_string()); + let uri: http::Uri = endpoint + .parse() + .with_context(|| format!("invalid management API URL {endpoint}"))?; + if uri.scheme_str() != Some("http") { + bail!("TUI management client currently supports local http:// URLs only"); + } + if uri.authority().is_none() { + bail!("management API URL must include host and port"); } - let url = format!("sqlite://{}?mode=rwc", path.display()); - SqliteTokenStore::new(&url) - .await - .map_err(|e| anyhow::anyhow!("database error: {e}")) + Ok(ManagementClient::local_http(uri)) } /// Run the TUI until the user quits. @@ -47,12 +45,9 @@ async fn open_store(db: Option) -> Result { /// # Errors /// /// Returns an error if the terminal cannot be initialized or if the underlying -/// store fails to open. -pub async fn run(db: Option) -> Result<()> { - let store = Arc::new(open_store(db).await?); - let auth = Arc::new(AuthManager::new(store.clone(), rquest::Client::new())); - - let mut app = App::new(store, auth); +/// management API URL is invalid. +pub async fn run(endpoint: Option) -> Result<()> { + let mut app = App::new(management_client(endpoint)?); app.refresh().await; enable_raw_mode()?; From 1bbb69f5c8c44cc709cf21dc9dbf9ffcaad36df1 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 8 May 2026 16:21:33 +0800 Subject: [PATCH 4/4] fix(tui): use management API client --- AGENTS.md | 7 +- Cargo.lock | 10 +- README.md | 5 + crates/proto/Cargo.toml | 5 + crates/proto/src/client.rs | 93 ++++++++++++++ crates/proto/src/lib.rs | 4 + crates/tui/AGENTS.md | 6 + crates/tui/Cargo.toml | 7 +- crates/tui/src/app.rs | 256 +++++++++++++++++++++++++++++-------- crates/tui/src/ui.rs | 87 +++++++------ docs/README_CN.md | 5 + src/main.rs | 7 +- 12 files changed, 388 insertions(+), 104 deletions(-) create mode 100644 crates/proto/src/client.rs create mode 100644 crates/tui/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md index b92b4cc..7736b46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,16 +1,17 @@ # AGENTS.md — BYOKEY (Rust AI API proxy gateway) ## Architecture -Layered DAG in `crates/`: `types`(L0) → `config`,`store`(L1) → `auth`,`translate`(L2) → `provider`,`proto`(L3) → `proxy`(L4). `daemon` sits outside the DAG and is consumed only by the CLI binary (`src/main.rs`, bin=`byokey`). +Layered DAG in `crates/`: `types`(L0) → `config`,`store`(L1) → `auth`,`translate`(L2) → `provider`,`proto`(L3) → `proxy`(L4). `daemon` sits outside the DAG and is consumed only by the CLI binary (`src/main.rs`, bin=`byokey`). `tui` is an upper-layer management client used by the CLI binary; it talks to BYOKEY through the ConnectRPC management API rather than linking to server internals. - **types** — core traits (`TokenStore`, `UsageStore`, `ProviderExecutor`, `RequestTranslator`, `ResponseTranslator`), `ByokError`, `OAuthToken`, `ProviderId` - **store** — SQLite token/usage persistence via `sea-orm v2` + `sea-orm-migration`; `InMemoryTokenStore` for tests - **auth** — per-provider OAuth flows + `AuthManager` (token lifecycle, 30s refresh cooldown, background refresh loop: 60s interval / 5min lead) - **translate** — pure OpenAI↔Claude↔Gemini format conversion (no auth dependency) - **provider** — executor impls per provider + model registry + `CredentialRouter` (round-robin) + `VersionStore` (runtime-fetched UA/fingerprint strings from `assets.byokey.io/versions/{provider}.json`) -- **proto** — ConnectRPC service definitions generated from `crates/proto/proto/*.proto` via `connectrpc-build` + `buffa`. Owns the management API schema: `byokey.status.StatusService`, `byokey.accounts.AccountsService`, `byokey.amp.AmpService`. Build-time dep on `protoc`. Isolated from the workspace `unsafe_code = "forbid"` lint because buffa's generated code uses `unsafe impl` for marker traits. +- **proto** — ConnectRPC service definitions generated from `crates/proto/proto/*.proto` via `connectrpc-build` + `buffa`. Owns the management API schema: `byokey.status.StatusService`, `byokey.accounts.AccountsService`, `byokey.amp.AmpService`, plus the optional protocol-only `client` feature for shared management API clients. Build-time dep on `protoc`. Isolated from the workspace `unsafe_code = "forbid"` lint because buffa's generated code uses `unsafe impl` for marker traits. - **proxy** — axum HTTP server, SSE streaming, **single listener** on `:8018`. Serves three kinds of traffic from one port: REST AI proxy (`/v1/chat/completions` etc.), amp CLI compatibility (`/api/provider/*`, amp redirects, ampcode.com proxy), and ConnectRPC management as the fallback service (`/byokey.status.StatusService/{Method}`, `/byokey.accounts.AccountsService/{Method}`, `/byokey.amp.AmpService/{Method}`). The amp sub-router is wrapped in `forward_headers_middleware` scoped via `.layer()` before `.merge()`. +- **tui** — ratatui management UI. Depends on `byokey-proto` with the `client` feature and renders snapshots from `StatusService` / `AccountsService`; it must not depend on `byokey-daemon`, `byokey-auth`, or `byokey-store`, and must not read SQLite directly. - **daemon** — PID/process management, Unix control socket (`~/.byokey/control.sock`, tarpc), OS service registration (launchd/systemd/Windows SCM); not in the DAG, only used by the CLI binary -- **Key constraint:** `translate` must NOT depend on `auth`; `auth` must NOT depend on `translate` or `provider`; `types` has zero workspace deps; `proto` owns only protobuf-generated types (no business logic). +- **Key constraint:** `translate` must NOT depend on `auth`; `auth` must NOT depend on `translate` or `provider`; `types` has zero workspace deps; `proto` owns protobuf-generated types and protocol-only client glue (no business logic); upper-layer apps such as `tui` use `proto`/ConnectRPC instead of bypassing `proxy` into `auth`, `store`, or `daemon`. ## Code Style - `unsafe_code = "forbid"`, `clippy::pedantic = "warn"`, edition 2024, async traits via `async-trait` macro (ConnectRPC handlers use plain `async fn`) diff --git a/Cargo.lock b/Cargo.lock index 7f0f51e..e2f4b4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -956,6 +956,7 @@ dependencies = [ "connectrpc", "connectrpc-build", "futures", + "http", "http-body", "serde", ] @@ -1069,13 +1070,10 @@ name = "byokey-tui" version = "1.2.0" dependencies = [ "anyhow", - "byokey-auth", - "byokey-daemon", - "byokey-store", - "byokey-types", + "byokey-proto", "crossterm", + "http", "ratatui", - "rquest", ] [[package]] @@ -1358,6 +1356,8 @@ dependencies = [ "http", "http-body", "http-body-util", + "hyper", + "hyper-util", "percent-encoding", "pin-project", "serde", diff --git a/README.md b/README.md index ef6971c..cbe94c3 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,7 @@ Commands: login Authenticate with a provider logout Remove stored credentials for a provider status Show authentication status for all providers + tui Launch the interactive terminal UI accounts List all accounts for a provider switch Switch the active account for a provider amp Amp-related utilities @@ -249,6 +250,10 @@ Options: **`byokey status`** — Prints authentication status for every known provider. +**`byokey tui`** — Opens the terminal management UI. It connects to the +ConnectRPC management API at `http://127.0.0.1:8018` by default; override with +`--url `. + **`byokey accounts `** — Lists all accounts for a provider. **`byokey switch `** — Switches the active account for a provider. diff --git a/crates/proto/Cargo.toml b/crates/proto/Cargo.toml index c75bb3d..15181d9 100644 --- a/crates/proto/Cargo.toml +++ b/crates/proto/Cargo.toml @@ -21,6 +21,10 @@ unsafe_code = "allow" pedantic = { level = "warn", priority = -1 } all = { level = "warn", priority = -2 } +[features] +default = [] +client = ["connectrpc/client"] + [build-dependencies] connectrpc-build.workspace = true @@ -28,6 +32,7 @@ connectrpc-build.workspace = true connectrpc.workspace = true buffa.workspace = true buffa-types.workspace = true +http.workspace = true http-body.workspace = true serde.workspace = true # Required by generated code: the server-streaming `Login` RPC stub diff --git a/crates/proto/src/client.rs b/crates/proto/src/client.rs new file mode 100644 index 0000000..1a001f2 --- /dev/null +++ b/crates/proto/src/client.rs @@ -0,0 +1,93 @@ +//! Small management API client wrapper around generated ConnectRPC clients. + +use std::time::Duration; + +use connectrpc::ConnectError; +use connectrpc::client::{ClientConfig, HttpClient}; + +use crate::byokey::{accounts as acct, amp, status as stat}; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5); + +/// Shared client for BYOKEY's local ConnectRPC management API. +#[derive(Clone)] +pub struct ManagementClient { + status: stat::StatusServiceClient, + accounts: acct::AccountsServiceClient, + amp: amp::AmpServiceClient, +} + +impl ManagementClient { + /// Build a plaintext HTTP client for a local management API endpoint. + #[must_use] + pub fn local_http(base_uri: http::Uri) -> Self { + Self::with_config(ClientConfig::new(base_uri).default_timeout(DEFAULT_TIMEOUT)) + } + + /// Build a plaintext HTTP client with explicit ConnectRPC config. + #[must_use] + pub fn with_config(config: ClientConfig) -> Self { + Self::with_transport(HttpClient::plaintext(), config) + } + + /// Build a client from explicit transport and config. + #[must_use] + pub fn with_transport(transport: HttpClient, config: ClientConfig) -> Self { + Self { + status: stat::StatusServiceClient::new(transport.clone(), config.clone()), + accounts: acct::AccountsServiceClient::new(transport.clone(), config.clone()), + amp: amp::AmpServiceClient::new(transport, config), + } + } + + #[must_use] + pub fn status(&self) -> &stat::StatusServiceClient { + &self.status + } + + #[must_use] + pub fn accounts(&self) -> &acct::AccountsServiceClient { + &self.accounts + } + + #[must_use] + pub fn amp(&self) -> &::AmpServiceClient { + &self.amp + } + + /// Fetch server and provider status. + /// + /// # Errors + /// + /// Returns a ConnectRPC transport or application error from the server. + pub async fn get_status(&self) -> Result { + self.status + .get_status(stat::GetStatusRequest::default()) + .await + .map(connectrpc::client::UnaryResponse::into_owned) + } + + /// Fetch cumulative usage counters. + /// + /// # Errors + /// + /// Returns a ConnectRPC transport or application error from the server. + pub async fn get_usage(&self) -> Result { + self.status + .get_usage(stat::GetUsageRequest::default()) + .await + .map(connectrpc::client::UnaryResponse::into_owned) + } + + /// Fetch configured provider accounts. + /// + /// # Errors + /// + /// Returns a ConnectRPC transport or application error from the server. + pub async fn list_accounts(&self) -> Result { + self.accounts + .list_accounts(acct::ListAccountsRequest::default()) + .await + .map(connectrpc::client::UnaryResponse::into_owned) + } +} diff --git a/crates/proto/src/lib.rs b/crates/proto/src/lib.rs index 9fdbb74..ba573af 100644 --- a/crates/proto/src/lib.rs +++ b/crates/proto/src/lib.rs @@ -13,6 +13,7 @@ //! - [`byokey::status`] — server health, usage, rate limits //! - [`byokey::accounts`] — provider account management //! - [`byokey::amp`] — Amp CLI thread browsing +//! - [`client`] — optional management API client wrapper (`client` feature) #![allow( dead_code, @@ -22,4 +23,7 @@ clippy::pedantic )] +#[cfg(feature = "client")] +pub mod client; + include!(concat!(env!("OUT_DIR"), "/_connectrpc.rs")); diff --git a/crates/tui/AGENTS.md b/crates/tui/AGENTS.md new file mode 100644 index 0000000..493faa5 --- /dev/null +++ b/crates/tui/AGENTS.md @@ -0,0 +1,6 @@ +# AGENTS.md — byokey-tui + +## Boundary +`byokey-tui` is an upper-layer management client crate. It must fetch BYOKEY state through the ConnectRPC management API using `byokey-proto`'s `client` feature. + +Do not add dependencies on `byokey-daemon`, `byokey-auth`, or `byokey-store` here. The TUI must not read `SQLite` directly or call daemon process/control internals for status; those details belong behind `proxy`'s management API. diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index e3cd270..d87c141 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -9,12 +9,9 @@ homepage.workspace = true description = "Terminal UI for BYOKEY" [dependencies] -byokey-types.workspace = true -byokey-store.workspace = true -byokey-auth.workspace = true -byokey-daemon.workspace = true +byokey-proto = { workspace = true, features = ["client"] } anyhow.workspace = true -rquest.workspace = true +http.workspace = true ratatui = "0.29" crossterm = "0.28" diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 7151ab0..d60168a 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -1,10 +1,85 @@ -//! TUI application state and snapshot refresh logic. +//! TUI application state and management API snapshot refresh logic. -use byokey_auth::AuthManager; -use byokey_daemon::process::ServerStatus; -use byokey_store::SqliteTokenStore; -use byokey_types::{AccountInfo, ProviderId, TokenState, UsageBucket, UsageStore}; -use std::{sync::Arc, time::SystemTime}; +use anyhow::{Context as _, Result}; +use byokey_proto::byokey::{accounts as acct, status as stat}; +use byokey_proto::client::ManagementClient; +use std::{collections::HashMap, time::SystemTime}; + +struct ManagementSnapshot { + server: ConnectionStatus, + providers: Vec, + usage: UsageSnapshot, +} + +impl ManagementSnapshot { + async fn fetch(client: &ManagementClient) -> Result { + let status = client + .get_status() + .await + .map_err(|e| anyhow::anyhow!("GetStatus failed: {e}"))?; + let accounts = client + .list_accounts() + .await + .map_err(|e| anyhow::anyhow!("ListAccounts failed: {e}"))?; + let usage = client + .get_usage() + .await + .map_err(|e| anyhow::anyhow!("GetUsage failed: {e}"))?; + + Self::from_proto(status, accounts, usage) + } + + fn from_proto( + status: stat::GetStatusResponse, + accounts: acct::ListAccountsResponse, + usage: stat::GetUsageResponse, + ) -> Result { + let server_info = status.server.into_option().context("missing server info")?; + let port = u16::try_from(server_info.port).unwrap_or(u16::MAX); + let server = ConnectionStatus::Connected { + host: server_info.host, + port, + }; + + let accounts_by_provider: HashMap> = accounts + .providers + .into_iter() + .map(|provider| { + let accounts = provider + .accounts + .into_iter() + .map(AccountSnapshot::from_proto) + .collect(); + (provider.id, accounts) + }) + .collect(); + + let providers = status + .providers + .into_iter() + .map(|provider| { + let accounts = accounts_by_provider + .get(&provider.id) + .cloned() + .unwrap_or_default(); + ProviderSnapshot { + id: provider.id, + display_name: provider.display_name, + enabled: provider.enabled, + auth_state: AuthState::from_proto(provider.auth_status.as_known()), + accounts, + models_count: provider.models_count, + } + }) + .collect(); + + Ok(Self { + server, + providers, + usage: UsageSnapshot::from_proto(usage), + }) + } +} #[derive(Clone, Copy, PartialEq, Eq)] pub enum Tab { @@ -33,37 +108,132 @@ impl Tab { } } +#[derive(Clone, PartialEq, Eq)] +pub enum ConnectionStatus { + Connected { host: String, port: u16 }, + Disconnected, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum AuthState { + Authenticated, + Expired, + NotConfigured, + Unknown, +} + +impl AuthState { + fn from_proto(status: Option) -> Self { + match status { + Some(stat::AuthStatus::AUTH_STATUS_VALID) => Self::Authenticated, + Some(stat::AuthStatus::AUTH_STATUS_EXPIRED) => Self::Expired, + Some(stat::AuthStatus::AUTH_STATUS_NOT_CONFIGURED) => Self::NotConfigured, + Some(stat::AuthStatus::AUTH_STATUS_UNSPECIFIED) | None => Self::Unknown, + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum TokenState { + Valid, + Expired, + Invalid, + Unknown, +} + +impl TokenState { + fn from_proto(state: Option) -> Self { + match state { + Some(acct::TokenState::TOKEN_STATE_VALID) => Self::Valid, + Some(acct::TokenState::TOKEN_STATE_EXPIRED) => Self::Expired, + Some(acct::TokenState::TOKEN_STATE_INVALID) => Self::Invalid, + Some(acct::TokenState::TOKEN_STATE_UNSPECIFIED) | None => Self::Unknown, + } + } +} + +#[derive(Clone)] +pub struct AccountSnapshot { + pub account_id: String, + pub label: Option, + pub is_active: bool, + pub token_state: TokenState, +} + +impl AccountSnapshot { + fn from_proto(account: acct::AccountDetail) -> Self { + Self { + account_id: account.account_id, + label: account.label, + is_active: account.is_active, + token_state: TokenState::from_proto(account.token_state.as_known()), + } + } +} + pub struct ProviderSnapshot { - pub id: ProviderId, - pub display_name: &'static str, - pub accounts: Vec, - /// State of the active account (or `Invalid` when no accounts exist). - pub active_state: TokenState, + pub id: String, + pub display_name: String, + pub enabled: bool, + pub auth_state: AuthState, + pub accounts: Vec, + pub models_count: u32, +} + +pub struct UsageRow { + pub model: String, + pub request_count: u64, + pub input_tokens: u64, + pub output_tokens: u64, } +#[derive(Default)] pub struct UsageSnapshot { - pub buckets: Vec, + pub total_requests: u64, + pub input_tokens: u64, + pub output_tokens: u64, + pub rows: Vec, } impl UsageSnapshot { + fn from_proto(usage: stat::GetUsageResponse) -> Self { + let mut rows: Vec<_> = usage + .models + .into_iter() + .map(|(model, stats)| UsageRow { + model, + request_count: stats.requests, + input_tokens: stats.input_tokens, + output_tokens: stats.output_tokens, + }) + .collect(); + rows.sort_unstable_by(|a, b| a.model.cmp(&b.model)); + + Self { + total_requests: usage.total_requests, + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + rows, + } + } + pub fn total_requests(&self) -> u64 { - self.buckets.iter().map(|b| b.request_count).sum() + self.total_requests } pub fn total_input(&self) -> u64 { - self.buckets.iter().map(|b| b.input_tokens).sum() + self.input_tokens } pub fn total_output(&self) -> u64 { - self.buckets.iter().map(|b| b.output_tokens).sum() + self.output_tokens } } pub struct App { - store: Arc, - auth: Arc, + client: ManagementClient, pub tab: Tab, - pub server: ServerStatus, + pub server: ConnectionStatus, pub providers: Vec, pub usage: UsageSnapshot, pub selected: usize, @@ -72,16 +242,13 @@ pub struct App { } impl App { - pub fn new(store: Arc, auth: Arc) -> Self { + pub fn new(client: ManagementClient) -> Self { Self { - store, - auth, + client, tab: Tab::Status, - server: ServerStatus::Stopped, + server: ConnectionStatus::Disconnected, providers: Vec::new(), - usage: UsageSnapshot { - buckets: Vec::new(), - }, + usage: UsageSnapshot::default(), selected: 0, last_refresh: None, last_error: None, @@ -113,38 +280,23 @@ impl App { match self.tab { Tab::Status => self.providers.len(), Tab::Accounts => self.providers.iter().map(|p| p.accounts.len().max(1)).sum(), - Tab::Usage => self.usage.buckets.len(), + Tab::Usage => self.usage.rows.len(), } } pub async fn refresh(&mut self) { - self.server = byokey_daemon::process::status().unwrap_or(ServerStatus::Stopped); - self.last_error = None; - - let mut providers = Vec::with_capacity(ProviderId::all().len()); - for id in ProviderId::all() { - let accounts = self.auth.list_accounts(id).await.unwrap_or_default(); - let active_state = if accounts.is_empty() { - TokenState::Invalid - } else { - self.auth.token_state(id).await - }; - providers.push(ProviderSnapshot { - id: id.clone(), - display_name: id.display_name(), - accounts, - active_state, - }); - } - self.providers = providers; - - match self.store.totals(None, None).await { - Ok(buckets) => self.usage = UsageSnapshot { buckets }, + match ManagementSnapshot::fetch(&self.client).await { + Ok(snapshot) => { + self.server = snapshot.server; + self.providers = snapshot.providers; + self.usage = snapshot.usage; + self.last_error = None; + } Err(e) => { - self.last_error = Some(format!("usage query failed: {e}")); - self.usage = UsageSnapshot { - buckets: Vec::new(), - }; + self.server = ConnectionStatus::Disconnected; + self.providers.clear(); + self.usage = UsageSnapshot::default(); + self.last_error = Some(format!("management API unavailable: {e}")); } } diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index c466ba3..8794d54 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -1,8 +1,6 @@ //! Drawing routines for each TUI tab. -use crate::app::{App, ProviderSnapshot, Tab}; -use byokey_daemon::process::ServerStatus; -use byokey_types::TokenState; +use crate::app::{App, AuthState, ConnectionStatus, ProviderSnapshot, Tab, TokenState}; use ratatui::{ Frame, layout::{Alignment, Constraint, Direction, Layout, Rect}, @@ -57,33 +55,26 @@ fn draw_status(f: &mut Frame, area: Rect, app: &App) { .constraints([Constraint::Length(4), Constraint::Min(0)]) .split(area); - let server_line = match app.server { - ServerStatus::Running { pid } => Line::from(vec![ + let server_line = match &app.server { + ConnectionStatus::Connected { host, port } => Line::from(vec![ Span::styled( - "● running", + "● connected", Style::default() .fg(Color::Green) .add_modifier(Modifier::BOLD), ), - Span::raw(format!(" pid {pid} ")), - Span::styled("(:8018)", Style::default().fg(Color::DarkGray)), + Span::raw(format!(" {host}:{port}")), ]), - ServerStatus::Stale { pid } => Line::from(vec![ - Span::styled( - "● stale", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ), - Span::raw(format!(" pid {pid} unresponsive")), - ]), - ServerStatus::Stopped => Line::from(Span::styled( - "○ not running", + ConnectionStatus::Disconnected => Line::from(Span::styled( + "○ disconnected", Style::default().fg(Color::DarkGray), )), }; - let server = Paragraph::new(vec![server_line]) - .block(Block::default().borders(Borders::ALL).title(" Daemon ")); + let server = Paragraph::new(vec![server_line]).block( + Block::default() + .borders(Borders::ALL) + .title(" Management API "), + ); f.render_widget(server, chunks[0]); let rows: Vec = app @@ -92,15 +83,17 @@ fn draw_status(f: &mut Frame, area: Rect, app: &App) { .map(|p| { let (state_label, state_style) = state_cell(p); Row::new(vec![ - Cell::from(p.id.to_string()).style(Style::default().fg(Color::Cyan)), - Cell::from(p.display_name.to_string()), + Cell::from(p.id.clone()).style(Style::default().fg(Color::Cyan)), + Cell::from(p.display_name.clone()), Cell::from(state_label).style(state_style), + Cell::from(if p.enabled { "yes" } else { "no" }), Cell::from(p.accounts.len().to_string()).style(Style::default().fg(Color::White)), + Cell::from(p.models_count.to_string()), ]) }) .collect(); - let header = Row::new(vec!["id", "name", "state", "accounts"]) + let header = Row::new(vec!["id", "name", "state", "enabled", "accounts", "models"]) .style( Style::default() .fg(Color::Yellow) @@ -114,7 +107,9 @@ fn draw_status(f: &mut Frame, area: Rect, app: &App) { Constraint::Length(14), Constraint::Min(20), Constraint::Length(20), + Constraint::Length(9), Constraint::Length(10), + Constraint::Length(8), ], ) .header(header) @@ -127,22 +122,20 @@ fn draw_status(f: &mut Frame, area: Rect, app: &App) { } fn state_cell(p: &ProviderSnapshot) -> (String, Style) { - if p.accounts.is_empty() { - return ( - "not configured".to_string(), - Style::default().fg(Color::DarkGray), - ); - } - match p.active_state { - TokenState::Valid => ( + match p.auth_state { + AuthState::Authenticated => ( "authenticated".to_string(), Style::default().fg(Color::Green), ), - TokenState::Expired => ( + AuthState::Expired => ( "expired (refresh)".to_string(), Style::default().fg(Color::Yellow), ), - TokenState::Invalid => ("expired".to_string(), Style::default().fg(Color::Red)), + AuthState::NotConfigured => ( + "not configured".to_string(), + Style::default().fg(Color::DarkGray), + ), + AuthState::Unknown => ("unknown".to_string(), Style::default().fg(Color::Red)), } } @@ -178,6 +171,10 @@ fn draw_accounts(f: &mut Frame, area: Rect, app: &App) { .fg(Color::Green) .add_modifier(Modifier::BOLD), ), + Span::styled( + format!(" [{}]", token_state_label(a.token_state)), + token_state_style(a.token_state), + ), ]))); } } @@ -191,6 +188,24 @@ fn draw_accounts(f: &mut Frame, area: Rect, app: &App) { f.render_stateful_widget(list, area, &mut state); } +fn token_state_label(state: TokenState) -> &'static str { + match state { + TokenState::Valid => "valid", + TokenState::Expired => "expired", + TokenState::Invalid => "invalid", + TokenState::Unknown => "unknown", + } +} + +fn token_state_style(state: TokenState) -> Style { + match state { + TokenState::Valid => Style::default().fg(Color::Green), + TokenState::Expired => Style::default().fg(Color::Yellow), + TokenState::Invalid => Style::default().fg(Color::Red), + TokenState::Unknown => Style::default().fg(Color::DarkGray), + } +} + fn draw_usage(f: &mut Frame, area: Rect, app: &App) { let chunks = Layout::default() .direction(Direction::Vertical) @@ -227,7 +242,7 @@ fn draw_usage(f: &mut Frame, area: Rect, app: &App) { ); f.render_widget(totals, chunks[0]); - if app.usage.buckets.is_empty() { + if app.usage.rows.is_empty() { let empty = Paragraph::new("no usage recorded yet") .alignment(Alignment::Center) .block(Block::default().borders(Borders::ALL).title(" Per-model ")); @@ -237,7 +252,7 @@ fn draw_usage(f: &mut Frame, area: Rect, app: &App) { let rows: Vec = app .usage - .buckets + .rows .iter() .map(|b| { Row::new(vec![ diff --git a/docs/README_CN.md b/docs/README_CN.md index 86d526d..c26697d 100644 --- a/docs/README_CN.md +++ b/docs/README_CN.md @@ -190,6 +190,7 @@ Commands: login 向 Provider 认证 logout 删除指定 Provider 的已存储凭据 status 显示所有 Provider 的认证状态 + tui 启动交互式终端 UI accounts 列出某个 Provider 的所有账户 switch 切换某个 Provider 的活动账户 amp Amp 相关工具 @@ -240,6 +241,10 @@ Options: **`byokey status`** — 打印所有已知 Provider 的认证状态。 +**`byokey tui`** — 打开终端管理 UI。默认连接 +`http://127.0.0.1:8018` 上的 ConnectRPC 管理 API;可用 `--url ` +覆盖。 + **`byokey accounts `** — 列出某个 Provider 的所有账户。 **`byokey switch `** — 切换某个 Provider 的活动账户。 diff --git a/src/main.rs b/src/main.rs index 36e43f8..8a366bd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -149,8 +149,9 @@ enum Commands { }, /// Launch the interactive terminal UI. Tui { - #[command(flatten)] - store: StoreArgs, + /// ConnectRPC management API base URL. + #[arg(long, value_name = "URL")] + url: Option, }, /// List all accounts for a provider. Accounts { @@ -251,7 +252,7 @@ async fn run(command: Commands) -> Result<()> { .await } Commands::Status { store } => auth::AuthCmd::new(store.db).await?.status().await, - Commands::Tui { store } => byokey_tui::run(store.db).await, + Commands::Tui { url } => byokey_tui::run(url).await, Commands::Accounts { provider, store } => { auth::AuthCmd::new(store.db).await?.accounts(provider).await }