Detect tier across WSL roots, not just native home (\\wsl$\<distro>\... probe)

This commit is contained in:
megaproxy 2026-05-09 01:00:50 +01:00
parent ef84257ddd
commit f33bb5481b
3 changed files with 86 additions and 34 deletions

View file

@ -1,9 +1,11 @@
//! Tauri command surface — every JS-callable function lives here.
use std::path::PathBuf;
use crate::paths::{list_wsl_distros, resolve_roots, ResolvedRoots};
use crate::settings::{save as save_settings, Caps, Settings};
use crate::state::SharedState;
use crate::tier::{detect as detect_tier, PlanTier};
use crate::tier::{detect_in as detect_tier_in, PlanTier};
use crate::usage::{build_snapshot, UsageSnapshot};
use crate::watch::refresh_and_emit;
@ -84,8 +86,27 @@ pub struct TierInfo {
}
#[tauri::command]
pub async fn detect_plan_tier() -> Result<TierInfo, String> {
let tier = detect_tier();
pub async fn detect_plan_tier(
state: tauri::State<'_, SharedState>,
) -> Result<TierInfo, String> {
// Each resolved root is a `<base>/.claude/projects` path. The tier
// file lives in the parent (`<base>/.claude/`) — so check those, plus
// the native home as a fallback, so this works whether Claude Code
// runs in WSL (UNC paths) or natively.
let mut candidates: Vec<PathBuf> = state
.roots
.read()
.iter()
.filter_map(|r| r.parent().map(PathBuf::from))
.collect();
if let Some(home) = dirs::home_dir() {
candidates.push(home.join(".claude"));
}
// Dedupe (canonicalize is unreliable on UNC paths, so compare raw).
candidates.sort();
candidates.dedup();
let tier = detect_tier_in(&candidates);
let label = tier.label();
let recommended_caps = tier.caps();
Ok(TierInfo { tier, label, recommended_caps })

View file

@ -65,8 +65,24 @@ pub fn load() -> Settings {
let Ok(bytes) = std::fs::read(&path) else {
// First run — seed caps from detected plan tier so the ring
// doesn't peg red against the static placeholders.
//
// We don't have resolved roots yet at this point in startup, so
// also check WSL homes (without a watcher running) by re-resolving
// from default settings.
let resolved = crate::paths::resolve_roots(None, true);
let mut candidates: Vec<std::path::PathBuf> = resolved
.roots
.iter()
.filter_map(|r| r.parent().map(std::path::PathBuf::from))
.collect();
if let Some(home) = dirs::home_dir() {
candidates.push(home.join(".claude"));
}
candidates.sort();
candidates.dedup();
let mut s = Settings::default();
s.caps = crate::tier::detect().caps();
s.caps = crate::tier::detect_in(&candidates).caps();
return s;
};
match serde_json::from_slice::<Settings>(&bytes) {

View file

@ -70,23 +70,18 @@ fn classify(raw: &str) -> PlanTier {
}
}
/// 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<PathBuf> {
let home = dirs::home_dir()?;
let primary = home.join(".claude").join(".claude.json");
if primary.is_file() {
return Some(primary);
}
/// Read the tier from a single `.claude.json`-shaped file. None if the file
/// doesn't exist, isn't valid JSON, or is missing the field.
fn read_tier_from(path: &std::path::Path) -> Option<PlanTier> {
let bytes = std::fs::read(path).ok()?;
let json: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
let raw = json.get("organizationRateLimitTier").and_then(|v| v.as_str())?;
Some(classify(raw))
}
let backups_dir = home.join(".claude").join("backups");
let entries = std::fs::read_dir(&backups_dir).ok()?;
/// Find the newest `.claude.json.backup.*` in `dir` and read its tier.
fn read_tier_from_backups(dir: &std::path::Path) -> Option<PlanTier> {
let entries = std::fs::read_dir(dir).ok()?;
let mut latest: Option<(std::time::SystemTime, PathBuf)> = None;
for entry in entries.flatten() {
let path = entry.path();
@ -104,23 +99,43 @@ fn locate_claude_json() -> Option<PathBuf> {
}
}
}
latest.map(|(_, p)| p)
let (_, path) = latest?;
read_tier_from(&path)
}
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::<serde_json::Value>(&bytes) else {
return PlanTier::NotFound;
};
match json.get("organizationRateLimitTier").and_then(|v| v.as_str()) {
Some(raw) => classify(raw),
None => PlanTier::NotFound,
/// Try each `.claude/` candidate directory in order. The first hit wins.
///
/// For each candidate `<dir>` we look at:
/// 1. `<dir>/.claude.json` (e.g. ~/.claude/.claude.json)
/// 2. `<dir>/backups/.claude.json.backup.*` (newest)
/// 3. `<parent of dir>/.claude.json` (some setups put it at ~/.claude.json)
pub fn detect_in(claude_dirs: &[PathBuf]) -> PlanTier {
for dir in claude_dirs {
let primary = dir.join(".claude.json");
if let Some(t) = read_tier_from(&primary) {
return t;
}
let backups = dir.join("backups");
if let Some(t) = read_tier_from_backups(&backups) {
return t;
}
if let Some(parent) = dir.parent() {
let alt = parent.join(".claude.json");
if let Some(t) = read_tier_from(&alt) {
return t;
}
}
}
PlanTier::NotFound
}
/// Native-only fallback path. Most callers should prefer `detect_in` with
/// the resolved-roots' parents so the WSL case works.
pub fn detect() -> PlanTier {
let Some(home) = dirs::home_dir() else {
return PlanTier::NotFound;
};
detect_in(&[home.join(".claude")])
}
#[cfg(test)]