Skip to content
Open
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
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ OpenQuota checks for updates automatically. Installable updates are cryptographi

- **[Claude Code](docs/providers/claude.md)** — multiple accounts, session and weekly limits,
model-specific usage, token history, and estimated spend
- **[Command Code](docs/providers/commandcode.md)** — session and weekly credit limits, monthly
subscription credits, and extra-credit balance
- **[Codex](docs/providers/codex.md)** — session and weekly limits, credits, token history, model
breakdown, and estimated spend
- **[Cursor](docs/providers/cursor.md)** — total, Auto and API usage, credits, token history, and
Expand All @@ -61,10 +63,11 @@ OpenQuota checks for updates automatically. Installable updates are cryptographi
- **[Kimi](docs/providers/kimi.md)** — Kimi Code session and weekly quotas (API key)
- **[MiniMax](docs/providers/minimax.md)** — Token Plan session and weekly quotas (API key)

Most providers use credentials already available on your computer. OpenRouter, Z.ai, Kimi, and
MiniMax require API keys, which you can add in Customize; OpenQuota stores them securely in your
operating system's credential store. Codex subscription limits require a ChatGPT login and are not
available in API-key-only sessions.
Most providers use credentials already available on your computer. Command Code reads the local
session created by `command-code login`. OpenRouter, Z.ai, Kimi, and MiniMax require API keys,
which you can add in Customize; OpenQuota stores them securely in your operating system's credential
store. Codex subscription limits require a ChatGPT login and are not available in API-key-only
sessions.

## Features

Expand Down
30 changes: 30 additions & 0 deletions docs/providers/commandcode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Command Code

OpenQuota reads the local session created by the Command Code CLI and tracks the subscription
windows reported by Command Code.

## What it tracks

| Metric | Meaning |
| --------------- | ---------------------------------------------------------- |
| Session | Credit usage in the rolling 5-hour subscription window |
| Weekly | Credit usage in the rolling 7-day subscription window |
| Monthly Credits | Remaining credits from the current subscription allocation |
| Extra Credits | Remaining purchased or top-up credits, when available |

## Setup

Install the Command Code CLI and sign in:

```sh
command-code login
```

OpenQuota reads `~/.commandcode/auth.json` locally. The session key remains on your device and is
used only to request Command Code usage data.

## Troubleshooting

