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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion doc/reference/04-environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,16 +141,24 @@ 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
OPENROUTER_API_KEY=sk-or-v1-...
ANTHROPIC_API_KEY=sk-ant-...
```

Two `.env` locations are loaded, in precedence order (later wins):

1. **User-scope** — `<config_dir>/.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.
9 changes: 8 additions & 1 deletion doc/usage/04-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<config_dir>/.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:
Expand Down Expand Up @@ -154,7 +162,6 @@ The following providers also work via the same `<PROVIDER>_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` |
Expand Down
247 changes: 247 additions & 0 deletions src/account.rs
Original file line number Diff line number Diff line change
@@ -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<PathBuf> {
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-<id>`), 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<String> {
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<Session> {
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<PathBuf> {
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<reqwest::Client> {
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<T: for<'de> Deserialize<'de>>(
res: reqwest::Response,
) -> Result<std::result::Result<T, String>> {
let parsed: (Option<String>, Option<serde_json::Value>) = 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<T: for<'de> Deserialize<'de>>(
path: &str,
body: serde_json::Value,
) -> Result<std::result::Result<T, String>> {
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<bool> {
let res: std::result::Result<Refreshed, String> = 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<T: for<'de> Deserialize<'de>>(path: &str) -> Result<Option<T>> {
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::<T>(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::<T>(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<Option<Account>> {
get::<Account>("/auth/me").await
}

/// Account usage. `None` = not signed in; nothing to show and nothing wrong.
pub async fn usage() -> Result<Option<Usage>> {
get::<Usage>("/account/usage").await
}
1 change: 1 addition & 0 deletions src/acp/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ fn build_available_commands() -> Vec<AvailableCommand> {
)),
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"),
]
}
Expand Down
22 changes: 22 additions & 0 deletions src/commands/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) =
Expand Down
Loading
Loading