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")
91        .ok()
92        .or_else(|| config.get("groq_api_key"));
93    let openai_key = std::env::var("OPENAI_API_KEY")
94        .ok()
95        .or_else(|| config.get("openai_api_key"));
96    let gemini_key = std::env::var("GEMINI_API_KEY")
97        .ok()
98        .or_else(|| config.get("gemini_api_key"));
99    let anthropic_key = std::env::var("ANTHROPIC_API_KEY")
100        .ok()
101        .or_else(|| config.get("anthropic_api_key"));
102
103    // Fetch subscription info (if API key set and not offline)
104    let subscription = if has_api_key && !args.offline {
105        fetch_subscription_info(
106            &effective_api_key.as_ref().unwrap(),
107            &effective_dashboard_url,
108        )
109        .ok()
110    } else {
111        None
112    };
113
114    // Scan for local .mv2 files
115    let scan_dir = args.dir.clone().unwrap_or_else(|| PathBuf::from("."));
116    let local_memories = scan_local_memories(&scan_dir);
117    let total_size: u64 = local_memories.iter().map(|m| m.size_bytes).sum();
118
119    if args.json {
120        output_json(
121            &config_path,
122            has_api_key,
123            api_key_source,
124            &effective_api_key,
125            &effective_dashboard_url,
126            &named_memories,
127            &subscription,
128            &local_memories,
129            total_size,
130            groq_key.is_some(),
131            openai_key.is_some(),
132            gemini_key.is_some(),
133            anthropic_key.is_some(),
134        )?;
135    } else {
136        output_pretty(
137            &config_path,
138            has_api_key,
139            api_key_source,
140            &effective_api_key,
141            &effective_dashboard_url,
142            &named_memories,
143            &subscription,
144            &local_memories,
145            total_size,
146            groq_key.is_some(),
147            openai_key.is_some(),
148            gemini_key.is_some(),
149            anthropic_key.is_some(),
150        );
151    }
152
153    Ok(())
154}
155
156fn fetch_subscription_info(api_key: &str, dashboard_url: &str) -> Result<SubscriptionInfo> {
157    let url = format!("{}/api/ticket", dashboard_url.trim_end_matches('/'));
158
159    let mut headers = HeaderMap::new();
160    headers.insert(
161        "x-api-key",
162        HeaderValue::from_str(api_key).context("Invalid API key format")?,
163    );
164
165    let client = Client::builder()
166        .timeout(Duration::from_secs(5))
167        .build()
168        .context("Failed to create HTTP client")?;
169
170    let response = client
171        .get(&url)
172        .headers(headers)
173        .send()
174        .context("Failed to fetch subscription info")?;
175
176    let body: serde_json::Value = response.json().context("Failed to parse response")?;
177
178    let data = body.get("data").unwrap_or(&body);
179    let ticket = data.get("ticket").unwrap_or(data);
180    let subscription = data.get("subscription");
181
182    let capacity_bytes = ticket
183        .get("capacity_bytes")
184        .and_then(|v| v.as_u64())
185        .unwrap_or(1_073_741_824); // 1 GB default
186
187    let capacity_gb = capacity_bytes as f64 / 1_073_741_824.0;
188
189    let status = subscription
190        .and_then(|s| s.get("status"))
191        .and_then(|v| v.as_str())
192        .unwrap_or("active")
193        .to_string();
194
195    let plan_name = ticket
196        .get("issuer")
197        .and_then(|v| v.as_str())
198        .unwrap_or("Free")
199        .to_string();
200
201    let renews_at = subscription
202        .and_then(|s| s.get("planEndDate").or_else(|| s.get("ends_at")))
203        .and_then(|v| v.as_str())
204        .map(|s| s.to_string());
205
206    Ok(SubscriptionInfo {
207        status,
208        plan_name,
209        capacity_gb,
210        renews_at,
211    })
212}
213
214fn scan_local_memories(dir: &PathBuf) -> Vec<LocalMemory> {
215    let mut memories = Vec::new();
216
217    if let Ok(entries) = fs::read_dir(dir) {
218        for entry in entries.filter_map(|e| e.ok()) {
219            let path = entry.path();
220            if path.extension().map_or(false, |ext| ext == "mv2") {
221                if let Ok(metadata) = fs::metadata(&path) {
222                    let name = path
223                        .file_name()
224                        .map(|n| n.to_string_lossy().to_string())
225                        .unwrap_or_default();
226                    memories.push(LocalMemory {
227                        path,
228                        size_bytes: metadata.len(),
229                        name,
230                    });
231                }
232            }
233        }
234    }
235
236    // Sort by name
237    memories.sort_by(|a, b| a.name.cmp(&b.name));
238    memories
239}
240
241fn format_bytes(bytes: u64) -> String {
242    if bytes >= 1_073_741_824 {
243        format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
244    } else if bytes >= 1_048_576 {
245        format!("{:.1} MB", bytes as f64 / 1_048_576.0)
246    } else if bytes >= 1024 {
247        format!("{:.1} KB", bytes as f64 / 1024.0)
248    } else {
249        format!("{} bytes", bytes)
250    }
251}
252
253#[allow(clippy::too_many_arguments)]
254fn output_pretty(
255    config_path: &PathBuf,
256    has_api_key: bool,
257    api_key_source: &str,
258    effective_api_key: &Option<String>,
259    dashboard_url: &str,
260    named_memories: &[(String, String)],
261    subscription: &Option<SubscriptionInfo>,
262    local_memories: &[LocalMemory],
263    total_size: u64,
264    has_groq: bool,
265    has_openai: bool,
266    has_gemini: bool,
267    has_anthropic: bool,
268) {
269    println!();
270    println!("Memvid Status");
271    println!("{}", "━".repeat(50));
272    println!();
273
274    // API Key status
275    if has_api_key {
276        let key = effective_api_key.as_ref().unwrap();
277        let masked = PersistentConfig::mask_value(key);
278        println!("✓ API Key: configured ({})", masked);
279        println!("  Source: {}", api_key_source);
280    } else {
281        println!("✗ API Key: not configured");
282        println!("  Fix: memvid config set api_key <your-key>");
283    }
284
285    // Subscription/Plan status
286    if let Some(sub) = subscription {
287        println!();
288        println!("✓ Plan: {} ({:.1} GB)", sub.plan_name, sub.capacity_gb);
289        println!("✓ Subscription: {}", sub.status);
290        if let Some(renews) = &sub.renews_at {
291            println!("  Renews: {}", renews);
292        }
293    } else if has_api_key {
294        println!();
295        println!("⚠ Plan: Could not fetch (use --offline to skip)");
296    }
297
298    // Dashboard URL
299    println!();
300    println!("Dashboard: {}", dashboard_url);
301
302    // Named memories
303    if !named_memories.is_empty() {
304        println!();
305        println!("Named Memories:");
306        for (name, id) in named_memories {
307            let short_id = if id.len() > 12 {
308                format!("{}...", &id[..12])
309            } else {
310                id.clone()
311            };
312            println!("  {} → {}", name, short_id);
313        }
314    }
315
316    // LLM Provider keys
317    println!();
318    println!("LLM Providers:");
319    print_key_status("  Groq", has_groq);
320    print_key_status("  OpenAI", has_openai);
321    print_key_status("  Gemini", has_gemini);
322    print_key_status("  Anthropic", has_anthropic);
323
324    // Local memories
325    println!();
326    if local_memories.is_empty() {
327        println!("Local Memories: None found in current directory");
328    } else {
329        println!(
330            "Local Memories: {} files ({})",
331            local_memories.len(),
332            format_bytes(total_size)
333        );
334        for mem in local_memories.iter().take(5) {
335            println!("  {} ({})", mem.name, format_bytes(mem.size_bytes));
336        }
337        if local_memories.len() > 5 {
338            println!("  ... and {} more", local_memories.len() - 5);
339        }
340    }
341
342    // Config file location
343    println!();
344    println!("Config: {}", config_path.display());
345    println!();
346}
347
348fn print_key_status(name: &str, configured: bool) {
349    if configured {
350        println!("{}: ✓ configured", name);
351    } else {
352        println!("{}: ✗ not set", name);
353    }
354}
355
356#[allow(clippy::too_many_arguments)]
357fn output_json(
358    config_path: &PathBuf,
359    has_api_key: bool,
360    api_key_source: &str,
361    effective_api_key: &Option<String>,
362    dashboard_url: &str,
363    named_memories: &[(String, String)],
364    subscription: &Option<SubscriptionInfo>,
365    local_memories: &[LocalMemory],
366    total_size: u64,
367    has_groq: bool,
368    has_openai: bool,
369    has_gemini: bool,
370    has_anthropic: bool,
371) -> Result<()> {
372    let memories_json: Vec<serde_json::Value> = local_memories
373        .iter()
374        .map(|m| {
375            json!({
376                "name": m.name,
377                "path": m.path.display().to_string(),
378                "size_bytes": m.size_bytes,
379            })
380        })
381        .collect();
382
383    let named_memories_json: serde_json::Map<String, serde_json::Value> = named_memories
384        .iter()
385        .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
386        .collect();
387
388    let output = json!({
389        "config_path": config_path.display().to_string(),
390        "api_key": {
391            "configured": has_api_key,
392            "source": api_key_source,
393            "value": effective_api_key.as_ref().map(|k| PersistentConfig::mask_value(k)),
394        },
395        "dashboard_url": dashboard_url,
396        "subscription": subscription.as_ref().map(|s| json!({
397            "status": s.status,
398            "plan": s.plan_name,
399            "capacity_gb": s.capacity_gb,
400            "renews_at": s.renews_at,
401        })),
402        "named_memories": named_memories_json,
403        "llm_providers": {
404            "groq": has_groq,
405            "openai": has_openai,
406            "gemini": has_gemini,
407            "anthropic": has_anthropic,
408        },
409        "local_memories": {
410            "count": local_memories.len(),
411            "total_bytes": total_size,
412            "files": memories_json,
413        },
414    });
415
416    println!("{}", serde_json::to_string_pretty(&output)?);
417    Ok(())
418}