- **Not logged in** — run `command-code login`, then refresh OpenQuota.
- **Login expired** — sign in again with `command-code login`.
- **Usage unavailable** — check the connection and refresh again.
3 changes: 2 additions & 1 deletion scripts/verify/verify-provider-registry-contract.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const frontendConsumers = [
'src/lib/shareCard.ts',
];
const providerLiteral =
/["'](?:claude|codex|cursor|antigravity|copilot|devin|grok|opencode|openrouter|zai|kimi|minimax)["']/;
/["'](?:claude|commandcode|codex|cursor|antigravity|copilot|devin|grok|opencode|openrouter|zai|kimi|minimax)["']/;

for (const file of rustConsumers) {
const source = fs.readFileSync(new URL(file, root), 'utf8').split('#[cfg(test)]')[0];
Expand Down Expand Up @@ -82,6 +82,7 @@ const runtimeOrder = [
const expectedRuntimeOrder = [
'ClaudeProvider',
'CodexProvider',
'CommandCodeProvider',
'CursorProvider',
'AntigravityProvider',
'CopilotProvider',
Expand Down
9 changes: 5 additions & 4 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,10 @@ use crate::{
pricing::PricingStore,
providers::{
antigravity::AntigravityProvider, claude, codex::reset_claim::CodexResetClaimService,
codex::CodexProvider, copilot::CopilotProvider, cursor::CursorProvider,
detect_local_credentials, devin::DevinProvider, grok::GrokProvider, kimi::KimiProvider,
minimax::MiniMaxProvider, opencode::OpenCodeProvider, openrouter::OpenRouterProvider,
zai::ZaiProvider, ProviderRegistry, UsageProvider,
codex::CodexProvider, commandcode::CommandCodeProvider, copilot::CopilotProvider,
cursor::CursorProvider, detect_local_credentials, devin::DevinProvider, grok::GrokProvider,
kimi::KimiProvider, minimax::MiniMaxProvider, opencode::OpenCodeProvider,
openrouter::OpenRouterProvider, zai::ZaiProvider, ProviderRegistry, UsageProvider,
},
storage::Storage,
window::{
Expand Down Expand Up @@ -230,6 +230,7 @@ pub fn run() {
providers.extend(vec![
Arc::new(CodexProvider::new(storage.clone(), pricing.clone())?)
as Arc<dyn UsageProvider>,
Arc::new(CommandCodeProvider::new()?) as Arc<dyn UsageProvider>,
Arc::new(CursorProvider::new(pricing.clone())?) as Arc<dyn UsageProvider>,
Arc::new(AntigravityProvider::new(
app_data_dir.join("antigravity").join("auth.json"),
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/menu_bar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const STACKED_BASELINES: [f32; 2] = [15.0, 32.0];
const FONT_SOURCE: &[u8] = include_bytes!("../assets/fonts/Poppins-SemiBold.ttf");

const CLAUDE_ICON: &str = include_str!("../../src/assets/provider-icons/claude.svg");
const COMMANDCODE_ICON: &str = include_str!("../../src/assets/provider-icons/commandcode.svg");
const CODEX_ICON: &str = include_str!("../../src/assets/provider-icons/codex.svg");
const COPILOT_ICON: &str = include_str!("../../src/assets/provider-icons/copilot.svg");
const CURSOR_ICON: &str = include_str!("../../src/assets/provider-icons/cursor.svg");
Expand Down Expand Up @@ -270,6 +271,7 @@ fn provider_path(provider_id: &str) -> Option<&'static Path> {
}

static CLAUDE: OnceLock<Path> = OnceLock::new();
static COMMANDCODE: OnceLock<Path> = OnceLock::new();
static CODEX: OnceLock<Path> = OnceLock::new();
static COPILOT: OnceLock<Path> = OnceLock::new();
static CURSOR: OnceLock<Path> = OnceLock::new();
Expand All @@ -283,6 +285,7 @@ fn provider_path(provider_id: &str) -> Option<&'static Path> {
static MINIMAX: OnceLock<Path> = OnceLock::new();
match crate::providers::provider_family(provider_id) {
"claude" => Some(parsed(CLAUDE_ICON, &CLAUDE)),
"commandcode" => Some(parsed(COMMANDCODE_ICON, &COMMANDCODE)),
"codex" => Some(parsed(CODEX_ICON, &CODEX)),
"copilot" => Some(parsed(COPILOT_ICON, &COPILOT)),
"cursor" => Some(parsed(CURSOR_ICON, &CURSOR)),
Expand Down Expand Up @@ -588,6 +591,7 @@ mod tests {
fn bundled_provider_marks_and_font_render_into_a_retina_text_strip() {
for provider in [
"claude",
"commandcode",
"codex",
"copilot",
"cursor",
Expand Down
51 changes: 51 additions & 0 deletions src-tauri/src/providers/commandcode/auth.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
use std::{env, fs, path::PathBuf};

use serde::Deserialize;

use super::CommandCodeError;

#[derive(Debug, Deserialize)]
struct AuthDocument {
#[serde(rename = "apiKey")]
api_key: Option<String>,
}

#[derive(Clone)]
pub struct CommandCodeAuthStore {
path: PathBuf,
}

impl CommandCodeAuthStore {
pub fn new() -> Self {
Self {
path: home_directory().join(".commandcode").join("auth.json"),
}
}

pub fn load(&self) -> Result<String, CommandCodeError> {
let text = fs::read_to_string(&self.path).map_err(|_| CommandCodeError::NotLoggedIn)?;
let document: AuthDocument =
serde_json::from_str(&text).map_err(|_| CommandCodeError::InvalidAuth)?;
document
.api_key
.map(|key| key.trim().to_owned())
.filter(|key| !key.is_empty())
.ok_or(CommandCodeError::InvalidAuth)
}

pub fn has_local_credentials(&self) -> bool {
self.load().is_ok()
}
}

impl Default for CommandCodeAuthStore {
fn default() -> Self {
Self::new()
}
}

fn home_directory() -> PathBuf {
env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
}
86 changes: 86 additions & 0 deletions src-tauri/src/providers/commandcode/client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
use std::time::Duration;

use reqwest::{blocking::Client, StatusCode};
use serde_json::Value;

use super::CommandCodeError;

const CREDITS_URL: &str = "https://api.commandcode.ai/alpha/billing/credits";
const SUBSCRIPTION_URL: &str = "https://api.commandcode.ai/alpha/billing/subscriptions";

#[derive(Debug)]
pub struct EndpointResponse {
pub status: StatusCode,
pub body: Value,
}

pub struct CommandCodeClient {
client: Client,
credits_url: String,
subscription_url: String,
}

impl CommandCodeClient {
pub fn new() -> Result<Self, CommandCodeError> {
Self::with_endpoints(CREDITS_URL, SUBSCRIPTION_URL, Duration::from_secs(15))
}

fn with_endpoints(
credits_url: &str,
subscription_url: &str,
timeout: Duration,
) -> Result<Self, CommandCodeError> {
Ok(Self {
client: Client::builder()
.connect_timeout(Duration::from_secs(8))
.timeout(timeout)
.user_agent(concat!("OpenQuota/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|_| CommandCodeError::ConnectionFailed)?,
credits_url: credits_url.into(),
subscription_url: subscription_url.into(),
})
}

pub fn fetch(
&self,
api_key: &str,
) -> Result<(EndpointResponse, EndpointResponse), CommandCodeError> {
Ok((
self.fetch_endpoint(&self.credits_url, api_key, "credits")?,
self.fetch_endpoint(&self.subscription_url, api_key, "subscriptions")?,
))
}

fn fetch_endpoint(
&self,
url: &str,
api_key: &str,
endpoint: &str,
) -> Result<EndpointResponse, CommandCodeError> {
let started = std::time::Instant::now();
let response = self
.client
.get(url)
.bearer_auth(api_key)
.header("Accept", "application/json")
.send()
.map_err(|_| {
crate::app_warn!("http", "command-code {endpoint} request failed (transport)");
CommandCodeError::ConnectionFailed
})?;
let status = response.status();
crate::app_debug!(
"http",
"command-code {endpoint} HTTP {} ({}ms)",
status.as_u16(),
started.elapsed().as_millis()
);
let body = response
.text()
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or(Value::Null);
Ok(EndpointResponse { status, body })
}
}
Loading