diff --git a/doc/reference/04-environment-variables.md b/doc/reference/04-environment-variables.md index 35c70e3c..5e712b4e 100644 --- a/doc/reference/04-environment-variables.md +++ b/doc/reference/04-environment-variables.md @@ -141,7 +141,7 @@ Available to hook scripts when processing incoming webhooks. ## .env File Support -Octomind automatically loads a `.env` file from the working directory at startup, as an alternative to exporting variables in your shell. This is useful for project-specific API keys: +Octomind automatically loads `.env` files at startup, as an alternative to exporting variables in your shell. This is useful for API keys: ```bash # .env @@ -149,8 +149,16 @@ OPENROUTER_API_KEY=sk-or-v1-... ANTHROPIC_API_KEY=sk-ant-... ``` +Two `.env` locations are loaded, in precedence order (later wins): + +1. **User-scope** — `/.env` in the shared config directory (alongside `config.toml`). Shared across all projects. +2. **Project-local** — `./.env` in the current working directory. Overrides the user-scope file. + +System environment variables are the base; both `.env` files override them. + Key behaviors: - **`.env` overrides the system environment.** When a variable is defined in both, the `.env` value wins. +- **Project-local `.env` overrides user-scope `.env`.** A key set in the working directory wins over the same key in the shared config directory. - **Empty values are treated as "not set."** A variable whose value is empty (or only whitespace) is reported as `NotFound` for API-key source detection — so leaving `OPENROUTER_API_KEY=` empty is the same as not defining it. - **Source tracking.** The `EnvTracker` records whether each variable came from the system environment (`System`) or the `.env` file (`DotEnv`); this source is shown in debug mode. diff --git a/doc/usage/04-providers.md b/doc/usage/04-providers.md index 379993b5..22ba2371 100644 --- a/doc/usage/04-providers.md +++ b/doc/usage/04-providers.md @@ -35,6 +35,14 @@ ANTHROPIC_API_KEY=your_key Octomind loads `.env` on startup and it **overrides** the process environment. An **empty** value is treated as "not set", so a blank `KEY=` line will not satisfy a required key. `octomind config --show` reports whether each detected key came from the system environment or from `.env`. +In addition to the project-local `.env`, Octomind also loads a **user-scope** `.env` from the shared config directory (the same directory holding your `config.toml` and other `*.toml` files). This lets you set keys once for all projects. Load order, later wins: + +1. System environment variables (base) +2. User-scope `/.env` (shared across projects) +3. Project-local `./.env` (overrides user-scope) + +A key set in the project `.env` therefore wins over the user-scope `.env`, which wins over the system environment. + ## Supported Providers The most common providers are documented in detail below. The full set of provider prefixes octolib recognizes is: @@ -154,7 +162,6 @@ The following providers also work via the same `_API_KEY` pattern. Eac | Provider | Format | Env var(s) | |----------|--------|------------| | Cerebras | `cerebras:model` | `CEREBRAS_API_KEY` | -| Groq | `groq:model` | `GROQ_API_KEY` | | Together | `together:model` | `TOGETHER_API_KEY` | | Fireworks | `fireworks:model` | `FIREWORKS_API_KEY` | | NVIDIA | `nvidia:model` | `NVIDIA_API_KEY` | diff --git a/src/account.rs b/src/account.rs new file mode 100644 index 00000000..967d2108 --- /dev/null +++ b/src/account.rs @@ -0,0 +1,247 @@ +// Copyright 2026 Muvon Un Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Octomind account client — the control-plane API behind `octomind login`. +//! +//! `octomind login` leaves two things behind, and neither is a developer API key +//! (those stay something you create deliberately in the panel): +//! +//! - `OCTOHUB_API_KEY` in the user-scope `.env` — the model-gateway credential +//! octolib's octohub provider reads. +//! - a panel session (`auth.json`) — the same short-lived jwt + long refresh +//! token the browser gets. This module authenticates with it and refreshes +//! automatically on 401, so a logged-in CLI stays logged in. +//! +//! Not signed in is a normal state, not an error — the CLI works fine against +//! your own provider keys — so lookups return `None` rather than failing. + +use anyhow::{bail, Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::time::Duration; + +use crate::directories::get_config_dir; + +/// Control-plane API. Override for local development. +pub const API_URL_ENV: &str = "OCTOMIND_API_URL"; +pub const DEFAULT_API_URL: &str = "https://api.octomind.run"; +/// Panel origin for browser hand-offs. Only needed when pointing at a panel the +/// API doesn't know about (e.g. a local dev server). +pub const PANEL_URL_ENV: &str = "OCTOMIND_PANEL_URL"; +/// Model-gateway credential, read by octolib's octohub provider. +pub const HUB_KEY_ENV: &str = "OCTOHUB_API_KEY"; + +const TIMEOUT: Duration = Duration::from_secs(20); + +pub fn api_url() -> String { + std::env::var(API_URL_ENV) + .unwrap_or_else(|_| DEFAULT_API_URL.to_string()) + .trim_end_matches('/') + .to_string() +} + +/// The stored panel session. Sits next to the config, mode 0600 — it is a live +/// credential, not configuration. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Session { + pub jwt: String, + pub refresh_token: String, + /// Which API this session belongs to, so pointing at a different host doesn't + /// silently reuse a session minted somewhere else. + #[serde(default)] + pub api_url: String, +} + +pub fn session_path() -> Result { + Ok(get_config_dir()?.join("auth.json")) +} + +/// A stable id for THIS machine, created once and kept next to the config. +/// +/// It names the key a login mints (`octomind-cli-`), so signing in here only +/// ever supersedes this machine's key and other machines keep working. Not a +/// secret and not a credential — it only has to be stable and unique-ish. It +/// deliberately does not live in `auth.json`: it must outlive signing out. +pub fn machine_id() -> Result { + let path = get_config_dir()?.join("machine-id"); + if let Ok(existing) = std::fs::read_to_string(&path) { + let id = existing.trim().to_string(); + if !id.is_empty() { + return Ok(id); + } + } + let id: String = uuid::Uuid::new_v4() + .simple() + .to_string() + .chars() + .take(12) + .collect(); + std::fs::write(&path, &id).with_context(|| format!("could not write {}", path.display()))?; + Ok(id) +} + +/// The current session, if a login left one for THIS api_url. +pub fn session() -> Option { + let raw = std::fs::read_to_string(session_path().ok()?).ok()?; + let s: Session = serde_json::from_str(&raw).ok()?; + if !s.api_url.is_empty() && s.api_url != api_url() { + return None; + } + (!s.jwt.is_empty()).then_some(s) +} + +pub fn save_session(s: &Session) -> Result { + let path = session_path()?; + std::fs::write(&path, serde_json::to_string_pretty(s)?) + .with_context(|| format!("could not write {}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; + } + Ok(path) +} + +fn client() -> Result { + Ok(reqwest::Client::builder().timeout(TIMEOUT).build()?) +} + +/// kisscore answers `[err, data]` on every status, so the error CODE is what we +/// surface — an HTTP number tells the user nothing they can act on. +async fn envelope Deserialize<'de>>( + res: reqwest::Response, +) -> Result> { + let parsed: (Option, Option) = res + .json() + .await + .context("unexpected response from the API (not an Octomind endpoint?)")?; + match parsed { + (Some(err), _) => Ok(Err(err)), + (None, Some(data)) => Ok(Ok(serde_json::from_value(data)?)), + (None, None) => bail!("empty response from the API"), + } +} + +/// Unauthenticated POST — the login flow, before any credential exists. +pub async fn post_public Deserialize<'de>>( + path: &str, + body: serde_json::Value, +) -> Result> { + let res = client()? + .post(format!("{}/api/v1{path}", api_url())) + .json(&body) + .send() + .await + .with_context(|| format!("could not reach {}", api_url()))?; + envelope(res).await +} + +#[derive(Deserialize)] +struct Refreshed { + jwt: String, +} + +/// Trade the refresh token for a fresh jwt and persist it. `false` = the refresh +/// token is dead too (idle past its window, or revoked): sign in again. +async fn refresh(s: &Session) -> Result { + let res: std::result::Result = post_public( + "/auth/refresh", + serde_json::json!({ "refresh_token": s.refresh_token }), + ) + .await?; + match res { + Ok(r) => { + save_session(&Session { + jwt: r.jwt, + refresh_token: s.refresh_token.clone(), + api_url: api_url(), + })?; + Ok(true) + } + Err(_) => Ok(false), + } +} + +/// Authenticated GET, refreshing once on 401 exactly as the panel does. `None` = +/// no usable session; the caller decides whether that is worth mentioning. +pub async fn get Deserialize<'de>>(path: &str) -> Result> { + let Some(s) = session() else { + return Ok(None); + }; + let url = format!("{}/api/v1{path}", api_url()); + let send = |jwt: String| async { + client()? + .get(&url) + .bearer_auth(jwt) + .send() + .await + .with_context(|| format!("could not reach {}", api_url())) + }; + + match envelope::(send(s.jwt.clone()).await?).await? { + Ok(v) => Ok(Some(v)), + Err(e) if e == "unauthorized" || e == "token_expired" => { + if !refresh(&s).await? { + return Ok(None); // signed out for real — `octomind login` again + } + let s = session().context("session vanished mid-refresh")?; + match envelope::(send(s.jwt).await?).await? { + Ok(v) => Ok(Some(v)), + Err(e) if e == "unauthorized" || e == "token_expired" => Ok(None), + Err(e) => bail!("{e}"), + } + } + Err(e) => bail!("{e}"), + } +} + +#[derive(Deserialize, Debug, Clone)] +pub struct Account { + pub email: String, + pub plan: String, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct Window { + pub spent_usd: f64, + pub cap_usd: f64, + pub resets_at: String, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct Network { + pub used_gb: f64, + pub included_gb: f64, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct Usage { + pub window_4h: Window, + pub week: Window, + pub month: Window, + pub balance_usd: f64, + pub storage_gb: f64, + pub storage_quota_gb: f64, + pub network: Network, +} + +/// Who the stored session belongs to. `None` = not signed in. +pub async fn whoami() -> Result> { + get::("/auth/me").await +} + +/// Account usage. `None` = not signed in; nothing to show and nothing wrong. +pub async fn usage() -> Result> { + get::("/account/usage").await +} diff --git a/src/acp/agent.rs b/src/acp/agent.rs index 5ad27fc9..f178e122 100644 --- a/src/acp/agent.rs +++ b/src/acp/agent.rs @@ -274,6 +274,7 @@ fn build_available_commands() -> Vec { )), AvailableCommand::new("agents", "Show running/offloaded agents in this session") .input(unstructured("[session]")), + AvailableCommand::new("usage", "Show spend and quotas for your Octomind account"), AvailableCommand::new("exit", "Exit the session"), ] } diff --git a/src/commands/config.rs b/src/commands/config.rs index a8630eb3..f51e6c95 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -427,6 +427,7 @@ pub fn execute(args: &ConfigArgs, mut config: Config) -> Result<(), anyhow::Erro render_env_api_key_row("Google", "GOOGLE_APPLICATION_CREDENTIALS"); render_env_api_key_row("Amazon", "AWS_ACCESS_KEY_ID"); render_env_api_key_row("Cloudflare", "CLOUDFLARE_API_TOKEN"); + render_octomind_cloud_row(); // ── mcp ─────────────────────────────────────────────────────────── let dev_mcp_enabled = config @@ -498,6 +499,26 @@ pub fn execute(args: &ConfigArgs, mut config: Config) -> Result<(), anyhow::Erro Ok(()) } +/// Octomind sits in the same list, but its key isn't something you export +/// — it arrives from `octomind login`, so the row says that instead. +fn render_octomind_cloud_row() { + let signed_in = octomind::account::session().is_some(); + let has_key = std::env::var(octomind::account::HUB_KEY_ENV).is_ok_and(|k| !k.trim().is_empty()); + let (mark, note) = match (signed_in, has_key) { + (true, _) => ("✓".bright_green(), "signed in".dimmed()), + // A key with no session was exported by hand (or predates login): models + // work, the account commands don't. + (false, true) => ("✓".bright_green(), "key set, not signed in".dimmed()), + (false, false) => ("✗".bright_red(), "not set (octomind login)".dimmed()), + }; + block_row_text(&format!( + "{} {} {}", + format!("{:<11}", "Octomind").bright_white(), + mark, + note, + )); +} + /// Render an env-var API key row under the current /config block. fn render_env_api_key_row(provider: &str, env_var: &str) { match std::env::var(env_var) { @@ -649,6 +670,7 @@ fn show_configuration(config: &Config) -> Result<(), anyhow::Error> { render_env_api_key_row("Google", "GOOGLE_APPLICATION_CREDENTIALS"); render_env_api_key_row("Amazon", "AWS_ACCESS_KEY_ID"); render_env_api_key_row("Cloudflare", "CLOUDFLARE_API_TOKEN"); + render_octomind_cloud_row(); // ── roles ───────────────────────────────────────────────────────── let (_dev_config, dev_mcp, _dev_layers, _dev_commands, dev_system) = diff --git a/src/commands/login.rs b/src/commands/login.rs new file mode 100644 index 00000000..c936b7ec --- /dev/null +++ b/src/commands/login.rs @@ -0,0 +1,261 @@ +// Copyright 2026 Muvon Un Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! `octomind login` — sign in to your Octomind account from the terminal. +//! +//! Device-authorization flow (the shape of `gh auth login`, RFC 8628): the CLI +//! asks the API to start a login, shows a short code, and the user confirms that +//! code in the browser where they are already signed in. Nothing here handles a +//! password. +//! +//! It collects a hub key (written to the user-scope `.env` as `OCTOHUB_API_KEY`, +//! which every octomind process loads at startup) and a panel session (stored by +//! [`crate::account`], refreshed automatically from then on). + +use anyhow::{bail, Context, Result}; +use clap::Args; +use colored::Colorize; +use serde::Deserialize; +use std::time::Duration; + +use octomind::account; +use octomind::directories::get_config_dir; +use octomind::session::chat::{block_close_ok, block_line, block_open, block_row, key_width}; + +/// Stop polling well after the server's own TTL rather than hanging forever. +const MAX_POLL: Duration = Duration::from_secs(15 * 60); + +#[derive(Args, Debug)] +pub struct LoginArgs { + /// Sign in again even if this machine already has a session. + #[arg(long)] + pub force: bool, + + /// Print the URL instead of opening a browser. + #[arg(long)] + pub no_browser: bool, +} + +#[derive(Deserialize)] +struct Start { + device_code: String, + user_code: String, + verification_url: String, + verification_url_complete: String, + interval: u64, +} + +#[derive(Deserialize)] +struct Claim { + api_key: String, + jwt: String, + refresh_token: String, + /// What the key ended up called in the Keys page. + #[serde(default = "default_key_name")] + key_name: String, +} + +fn default_key_name() -> String { + "octomind-cli".to_string() +} + +/// Swap the origin of a server-supplied verification URL for the local panel. +fn repoint(url: &str, panel: &str) -> String { + match url.find("/app") { + Some(i) => format!("{}{}", panel.trim_end_matches('/'), &url[i..]), + None => url.to_string(), + } +} + +pub async fn execute(args: &LoginArgs) -> Result<()> { + // Already signed in is worth saying out loud rather than silently minting a + // second set of credentials and killing the ones that were working. + if !args.force { + if let Some(account) = account::whoami().await? { + block_open("login", Some("octomind account")); + let kw = key_width(["account", "plan"]); + block_row("account", &account.email.bright_green().to_string(), kw); + block_row("plan", &account.plan, kw); + block_close_ok("login", Some("already signed in")); + println!(); + println!("Use `octomind login --force` to sign in as someone else."); + return Ok(()); + } + } + + // The device id scopes the key this login mints, so signing in here supersedes + // only this machine's key and every other machine keeps working. + let device_id = account::machine_id()?; + let start: Start = account::post_public( + "/auth/cli", + serde_json::json!({ + "client": format!("octomind/{} {}", env!("CARGO_PKG_VERSION"), std::env::consts::OS), + "device_id": device_id, + }), + ) + .await? + .map_err(|e| anyhow::anyhow!("could not start login: {e}"))?; + + let panel = std::env::var(account::PANEL_URL_ENV).ok(); + let repoint_for = |url: &str| match &panel { + Some(p) => repoint(url, p), + None => url.to_string(), + }; + let confirm_url = repoint_for(&start.verification_url_complete); + + block_open("login", Some("octomind account")); + let kw = key_width(["code", "url"]); + block_row( + "code", + &start.user_code.bright_yellow().bold().to_string(), + kw, + ); + block_row( + "url", + &repoint_for(&start.verification_url) + .bright_cyan() + .to_string(), + kw, + ); + block_line(""); + block_line("Confirm the code in your browser to finish signing in."); + + if args.no_browser { + block_line(&format!("Open: {confirm_url}")); + } else if open::that(&confirm_url).is_err() { + // Headless box, no xdg-open, SSH session — the URL is still actionable. + block_line(&format!("Could not open a browser. Open: {confirm_url}")); + } + block_line("waiting…"); + + let interval = Duration::from_secs(start.interval.clamp(1, 30)); + let deadline = std::time::Instant::now() + MAX_POLL; + let claim = loop { + if std::time::Instant::now() >= deadline { + bail!("login timed out — run `octomind login` again"); + } + tokio::time::sleep(interval).await; + match account::post_public::( + "/auth/cli/token", + serde_json::json!({ "device_code": start.device_code }), + ) + .await? + { + Ok(c) => break c, + Err(e) if e == "pending" => continue, + Err(e) if e == "expired" => bail!("that code expired — run `octomind login` again"), + Err(e) => bail!("login failed: {e}"), + } + }; + + let env_path = write_hub_key(&claim.api_key)?; + account::save_session(&account::Session { + jwt: claim.jwt, + refresh_token: claim.refresh_token, + api_url: account::api_url(), + })?; + // Same process, so anything later in this run picks it up without a restart. + std::env::set_var(account::HUB_KEY_ENV, &claim.api_key); + + let who = account::whoami().await.ok().flatten(); + let kw = key_width(["account", "key", "stored"]); + if let Some(a) = &who { + block_row("account", &a.email.bright_green().to_string(), kw); + } + // The server names the key; echo ITS answer so the row always matches what the + // Keys page shows, including for older servers that ignore the device id. + block_row("key", &claim.key_name, kw); + block_row("stored", &env_path.display().to_string(), kw); + block_close_ok("login", Some("signed in")); + println!(); + Ok(()) +} + +/// Upsert `OCTOHUB_API_KEY` in the user-scope `.env` — the one every octomind +/// process loads at startup. Other variables in that file are left alone; a new +/// login replaces the previous key rather than appending a second line. +fn write_hub_key(key: &str) -> Result { + let path = get_config_dir()?.join(".env"); + let existing = std::fs::read_to_string(&path).unwrap_or_default(); + std::fs::write(&path, upsert(&existing, key)) + .with_context(|| format!("could not write {}", path.display()))?; + + // It holds a live credential: keep it owner-only. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; + } + Ok(path) +} + +/// Replace any existing `OCTOHUB_API_KEY` line, keep every other line as-is. +fn upsert(existing: &str, key: &str) -> String { + let prefix = format!("{}=", account::HUB_KEY_ENV); + let mut out: Vec<&str> = existing + .lines() + .filter(|l| { + !l.trim_start() + .trim_start_matches("export ") + .starts_with(&prefix) + }) + .collect(); + let line = format!("{prefix}{key}"); + out.push(&line); + out.join("\n") + "\n" +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn upsert_replaces_the_key_and_keeps_the_rest() { + let before = "OPENROUTER_API_KEY=abc\nOCTOHUB_API_KEY=old\nFOO=bar\n"; + let after = upsert(before, "new"); + assert_eq!( + after, + "OPENROUTER_API_KEY=abc\nFOO=bar\nOCTOHUB_API_KEY=new\n" + ); + // Empty file, exported form, and repeated logins all converge on one line. + assert_eq!(upsert("", "k"), "OCTOHUB_API_KEY=k\n"); + assert_eq!( + upsert("export OCTOHUB_API_KEY=old", "k"), + "OCTOHUB_API_KEY=k\n" + ); + assert_eq!( + upsert(&after, "third"), + "OPENROUTER_API_KEY=abc\nFOO=bar\nOCTOHUB_API_KEY=third\n" + ); + } + + #[test] + fn repoint_swaps_origin_keeps_path_and_query() { + assert_eq!( + repoint( + "https://octomind.run/app/login/cli?code=AB12-CD34", + "http://localhost:5199" + ), + "http://localhost:5199/app/login/cli?code=AB12-CD34" + ); + } + + #[test] + fn repoint_leaves_unrecognized_urls_alone() { + assert_eq!( + repoint("https://example.com/x", "http://localhost:1"), + "https://example.com/x" + ); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 611eb47b..7c0421a5 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -16,6 +16,7 @@ pub mod acp; pub mod common; pub mod complete; pub mod config; +pub mod login; pub mod run; pub mod send; pub mod server; @@ -27,6 +28,7 @@ pub mod workflow; pub use acp::AcpArgs; pub use complete::CompleteArgs; pub use config::ConfigArgs; +pub use login::LoginArgs; pub use run::RunArgs; pub use send::SendArgs; pub use server::ServerArgs; diff --git a/src/config/env_source.rs b/src/config/env_source.rs index 4504806a..a80d52ab 100644 --- a/src/config/env_source.rs +++ b/src/config/env_source.rs @@ -22,6 +22,8 @@ use std::collections::HashMap; use std::env; use std::path::Path; +use crate::directories::get_config_dir; + /// Represents the source of an environment variable #[derive(Debug, Clone, PartialEq)] pub enum EnvSource { @@ -51,8 +53,22 @@ impl EnvTracker { } } - /// Load .env file with override and track that it was loaded + /// Load .env files with override, in precedence order (later wins): + /// 1. user-scope `/.env` (shared across projects) + /// 2. cwd `.env` (project-local, overrides user-scope) + /// System environment variables are the base; both .env files override them. pub fn load_dotenv_override(&mut self) -> Result<(), dotenvy::Error> { + // 1. User-scope .env from the shared config directory. + if let Ok(config_dir) = get_config_dir() { + let user_env = config_dir.join(".env"); + if user_env.exists() { + dotenvy::from_filename_override(&user_env)?; + self.dotenv_loaded = true; + crate::log_debug!("Loaded user-scope .env: {}", user_env.display()); + } + } + + // 2. Project-local .env from the current working directory (overrides user-scope). if Path::new(".env").exists() { dotenvy::from_filename_override(".env")?; self.dotenv_loaded = true; diff --git a/src/lib.rs b/src/lib.rs index baa67a24..f5b6fd22 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -86,6 +86,7 @@ macro_rules! eprint { // ============================================================================ // Main lib.rs file that exports our modules +pub mod account; pub mod acp; pub mod agent; pub mod branding; diff --git a/src/main.rs b/src/main.rs index 3de9bf3b..a41e7542 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,6 +42,10 @@ enum Commands { /// Use --format to run non-interactively. Run(commands::RunArgs), + /// Sign in to your Octomind account — confirm a code in the browser and the CLI + /// stores the hub key it mints. + Login(commands::LoginArgs), + /// Start WebSocket server for remote AI sessions Server(commands::ServerArgs), @@ -162,6 +166,7 @@ async fn run_with_cleanup(args: CliArgs, config: Config) -> Result<(), anyhow::E match args.command { Commands::Config(config_args) => commands::config::execute(&config_args, config)?, Commands::Run(run_args) => commands::run::execute(&run_args, &config).await?, + Commands::Login(login_args) => commands::login::execute(&login_args).await?, Commands::Server(server_args) => commands::server::execute(&server_args, &config).await?, Commands::Acp(acp_args) => commands::acp::execute(&acp_args, &config).await?, Commands::Tap(tap_args) => commands::tap::execute(&tap_args)?, diff --git a/src/session/chat/commands.rs b/src/session/chat/commands.rs index 4f11dcf3..9ecce815 100644 --- a/src/session/chat/commands.rs +++ b/src/session/chat/commands.rs @@ -43,8 +43,9 @@ pub const LEARNING_COMMAND: &str = "/learning"; pub const SHARE_COMMAND: &str = "/share"; pub const ANALYZE_COMMAND: &str = "/analyze"; pub const AGENTS_COMMAND: &str = "/agents"; +pub const USAGE_COMMAND: &str = "/usage"; // List of all available commands for autocomplete -pub const COMMANDS: [&str; 28] = [ +pub const COMMANDS: [&str; 29] = [ HELP_COMMAND, HELP_COMMAND_ALT, EXIT_COMMAND, @@ -73,4 +74,5 @@ pub const COMMANDS: [&str; 28] = [ SHARE_COMMAND, ANALYZE_COMMAND, AGENTS_COMMAND, + USAGE_COMMAND, ]; diff --git a/src/session/chat/session/commands/display.rs b/src/session/chat/session/commands/display.rs index 698cdd74..62e424e0 100644 --- a/src/session/chat/session/commands/display.rs +++ b/src/session/chat/session/commands/display.rs @@ -2402,6 +2402,111 @@ pub fn display_learning(output: &CommandOutput) { } } +/// Width of the usage bars. Short enough to sit inside a narrow terminal. +const BAR_W: usize = 24; + +/// Draw a proportion, coloured by how close it is to the limit — the point of +/// `/usage` is seeing "am I about to be cut off" without reading the numbers. +fn bar(used: f64, limit: f64, render: impl Fn(f64, f64) -> String) -> String { + if limit <= 0.0 { + return "unlimited".dimmed().to_string(); + } + let frac = (used / limit).clamp(0.0, 1.0); + let filled = (frac * BAR_W as f64).round() as usize; + let drawn = "█".repeat(filled); + let coloured = if frac >= 0.9 { + drawn.bright_red() + } else if frac >= 0.7 { + drawn.bright_yellow() + } else { + drawn.bright_green() + }; + format!( + "{}{} {:>5.1}% {}", + coloured, + "░".repeat(BAR_W - filled).dimmed(), + frac * 100.0, + render(used, limit) + ) +} + +fn money_bar(used: f64, limit: f64) -> String { + bar(used, limit, |u, l| format!("${u:.2} / ${l:.2}")) +} + +fn gb_bar(used: f64, limit: f64) -> String { + if limit <= 0.0 { + return format!("{used:.2} GB").dimmed().to_string(); + } + bar(used, limit, |u, l| format!("{u:.2} / {l:.0} GB")) +} + +pub fn display_usage(output: &CommandOutput) { + let CommandOutput::Usage { + signed_in, + account, + windows, + balance_usd, + storage_gb, + storage_quota_gb, + network_used_gb, + network_included_gb, + } = output + else { + return; + }; + + block_open("/usage", None); + if !signed_in { + block_line(&"Not signed in to Octomind.".yellow().to_string()); + block_line( + &"Run `octomind login` to see your allowances." + .dimmed() + .to_string(), + ); + block_close_ok("/usage", Some("signed out")); + println!(); + return; + } + + if let Some(a) = account { + block_row( + "account", + &a.bright_green().to_string(), + key_width(["account"]), + ); + } + + block_section("spend"); + let kw = key_width(windows.iter().map(|w| w.label.as_str())); + for w in windows { + block_row(&w.label, &money_bar(w.spent_usd, w.cap_usd), kw); + } + + block_section("resources"); + let kw = key_width(["balance", "storage", "network"]); + block_row( + "balance", + &format!("${balance_usd:.2}").bright_cyan().to_string(), + kw, + ); + block_row("storage", &gb_bar(*storage_gb, *storage_quota_gb), kw); + block_row( + "network", + &gb_bar(*network_used_gb, *network_included_gb), + kw, + ); + + // Summarise with the tightest window — that's the one that will bite first. + let peak = windows + .iter() + .filter(|w| w.cap_usd > 0.0) + .map(|w| w.spent_usd / w.cap_usd) + .fold(0.0_f64, f64::max); + block_close_ok("/usage", Some(&format!("{:.0}% of cap", peak * 100.0))); + println!(); +} + pub fn display_share(output: &CommandOutput) { if let CommandOutput::Share { id, url } = output { block_open("/share", None); diff --git a/src/session/chat/session/commands/help.rs b/src/session/chat/session/commands/help.rs index 4ca028b8..2bd6d1be 100644 --- a/src/session/chat/session/commands/help.rs +++ b/src/session/chat/session/commands/help.rs @@ -47,6 +47,7 @@ pub async fn handle_help(config: &Config, role: &str) -> Result { commands.push(SCHEDULE_COMMAND.to_string()); commands.push(AGENTS_COMMAND.to_string()); commands.push(REPORT_COMMAND.to_string()); + commands.push(USAGE_COMMAND.to_string()); commands.push(format!("{} | {}", EXIT_COMMAND, QUIT_COMMAND)); // Add custom commands from config diff --git a/src/session/chat/session/commands/mod.rs b/src/session/chat/session/commands/mod.rs index 7701c54f..ac4aec77 100644 --- a/src/session/chat/session/commands/mod.rs +++ b/src/session/chat/session/commands/mod.rs @@ -41,6 +41,8 @@ mod schedule; mod session; mod share; mod skill; +mod usage; +pub use usage::UsageWindow; mod utils; mod video; @@ -198,6 +200,18 @@ pub enum CommandOutput { id: String, url: String, }, + /// Octomind account spend + quotas. `signed_in: false` is the normal not-logged-in + /// state, with every number left at zero — there is nothing to report. + Usage { + signed_in: bool, + account: Option, + windows: Vec, + balance_usd: f64, + storage_gb: f64, + storage_quota_gb: f64, + network_used_gb: f64, + network_included_gb: f64, + }, Analyze { url: String, port: u16, @@ -287,6 +301,7 @@ impl CommandOutput { Self::Schedule { .. } => display::display_schedule(self), Self::Learning { .. } => display::display_learning(self), Self::Share { .. } => display::display_share(self), + Self::Usage { .. } => display::display_usage(self), Self::Analyze { .. } => display::display_analyze(self), Self::Agents { .. } => display::display_agents(self), Self::Error { error, .. } => { @@ -376,6 +391,7 @@ pub async fn process_command( SHARE_COMMAND => share::handle_share(session).await, ANALYZE_COMMAND => analyze::handle_analyze(session).await, AGENTS_COMMAND => agents::handle_agents(params), + USAGE_COMMAND => usage::handle_usage().await, _ => { // Unknown command - treat as user input instead of showing error Ok(CommandResult::TreatAsUserInput) diff --git a/src/session/chat/session/commands/usage.rs b/src/session/chat/session/commands/usage.rs new file mode 100644 index 00000000..b290b2a3 --- /dev/null +++ b/src/session/chat/session/commands/usage.rs @@ -0,0 +1,95 @@ +// Copyright 2026 Muvon Un Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! /usage — spend and quotas for the signed-in Octomind account. +//! +//! This is the ACCOUNT, not this session: `/info` covers the session's own tokens +//! and cost. The account spans the hub and cloud machines, which is why one set of +//! caps covers both. Not signed in is a normal state — the CLI works fine against +//! your own provider keys — so it says so and stops, rather than erroring. + +use super::{CommandOutput, CommandResult}; +use crate::account; +use anyhow::Result; + +pub async fn handle_usage() -> Result { + let usage = match account::usage().await { + Ok(Some(u)) => u, + Ok(None) => { + return Ok(CommandResult::HandledWithOutput(Box::new( + CommandOutput::Usage { + signed_in: false, + account: None, + windows: vec![], + balance_usd: 0.0, + storage_gb: 0.0, + storage_quota_gb: 0.0, + network_used_gb: 0.0, + network_included_gb: 0.0, + }, + ))); + } + Err(e) => { + return Ok(CommandResult::HandledWithOutput(Box::new( + CommandOutput::Error { + error: format!("Could not read account usage: {e}"), + context: Some(serde_json::json!({ + "hint": "Run `octomind login` to sign in, or set OCTOMIND_API_URL if you're testing against a local API." + })), + }, + ))); + } + }; + + // Best-effort: the numbers are the point, the email is a nicety. + let account = account::whoami() + .await + .ok() + .flatten() + .map(|a| format!("{} ({})", a.email, a.plan)); + + Ok(CommandResult::HandledWithOutput(Box::new( + CommandOutput::Usage { + signed_in: true, + account, + windows: vec![ + window("4 hours", &usage.window_4h), + window("week", &usage.week), + window("month", &usage.month), + ], + balance_usd: usage.balance_usd, + storage_gb: usage.storage_gb, + storage_quota_gb: usage.storage_quota_gb, + network_used_gb: usage.network.used_gb, + network_included_gb: usage.network.included_gb, + }, + ))) +} + +fn window(label: &str, w: &account::Window) -> UsageWindow { + UsageWindow { + label: label.to_string(), + spent_usd: w.spent_usd, + cap_usd: w.cap_usd, + resets_at: w.resets_at.clone(), + } +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct UsageWindow { + pub label: String, + pub spent_usd: f64, + pub cap_usd: f64, + pub resets_at: String, +}