memvid_cli/commands/
status.rs

1//! Status command - Show configuration and system status
2//!
3//! Provides a comprehensive overview of:
4//! - API key configuration
5//! - Plan/subscription status
6//! - Local memory files
7//! - LLM provider keys
8
9use anyhow::{Context, Result};
10use clap::Args;
11use reqwest::blocking::Client;
12use reqwest::header::{HeaderMap, HeaderValue};
13use serde_json::json;
14use std::fs;
15use std::path::PathBuf;
16use std::time::Duration;
17
18use super::config::PersistentConfig;
19
20/// Arguments for the `status` command
21#[derive(Args)]
22pub struct StatusArgs {
23    /// Output as JSON
24    #[arg(long)]
25    pub json: bool,
26
27    /// Directory to scan for .mv2 files (defaults to current directory)
28    #[arg(short, long)]
29    pub dir: Option<PathBuf>,
30
31    /// Skip API validation (offline mode)
32    #[arg(long)]
33    pub offline: bool,
34}
35
36/// Subscription info from API
37#[derive(Debug, Default)]
38struct SubscriptionInfo {
39    status: String,
40    plan_name: String,
41    capacity_gb: f64,
42    renews_at: Option<String>,
43}
44
45/// Local memory file info
46#[derive(Debug)]
47struct LocalMemory {
48    path: PathBuf,
49    size_bytes: u64,
50    name: String,
51}
52
53/// Handle the status command
54pub fn handle_status(args: StatusArgs) -> Result<()> {
55    let config = PersistentConfig::load()?;
56    let config_path = PersistentConfig::config_path()?;
57
58    // Check environment variables
59    let env_api_key = std::env::var("MEMVID_API_KEY").ok();
60    let env_dashboard_url = std::env::var("MEMVID_DASHBOARD_URL")
61        .or_else(|_| std::env::var("MEMVID_API_URL"))
62        .ok();
63
64    // Effective values (env takes precedence)
65    let effective_api_key = env_api_key.clone().or(config.api_key.clone());
66    let effective_dashboard_url = env_dashboard_url
67        .clone()
68        .or(config.dashboard_url.clone())
69        .or(config.api_url.clone())
70        .unwrap_or_else(|| "https://memvid.com".to_string());
71
72    // API key status
73    let has_api_key = effective_api_key.is_some();
74    let api_key_source = if env_api_key.is_some() {
75        "environment"
76    } else if config.api_key.is_some() {
77        "config file"
78    } else {
79        "not set"
80    };
81
82    // Named memories from config
83    let named_memories: Vec<(String, String)> = config
84        .memory
85        .iter()
86        .map(|(k, v)| (k.clone(), v.clone()))
87        .collect();
88
89    // Check LLM provider keys (env vars or config file)
90    let groq_key = std::env::var("GROQ_API_KEY").ok().or_else(|| config.get("groq_api_key"));
91    let openai_key = std::env::var("OPENAI_API_KEY").ok().or_else(|| config.get("openai_api_key"));
92    let gemini_key = std::env::var("GEMINI_API_KEY").ok().or_else(|| config.get("gemini_api_key"));
93    let anthropic_key = std::env::var("ANTHROPIC_API_KEY").ok().or_else(|| config.get("anthropic_api_key"));
94
95    // Fetch subscription info (if API key set and not offline)
96    let subscription = if has_api_key && !args.offline {
97        fetch_subscription_info(&effective_api_key.as_ref().unwrap(), &effective_dashboard_url)
98            .ok()
99    } else {
100        None
101    };
102
103    // Scan for local .mv2 files
104    let scan_dir = args.dir.clone().unwrap_or_else(|| PathBuf::from("."));
105    let local_memories = scan_local_memories(&scan_dir);
106    let total_size: u64 = local_memories.iter().map(|m| m.size_bytes).sum();
107
108    if args.json {
109        output_json(
110            &config_path,
111            has_api_key,
112            api_key_source,
113            &effective_api_key,
114            &effective_dashboard_url,
115            &named_memories,
116            &subscription,
117            &local_memories,
118            total_size,
119            groq_key.is_some(),
120            openai_key.is_some(),
121            gemini_key.is_some(),
122            anthropic_key.is_some(),
123        )?;
124    } else {
125        output_pretty(
126            &config_path,
127            has_api_key,
128            api_key_source,
129            &effective_api_key,
130            &effective_dashboard_url,
131            &named_memories,
132            &subscription,
133            &local_memories,
134            total_size,
135            groq_key.is_some(),
136            openai_key.is_some(),
137            gemini_key.is_some(),
138            anthropic_key.is_some(),
139        );
140    }
141
142    Ok(())
143}
144
145fn fetch_subscription_info(api_key: &str, dashboard_url: &str) -> Result<SubscriptionInfo> {
146    let url = format!("{}/api/ticket", dashboard_url.trim_end_matches('/'));
147
148    let mut headers = HeaderMap::new();
149    headers.insert(
150        "x-api-key",
151        HeaderValue::from_str(api_key).context("Invalid API key format")?,
152    );
153
154    let client = Client::builder()
155        .timeout(Duration::from_secs(5))
156        .build()
157        .context("Failed to create HTTP client")?;
158
159    let response = client
160        .get(&url)
161        .headers(headers)
162        .send()
163        .context("Failed to fetch subscription info")?;
164
165    let body: serde_json::Value = response.json().context("Failed to parse response")?;
166
167    let data = body.get("data").unwrap_or(&body);
168    let ticket = data.get("ticket").unwrap_or(data);
169    let subscription = data.get("subscription");
170
171    let capacity_bytes = ticket
172        .get("capacity_bytes")
173        .and_then(|v| v.as_u64())
174        .unwrap_or(1_073_741_824); // 1 GB default
175
176    let capacity_gb = capacity_bytes as f64 / 1_073_741_824.0;
177
178    let status = subscription
179        .and_then(|s| s.get("status"))
180        .and_then(|v| v.as_str())
181        .unwrap_or("active")
182        .to_string();
183
184    let plan_name = ticket
185        .get("issuer")
186        .and_then(|v| v.as_str())
187        .unwrap_or("Free")
188        .to_string();
189
190    let renews_at = subscription
191        .and_then(|s| s.get("planEndDate").or_else(|| s.get("ends_at")))
192        .and_then(|v| v.as_str())
193        .map(|s| s.to_string());
194
195    Ok(SubscriptionInfo {
196        status,
197        plan_name,
198        capacity_gb,
199        renews_at,
200    })
201}
202
203fn scan_local_memories(dir: &PathBuf) -> Vec<LocalMemory> {
204    let mut memories = Vec::new();
205
206    if let Ok(entries) = fs::read_dir(dir) {
207        for entry in entries.filter_map(|e| e.ok()) {
208            let path = entry.path();
209            if path.extension().map_or(false, |ext| ext == "mv2") {
210                if let Ok(metadata) = fs::metadata(&path) {
211                    let name = path
212                        .file_name()
213                        .map(|n| n.to_string_lossy().to_string())
214                        .unwrap_or_default();
215                    memories.push(LocalMemory {
216                        path,
217                        size_bytes: metadata.len(),
218                        name,
219                    });
220                }
221            }
222        }
223    }
224
225    // Sort by name
226    memories.sort_by(|a, b| a.name.cmp(&b.name));
227    memories
228}
229
230fn format_bytes(bytes: u64) -> String {
231    if bytes >= 1_073_741_824 {
232        format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
233    } else if bytes >= 1_048_576 {
234        format!("{:.1} MB", bytes as f64 / 1_048_576.0)
235    } else if bytes >= 1024 {
236        format!("{:.1} KB", bytes as f64 / 1024.0)
237    } else {
238        format!("{} bytes", bytes)
239    }
240}
241
242#[allow(clippy::too_many_arguments)]
243fn output_pretty(
244    config_path: &PathBuf,
245    has_api_key: bool,
246    api_key_source: &str,
247    effective_api_key: &Option<String>,
248    dashboard_url: &str,
249    named_memories: &[(String, String)],
250    subscription: &Option<SubscriptionInfo>,
251    local_memories: &[LocalMemory],
252    total_size: u64,
253    has_groq: bool,
254    has_openai: bool,
255    has_gemini: bool,
256    has_anthropic: bool,
257) {
258    println!();
259    println!("Memvid Status");
260    println!("{}", "━".repeat(50));
261    println!();
262
263    // API Key status
264    if has_api_key {
265        let key = effective_api_key.as_ref().unwrap();
266        let masked = PersistentConfig::mask_value(key);
267        println!("✓ API Key: configured ({})", masked);
268        println!("  Source: {}", api_key_source);
269    } else {
270        println!("✗ API Key: not configured");
271        println!("  Fix: memvid config set api_key <your-key>");
272    }
273
274    // Subscription/Plan status
275    if let Some(sub) = subscription {
276        println!();
277        println!("✓ Plan: {} ({:.1} GB)", sub.plan_name, sub.capacity_gb);
278        println!("✓ Subscription: {}", sub.status);
279        if let Some(renews) = &sub.renews_at {
280            println!("  Renews: {}", renews);
281        }
282    } else if has_api_key {
283        println!();
284        println!("⚠ Plan: Could not fetch (use --offline to skip)");
285    }
286
287    // Dashboard URL
288    println!();
289    println!("Dashboard: {}", dashboard_url);
290
291    // Named memories
292    if !named_memories.is_empty() {
293        println!();
294        println!("Named Memories:");
295        for (name, id) in named_memories {
296            let short_id = if id.len() > 12 {
297                format!("{}...", &id[..12])
298            } else {
299                id.clone()
300            };
301            println!("  {} → {}", name, short_id);
302        }
303    }
304
305    // LLM Provider keys
306    println!();
307    println!("LLM Providers:");
308    print_key_status("  Groq", has_groq);
309    print_key_status("  OpenAI", has_openai);
310    print_key_status("  Gemini", has_gemini);
311    print_key_status("  Anthropic", has_anthropic);
312
313    // Local memories
314    println!();
315    if local_memories.is_empty() {
316        println!("Local Memories: None found in current directory");
317    } else {
318        println!(
319            "Local Memories: {} files ({})",
320            local_memories.len(),
321            format_bytes(total_size)
322        );
323        for mem in local_memories.iter().take(5) {
324            println!("  {} ({})", mem.name, format_bytes(mem.size_bytes));
325        }
326        if local_memories.len() > 5 {
327            println!("  ... and {} more", local_memories.len() - 5);
328        }
329    }
330
331    // Config file location
332    println!();
333    println!("Config: {}", config_path.display());
334    println!();
335}
336
337fn print_key_status(name: &str, configured: bool) {
338    if configured {
339        println!("{}: ✓ configured", name);
340    } else {
341        println!("{}: ✗ not set", name);
342    }
343}
344
345#[allow(clippy::too_many_arguments)]
346fn output_json(
347    config_path: &PathBuf,
348    has_api_key: bool,
349    api_key_source: &str,
350    effective_api_key: &Option<String>,
351    dashboard_url: &str,
352    named_memories: &[(String, String)],
353    subscription: &Option<SubscriptionInfo>,
354    local_memories: &[LocalMemory],
355    total_size: u64,
356    has_groq: bool,
357    has_openai: bool,
358    has_gemini: bool,
359    has_anthropic: bool,
360) -> Result<()> {
361    let memories_json: Vec<serde_json::Value> = local_memories
362        .iter()
363        .map(|m| {
364            json!({
365                "name": m.name,
366                "path": m.path.display().to_string(),
367                "size_bytes": m.size_bytes,
368            })
369        })
370        .collect();
371
372    let named_memories_json: serde_json::Map<String, serde_json::Value> = named_memories
373        .iter()
374        .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
375        .collect();
376
377    let output = json!({
378        "config_path": config_path.display().to_string(),
379        "api_key": {
380            "configured": has_api_key,
381            "source": api_key_source,
382            "value": effective_api_key.as_ref().map(|k| PersistentConfig::mask_value(k)),
383        },
384        "dashboard_url": dashboard_url,
385        "subscription": subscription.as_ref().map(|s| json!({
386            "status": s.status,
387            "plan": s.plan_name,
388            "capacity_gb": s.capacity_gb,
389            "renews_at": s.renews_at,
390        })),
391        "named_memories": named_memories_json,
392        "llm_providers": {
393            "groq": has_groq,
394            "openai": has_openai,
395            "gemini": has_gemini,
396            "anthropic": has_anthropic,
397        },
398        "local_memories": {
399            "count": local_memories.len(),
400            "total_bytes": total_size,
401            "files": memories_json,
402        },
403    });
404
405    println!("{}", serde_json::to_string_pretty(&output)?);
406    Ok(())
407}