From ef84257ddd159d1b0baa7e6897c856be47f0d2a7 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Sat, 9 May 2026 00:54:50 +0100 Subject: [PATCH] Detect plan tier from .claude.json; seed sane caps per tier (Pro/Max 5x/Max 20x) --- src-tauri/src/commands.rs | 22 ++++- src-tauri/src/lib.rs | 2 + src-tauri/src/settings.rs | 6 +- src-tauri/src/tier.rs | 145 +++++++++++++++++++++++++++++++++ src/components/Settings.svelte | 55 ++++++++++++- src/ipc.ts | 3 +- src/types.ts | 10 +++ 7 files changed, 236 insertions(+), 7 deletions(-) create mode 100644 src-tauri/src/tier.rs diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 8209e48..f8f2afc 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,8 +1,9 @@ //! Tauri command surface — every JS-callable function lives here. use crate::paths::{list_wsl_distros, resolve_roots, ResolvedRoots}; -use crate::settings::{save as save_settings, Settings}; +use crate::settings::{save as save_settings, Caps, Settings}; use crate::state::SharedState; +use crate::tier::{detect as detect_tier, PlanTier}; use crate::usage::{build_snapshot, UsageSnapshot}; use crate::watch::refresh_and_emit; @@ -70,3 +71,22 @@ pub async fn quit_app(app: tauri::AppHandle) -> Result<(), String> { app.exit(0); Ok(()) } + +#[derive(serde::Serialize)] +pub struct TierInfo { + /// The classified tier — `Pro`, `Max5x`, `Max20x`, etc. + pub tier: PlanTier, + /// Human-readable label for the UI ("Max 5×" / "Pro" / "Unknown"). + pub label: String, + /// The caps that this tier maps to. The user's current Settings may + /// differ if they tuned manually. + pub recommended_caps: Caps, +} + +#[tauri::command] +pub async fn detect_plan_tier() -> Result { + let tier = detect_tier(); + let label = tier.label(); + let recommended_caps = tier.caps(); + Ok(TierInfo { tier, label, recommended_caps }) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 36cc7f4..8877e84 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ mod jsonl; mod paths; mod settings; mod state; +mod tier; mod usage; mod watch; @@ -66,6 +67,7 @@ pub fn run() { commands::get_roots, commands::force_rescan, commands::quit_app, + commands::detect_plan_tier, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index fd80c92..c6917c5 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -63,7 +63,11 @@ pub fn config_path() -> PathBuf { pub fn load() -> Settings { let path = config_path(); let Ok(bytes) = std::fs::read(&path) else { - return Settings::default(); + // First run — seed caps from detected plan tier so the ring + // doesn't peg red against the static placeholders. + let mut s = Settings::default(); + s.caps = crate::tier::detect().caps(); + return s; }; match serde_json::from_slice::(&bytes) { Ok(s) => s, diff --git a/src-tauri/src/tier.rs b/src-tauri/src/tier.rs new file mode 100644 index 0000000..eb2e9df --- /dev/null +++ b/src-tauri/src/tier.rs @@ -0,0 +1,145 @@ +//! Read the user's plan tier from `~/.claude/` and map it to approximate +//! cap values. +//! +//! These caps are *approximations* based on community-shared observations — +//! Anthropic does not publish the exact 5-hour / weekly token caps, and they +//! shift over time. The widget shows a percentage off these caps, but +//! anything within ±20% of "the real number" is fine for a desktop heads-up +//! display. Users tune them in Settings once they hit a real limit. + +use crate::settings::Caps; +use serde::Serialize; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub enum PlanTier { + Pro, + Max5x, + Max20x, + /// Tier string we recognized as Max but couldn't sub-classify. + MaxOther(String), + /// The on-disk string was something we don't have a mapping for. + Unknown(String), + /// No `.claude.json` (or backup) found, or the field was missing. + NotFound, +} + +impl PlanTier { + pub fn caps(&self) -> Caps { + match self { + // Rough numbers, deliberately on the generous side so the ring + // doesn't peg red on day one. Tune in Settings when reality hits. + PlanTier::Max20x => Caps { block_tokens: 200_000_000, weekly_tokens: 1_000_000_000 }, + PlanTier::Max5x | PlanTier::MaxOther(_) => Caps { + block_tokens: 50_000_000, + weekly_tokens: 250_000_000, + }, + PlanTier::Pro => Caps { block_tokens: 10_000_000, weekly_tokens: 50_000_000 }, + // Fall back to the safe (and visibly wrong) placeholders so the + // user knows they need to tune. Better than guessing. + PlanTier::Unknown(_) | PlanTier::NotFound => Caps::default(), + } + } + + pub fn label(&self) -> String { + match self { + PlanTier::Pro => "Pro".to_string(), + PlanTier::Max5x => "Max 5×".to_string(), + PlanTier::Max20x => "Max 20×".to_string(), + PlanTier::MaxOther(s) => format!("Max ({s})"), + PlanTier::Unknown(s) => format!("Unknown ({s})"), + PlanTier::NotFound => "Unknown".to_string(), + } + } +} + +/// Parse a raw tier string (the value of `organizationRateLimitTier` in +/// `.claude.json`) into a known tier. +fn classify(raw: &str) -> PlanTier { + let l = raw.to_lowercase(); + if l.contains("max_20x") || l.contains("max20x") { + PlanTier::Max20x + } else if l.contains("max_5x") || l.contains("max5x") { + PlanTier::Max5x + } else if l.contains("max") { + PlanTier::MaxOther(raw.to_string()) + } else if l.contains("pro") { + PlanTier::Pro + } else { + PlanTier::Unknown(raw.to_string()) + } +} + +/// Find the most recent on-disk source of `organizationRateLimitTier`. +/// +/// Search order: +/// 1. `~/.claude/.claude.json` if it exists (Linux / WSL) +/// 2. `%USERPROFILE%\.claude\.claude.json` (Windows native — same logic via dirs::home_dir) +/// 3. The newest `~/.claude/backups/.claude.json.backup.*` (this is what +/// we observed populating in practice — `.claude.json` itself isn't +/// always present, but the backup directory tracks every config change) +fn locate_claude_json() -> Option { + let home = dirs::home_dir()?; + let primary = home.join(".claude").join(".claude.json"); + if primary.is_file() { + return Some(primary); + } + + let backups_dir = home.join(".claude").join("backups"); + let entries = std::fs::read_dir(&backups_dir).ok()?; + let mut latest: Option<(std::time::SystemTime, PathBuf)> = None; + for entry in entries.flatten() { + let path = entry.path(); + let name = path.file_name().and_then(|s| s.to_str()).unwrap_or(""); + if !name.starts_with(".claude.json.backup.") { + continue; + } + if let Ok(meta) = entry.metadata() { + if let Ok(mtime) = meta.modified() { + match &latest { + None => latest = Some((mtime, path)), + Some((m, _)) if mtime > *m => latest = Some((mtime, path)), + _ => {} + } + } + } + } + latest.map(|(_, p)| p) +} + +pub fn detect() -> PlanTier { + let Some(path) = locate_claude_json() else { + return PlanTier::NotFound; + }; + let Ok(bytes) = std::fs::read(&path) else { + return PlanTier::NotFound; + }; + let Ok(json) = serde_json::from_slice::(&bytes) else { + return PlanTier::NotFound; + }; + match json.get("organizationRateLimitTier").and_then(|v| v.as_str()) { + Some(raw) => classify(raw), + None => PlanTier::NotFound, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_known_tiers() { + assert_eq!(classify("default_claude_max_5x"), PlanTier::Max5x); + assert_eq!(classify("default_claude_max_20x"), PlanTier::Max20x); + assert_eq!(classify("default_claude_pro"), PlanTier::Pro); + assert!(matches!(classify("default_claude_max_99x"), PlanTier::MaxOther(_))); + assert!(matches!(classify("anything-else"), PlanTier::Unknown(_))); + } + + #[test] + fn caps_scale_by_tier() { + assert!(PlanTier::Max20x.caps().block_tokens > PlanTier::Max5x.caps().block_tokens); + assert!(PlanTier::Max5x.caps().block_tokens > PlanTier::Pro.caps().block_tokens); + assert!(PlanTier::Pro.caps().block_tokens > PlanTier::NotFound.caps().block_tokens); + } +} diff --git a/src/components/Settings.svelte b/src/components/Settings.svelte index dd20e70..30fd69d 100644 --- a/src/components/Settings.svelte +++ b/src/components/Settings.svelte @@ -5,14 +5,21 @@ disable as disableAutostart, isEnabled as isAutostartEnabled, } from "@tauri-apps/plugin-autostart"; - import { getSettings, setSettings, listDistros, getRoots } from "../ipc"; - import type { Settings, ResolvedRoots } from "../types"; + import { + getSettings, + setSettings, + listDistros, + getRoots, + detectPlanTier, + } from "../ipc"; + import type { Settings, ResolvedRoots, TierInfo } from "../types"; let { onClose }: { onClose: () => void } = $props(); let settings = $state(null); let distros = $state([]); let roots = $state(null); + let tier = $state(null); let busy = $state(false); let err = $state(null); @@ -21,6 +28,7 @@ settings = await getSettings(); distros = await listDistros(); roots = await getRoots(); + tier = await detectPlanTier(); // Sync displayed autostart from the plugin's actual state. const enabled = await isAutostartEnabled(); if (settings && settings.autostart !== enabled) { @@ -31,6 +39,17 @@ } }); + function applyTierCaps() { + if (!settings || !tier) return; + settings = { + ...settings, + caps: { + block_tokens: tier.recommended_caps.block_tokens, + weekly_tokens: tier.recommended_caps.weekly_tokens, + }, + }; + } + async function save() { if (!settings) return; busy = true; @@ -59,12 +78,30 @@ {#if err}
{err}
{/if} {#if settings} + {#if tier} +
+
+
+
Plan tier
+
{tier.label}
+
+ +
+
+ Approximate — Anthropic doesn't publish exact caps. Tune below + once you actually hit a limit. +
+
+ {/if} +
5-hour block cap (tokens)
@@ -74,7 +111,7 @@ @@ -147,6 +184,16 @@ .actions { margin-top: auto; padding-top: 8px; border-top: 1px solid var(--border); } .roots { margin: 0; padding-left: 14px; max-height: 60px; overflow: auto; } .roots code { font-size: 10px; word-break: break-all; } + .tier-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px; + display: flex; + flex-direction: column; + gap: 4px; + } + .hint { font-size: 10px; line-height: 1.3; } .error { background: rgba(255, 107, 107, 0.15); border: 1px solid var(--danger); diff --git a/src/ipc.ts b/src/ipc.ts index 245a30e..1aecbff 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -1,6 +1,6 @@ import { invoke } from "@tauri-apps/api/core"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; -import type { ResolvedRoots, Settings, UsageSnapshot } from "./types"; +import type { ResolvedRoots, Settings, TierInfo, UsageSnapshot } from "./types"; export const getSnapshot = (): Promise => invoke("get_snapshot"); export const getSettings = (): Promise => invoke("get_settings"); @@ -10,6 +10,7 @@ export const listDistros = (): Promise => invoke("list_distros"); export const getRoots = (): Promise => invoke("get_roots"); export const forceRescan = (): Promise => invoke("force_rescan"); export const quitApp = (): Promise => invoke("quit_app"); +export const detectPlanTier = (): Promise => invoke("detect_plan_tier"); export const onUsageUpdated = ( cb: (snap: UsageSnapshot) => void, diff --git a/src/types.ts b/src/types.ts index bb7330a..af093b0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -58,3 +58,13 @@ export interface ResolvedRoots { wsl_distro: string | null; native_present: boolean; } + +// Mirrors PlanTier in src-tauri/src/tier.rs. The Rust enum is serde-tagged +// externally, so the wire shape is `"Max5x"` for unit variants and +// `{ MaxOther: "raw-string" }` for tuple variants — we just keep it as +// `unknown` here and rely on `label` for display. +export interface TierInfo { + tier: unknown; + label: string; + recommended_caps: Caps; +}