Skip to main content

lean_ctx/cli/
cloud.rs

1use crate::{cloud_client, core};
2
3fn mask_email(email: &str) -> String {
4    match email.split_once('@') {
5        Some((local, domain)) if local.len() > 2 => {
6            format!("{}...@{domain}", &local[..2])
7        }
8        _ => "***".to_string(),
9    }
10}
11
12fn parse_auth_args(args: &[String]) -> (String, Option<String>) {
13    let mut email = String::new();
14    let mut password: Option<String> = None;
15    let mut i = 0;
16    while i < args.len() {
17        match args[i].as_str() {
18            "--password" | "-p" => {
19                i += 1;
20                if i < args.len() {
21                    password = Some(args[i].clone());
22                }
23            }
24            _ => {
25                if email.is_empty() {
26                    email = args[i].trim().to_lowercase();
27                }
28            }
29        }
30        i += 1;
31    }
32    (email, password)
33}
34
35fn require_email_and_password(args: &[String], usage: &str) -> (String, String) {
36    let (email, password) = parse_auth_args(args);
37
38    if email.is_empty() {
39        eprintln!("Usage: {usage}");
40        std::process::exit(1);
41    }
42    if !email.contains('@') || !email.contains('.') {
43        tracing::error!("Invalid email address: {email}");
44        std::process::exit(1);
45    }
46
47    let pw = match password {
48        Some(p) => p,
49        None => match rpassword::prompt_password("Password: ") {
50            Ok(p) => p,
51            Err(e) => {
52                tracing::error!("Could not read password: {e}");
53                std::process::exit(1);
54            }
55        },
56    };
57    if pw.len() < 8 {
58        tracing::error!("Password must be at least 8 characters.");
59        std::process::exit(1);
60    }
61    (email, pw)
62}
63
64fn save_and_report(r: &cloud_client::RegisterResult, email: &str) {
65    if let Err(e) = cloud_client::save_credentials(&r.api_key, &r.user_id, email) {
66        tracing::warn!("Could not save credentials: {e}");
67        eprintln!("Please try again.");
68        return;
69    }
70    if let Ok(plan) = cloud_client::fetch_plan() {
71        let _ = cloud_client::save_plan(&plan);
72    }
73    println!("API key saved to ~/.lean-ctx/cloud/credentials.json");
74    if r.verification_sent {
75        println!("Verification email sent — please check your inbox.");
76    }
77    if !r.email_verified {
78        println!("Note: Your email is not yet verified.");
79    }
80}
81
82pub fn cmd_login(args: &[String]) {
83    let (email, pw) = require_email_and_password(args, "lean-ctx login <email> [--password <pw>]");
84
85    println!("Logging in to LeanCTX Cloud...");
86
87    match cloud_client::login(&email, &pw) {
88        Ok(r) => {
89            save_and_report(&r, &email);
90            println!("Logged in as {}", mask_email(&email));
91        }
92        Err(e) if e.contains("403") => {
93            tracing::error!("Please verify your email first. Check your inbox.");
94            std::process::exit(1);
95        }
96        Err(e) if e.contains("Invalid email or password") => {
97            tracing::error!("Invalid email or password.");
98            eprintln!("Forgot your password? Run: lean-ctx forgot-password <email>");
99            eprintln!("No account yet? Run: lean-ctx register <email>");
100            std::process::exit(1);
101        }
102        Err(e) => {
103            tracing::error!("Login failed: {e}");
104            eprintln!("If you don't have an account yet, run: lean-ctx register <email>");
105            std::process::exit(1);
106        }
107    }
108}
109
110pub fn cmd_forgot_password(args: &[String]) {
111    let (email, _) = parse_auth_args(args);
112
113    if email.is_empty() {
114        eprintln!("Usage: lean-ctx forgot-password <email>");
115        std::process::exit(1);
116    }
117
118    println!("Sending password reset email...");
119
120    match cloud_client::forgot_password(&email) {
121        Ok(_msg) => {
122            println!("Password reset email sent to {}.", mask_email(&email));
123            println!("Check your inbox and follow the reset link.");
124        }
125        Err(e) => {
126            tracing::error!("Failed: {e}");
127            std::process::exit(1);
128        }
129    }
130}
131
132pub fn cmd_register(args: &[String]) {
133    let (email, pw) =
134        require_email_and_password(args, "lean-ctx register <email> [--password <pw>]");
135
136    println!("Creating LeanCTX Cloud account...");
137
138    match cloud_client::register(&email, Some(&pw)) {
139        Ok(r) => {
140            save_and_report(&r, &email);
141            println!("Account created for {}", mask_email(&email));
142        }
143        Err(e) if e.contains("409") || e.contains("already exists") => {
144            tracing::error!("An account with this email already exists.");
145            eprintln!("Run: lean-ctx login <email>");
146            std::process::exit(1);
147        }
148        Err(e) => {
149            tracing::error!("Registration failed: {e}");
150            std::process::exit(1);
151        }
152    }
153}
154
155pub fn cmd_sync() {
156    if !cloud_client::is_logged_in() {
157        tracing::error!("Not logged in. Run: lean-ctx login <email>");
158        std::process::exit(1);
159    }
160
161    println!("Syncing stats...");
162    let store = core::stats::load();
163    let entries = build_sync_entries(&store);
164    if entries.is_empty() {
165        println!("No stats to sync yet.");
166    } else {
167        match cloud_client::sync_stats(&entries) {
168            Ok(_) => println!("  Stats: synced"),
169            Err(e) => tracing::error!("Stats sync failed: {e}"),
170        }
171    }
172
173    println!("Syncing commands...");
174    let command_entries = collect_command_entries(&store);
175    if command_entries.is_empty() {
176        println!("  No command data to sync.");
177    } else {
178        match cloud_client::push_commands(&command_entries) {
179            Ok(_) => println!("  Commands: synced"),
180            Err(e) => tracing::error!("Commands sync failed: {e}"),
181        }
182    }
183
184    println!("Syncing CEP scores...");
185    let cep_entries = collect_cep_entries(&store);
186    if cep_entries.is_empty() {
187        println!("  No CEP sessions to sync.");
188    } else {
189        match cloud_client::push_cep(&cep_entries) {
190            Ok(_) => println!("  CEP: synced"),
191            Err(e) => tracing::error!("CEP sync failed: {e}"),
192        }
193    }
194
195    println!("Syncing knowledge...");
196    let knowledge_entries = collect_knowledge_entries();
197    if knowledge_entries.is_empty() {
198        println!("  No knowledge to sync.");
199    } else {
200        match cloud_client::push_knowledge(&knowledge_entries) {
201            Ok(_) => println!("  Knowledge: synced"),
202            Err(e) => tracing::error!("Knowledge sync failed: {e}"),
203        }
204    }
205
206    println!("Syncing gotchas...");
207    let gotcha_entries = collect_gotcha_entries();
208    if gotcha_entries.is_empty() {
209        println!("  No gotchas to sync.");
210    } else {
211        match cloud_client::push_gotchas(&gotcha_entries) {
212            Ok(_) => println!("  Gotchas: synced"),
213            Err(e) => tracing::error!("Gotchas sync failed: {e}"),
214        }
215    }
216
217    println!("Syncing buddy...");
218    let buddy = core::buddy::BuddyState::compute();
219    let buddy_data = serde_json::to_value(&buddy).unwrap_or_default();
220    match cloud_client::push_buddy(&buddy_data) {
221        Ok(_) => println!("  Buddy: synced"),
222        Err(e) => tracing::error!("Buddy sync failed: {e}"),
223    }
224
225    println!("Syncing feedback thresholds...");
226    let feedback_entries = collect_feedback_entries();
227    if feedback_entries.is_empty() {
228        println!("  No feedback thresholds to sync.");
229    } else {
230        match cloud_client::push_feedback(&feedback_entries) {
231            Ok(_) => println!("  Feedback: synced"),
232            Err(e) => tracing::error!("Feedback sync failed: {e}"),
233        }
234    }
235
236    if let Ok(plan) = cloud_client::fetch_plan() {
237        let _ = cloud_client::save_plan(&plan);
238    }
239
240    println!("Sync complete.");
241}
242
243fn build_sync_entries(store: &core::stats::StatsStore) -> Vec<serde_json::Value> {
244    crate::cloud_sync::build_sync_entries(store)
245}
246
247fn collect_knowledge_entries() -> Vec<serde_json::Value> {
248    let Some(home) = dirs::home_dir() else {
249        return Vec::new();
250    };
251    let knowledge_dir = home.join(".lean-ctx").join("knowledge");
252    if !knowledge_dir.is_dir() {
253        return Vec::new();
254    }
255
256    let mut entries = Vec::new();
257
258    for project_entry in std::fs::read_dir(&knowledge_dir).into_iter().flatten() {
259        let Ok(project_entry) = project_entry else {
260            continue;
261        };
262        let project_path = project_entry.path();
263        if !project_path.is_dir() {
264            continue;
265        }
266
267        for file_entry in std::fs::read_dir(&project_path).into_iter().flatten() {
268            let Ok(file_entry) = file_entry else { continue };
269            let file_path = file_entry.path();
270            if file_path.extension().and_then(|e| e.to_str()) != Some("json") {
271                continue;
272            }
273            let Ok(data) = std::fs::read_to_string(&file_path) else {
274                continue;
275            };
276            let parsed: serde_json::Value = match serde_json::from_str(&data) {
277                Ok(v) => v,
278                Err(_) => continue,
279            };
280
281            if let Some(facts) = parsed["facts"].as_array() {
282                for fact in facts {
283                    let cat = fact["category"].as_str().unwrap_or("general");
284                    let key = fact["key"].as_str().unwrap_or("");
285                    let val = fact["value"]
286                        .as_str()
287                        .or_else(|| fact["description"].as_str())
288                        .unwrap_or("");
289                    if !key.is_empty() {
290                        entries.push(serde_json::json!({
291                            "category": cat,
292                            "key": key,
293                            "value": val,
294                        }));
295                    }
296                }
297            }
298
299            if let Some(gotchas) = parsed["gotchas"].as_array() {
300                for g in gotchas {
301                    let pattern = g["pattern"].as_str().unwrap_or("");
302                    let fix = g["fix"].as_str().unwrap_or("");
303                    if !pattern.is_empty() {
304                        entries.push(serde_json::json!({
305                            "category": "gotcha",
306                            "key": pattern,
307                            "value": fix,
308                        }));
309                    }
310                }
311            }
312        }
313    }
314
315    entries
316}
317
318fn collect_command_entries(store: &core::stats::StatsStore) -> Vec<serde_json::Value> {
319    store
320        .commands
321        .iter()
322        .map(|(name, stats)| {
323            let tokens_saved = stats.input_tokens.saturating_sub(stats.output_tokens);
324            serde_json::json!({
325                "command": name,
326                "source": if name.starts_with("ctx_") { "mcp" } else { "hook" },
327                "count": stats.count,
328                "input_tokens": stats.input_tokens,
329                "output_tokens": stats.output_tokens,
330                "tokens_saved": tokens_saved,
331            })
332        })
333        .collect()
334}
335
336fn complexity_to_float(s: &str) -> f64 {
337    match s.to_lowercase().as_str() {
338        "trivial" => 0.1,
339        "simple" => 0.3,
340        "moderate" => 0.5,
341        "complex" => 0.7,
342        "architectural" => 0.9,
343        other => other.parse::<f64>().unwrap_or(0.5),
344    }
345}
346
347fn collect_cep_entries(store: &core::stats::StatsStore) -> Vec<serde_json::Value> {
348    store
349        .cep
350        .scores
351        .iter()
352        .map(|s| {
353            serde_json::json!({
354                "recorded_at": s.timestamp,
355                "score": s.score as f64 / 100.0,
356                "cache_hit_rate": s.cache_hit_rate as f64 / 100.0,
357                "mode_diversity": s.mode_diversity as f64 / 100.0,
358                "compression_rate": s.compression_rate as f64 / 100.0,
359                "tool_calls": s.tool_calls,
360                "tokens_saved": s.tokens_saved,
361                "complexity": complexity_to_float(&s.complexity),
362            })
363        })
364        .collect()
365}
366
367fn collect_gotcha_entries() -> Vec<serde_json::Value> {
368    let mut all_gotchas = core::gotcha_tracker::load_universal_gotchas();
369
370    if let Some(home) = dirs::home_dir() {
371        let knowledge_dir = home.join(".lean-ctx").join("knowledge");
372        if let Ok(entries) = std::fs::read_dir(&knowledge_dir) {
373            for entry in entries.flatten() {
374                let gotcha_path = entry.path().join("gotchas.json");
375                if gotcha_path.exists() {
376                    if let Ok(content) = std::fs::read_to_string(&gotcha_path) {
377                        if let Ok(store) =
378                            serde_json::from_str::<core::gotcha_tracker::GotchaStore>(&content)
379                        {
380                            for g in store.gotchas {
381                                if !all_gotchas
382                                    .iter()
383                                    .any(|existing| existing.trigger == g.trigger)
384                                {
385                                    all_gotchas.push(g);
386                                }
387                            }
388                        }
389                    }
390                }
391            }
392        }
393    }
394
395    all_gotchas
396        .iter()
397        .map(|g| {
398            serde_json::json!({
399                "pattern": g.trigger,
400                "fix": g.resolution,
401                "severity": format!("{:?}", g.severity).to_lowercase(),
402                "category": format!("{:?}", g.category).to_lowercase(),
403                "occurrences": g.occurrences,
404                "prevented_count": g.prevented_count,
405                "confidence": g.confidence,
406            })
407        })
408        .collect()
409}
410
411fn collect_feedback_entries() -> Vec<serde_json::Value> {
412    let store = core::feedback::FeedbackStore::load();
413    store
414        .learned_thresholds
415        .iter()
416        .map(|(lang, thresholds)| {
417            serde_json::json!({
418                "language": lang,
419                "entropy": thresholds.entropy,
420                "jaccard": thresholds.jaccard,
421                "sample_count": thresholds.sample_count,
422                "avg_efficiency": thresholds.avg_efficiency,
423            })
424        })
425        .collect()
426}
427
428pub fn cmd_contribute() {
429    let mut entries = Vec::new();
430
431    if let Some(home) = dirs::home_dir() {
432        let mode_stats_path = home.join(".lean-ctx").join("mode_stats.json");
433        if let Ok(data) = std::fs::read_to_string(&mode_stats_path) {
434            if let Ok(predictor) = serde_json::from_str::<serde_json::Value>(&data) {
435                if let Some(history) = predictor["history"].as_object() {
436                    for (_sig_key, outcomes) in history {
437                        if let Some(arr) = outcomes.as_array() {
438                            for outcome in arr.iter().rev().take(5) {
439                                let ext = outcome["ext"].as_str().unwrap_or("unknown");
440                                let mode = outcome["mode"].as_str().unwrap_or("full");
441                                let tokens_in = outcome["tokens_in"].as_u64().unwrap_or(0);
442                                let tokens_out = outcome["tokens_out"].as_u64().unwrap_or(0);
443                                let ratio = if tokens_in > 0 {
444                                    1.0 - tokens_out as f64 / tokens_in as f64
445                                } else {
446                                    0.0
447                                };
448                                let bucket = match tokens_in {
449                                    0..=500 => "0-500",
450                                    501..=2000 => "500-2k",
451                                    2001..=10000 => "2k-10k",
452                                    _ => "10k+",
453                                };
454                                entries.push(serde_json::json!({
455                                    "file_ext": format!(".{ext}"),
456                                    "size_bucket": bucket,
457                                    "best_mode": mode,
458                                    "compression_ratio": (ratio * 100.0).round() / 100.0,
459                                }));
460                                if entries.len() >= 500 {
461                                    break;
462                                }
463                            }
464                        }
465                        if entries.len() >= 500 {
466                            break;
467                        }
468                    }
469                }
470            }
471        }
472    }
473
474    if entries.is_empty() {
475        let stats_data = core::stats::format_gain_json();
476        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&stats_data) {
477            let original = parsed["cep"]["total_tokens_original"].as_u64().unwrap_or(0);
478            let compressed = parsed["cep"]["total_tokens_compressed"]
479                .as_u64()
480                .unwrap_or(0);
481            let overall_ratio = if original > 0 {
482                1.0 - compressed as f64 / original as f64
483            } else {
484                0.0
485            };
486
487            if let Some(modes) = parsed["cep"]["modes"].as_object() {
488                let read_modes = ["full", "map", "signatures", "auto", "aggressive", "entropy"];
489                for (mode, count) in modes {
490                    if !read_modes.contains(&mode.as_str()) || count.as_u64().unwrap_or(0) == 0 {
491                        continue;
492                    }
493                    entries.push(serde_json::json!({
494                        "file_ext": "mixed",
495                        "size_bucket": "mixed",
496                        "best_mode": mode,
497                        "compression_ratio": (overall_ratio * 100.0).round() / 100.0,
498                    }));
499                }
500            }
501        }
502    }
503
504    if entries.is_empty() {
505        println!("No compression data to contribute yet. Use lean-ctx for a while first.");
506        return;
507    }
508
509    println!("Contributing {} data points...", entries.len());
510    match cloud_client::contribute(&entries) {
511        Ok(msg) => println!("{msg}"),
512        Err(e) => {
513            tracing::error!("Contribute failed: {e}");
514            std::process::exit(1);
515        }
516    }
517}
518
519pub fn cmd_cloud(args: &[String]) {
520    let action = args.first().map_or("help", std::string::String::as_str);
521
522    match action {
523        "pull-models" => {
524            println!("Updating adaptive models...");
525            match cloud_client::pull_cloud_models() {
526                Ok(data) => {
527                    let count = data
528                        .get("models")
529                        .and_then(|v| v.as_array())
530                        .map_or(0, std::vec::Vec::len);
531
532                    if let Err(e) = cloud_client::save_cloud_models(&data) {
533                        tracing::warn!("Could not save models: {e}");
534                        return;
535                    }
536                    println!("{count} adaptive models updated.");
537                    if let Some(est) = data
538                        .get("improvement_estimate")
539                        .and_then(serde_json::Value::as_f64)
540                    {
541                        println!("Estimated compression improvement: +{:.0}%", est * 100.0);
542                    }
543                }
544                Err(e) => {
545                    tracing::error!("{e}");
546                    std::process::exit(1);
547                }
548            }
549        }
550        "status" => {
551            if cloud_client::is_logged_in() {
552                println!("Connected to LeanCTX Cloud.");
553            } else {
554                println!("Not connected to LeanCTX Cloud.");
555                println!("Get started: lean-ctx login <email>");
556            }
557        }
558        _ => {
559            println!("Usage: lean-ctx cloud <command>");
560            println!("  pull-models — Update adaptive compression models");
561            println!("  status      — Show cloud connection status");
562        }
563    }
564}
565
566pub fn cmd_gotchas(args: &[String]) {
567    let action = args.first().map_or("list", std::string::String::as_str);
568    let project_root = std::env::current_dir()
569        .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string());
570
571    match action {
572        "list" | "ls" => {
573            let store = core::gotcha_tracker::GotchaStore::load(&project_root);
574            println!("{}", store.format_list());
575        }
576        "clear" => {
577            let mut store = core::gotcha_tracker::GotchaStore::load(&project_root);
578            let count = store.gotchas.len();
579            store.clear();
580            let _ = store.save(&project_root);
581            println!("Cleared {count} gotchas.");
582        }
583        "export" => {
584            let store = core::gotcha_tracker::GotchaStore::load(&project_root);
585            match serde_json::to_string_pretty(&store.gotchas) {
586                Ok(json) => println!("{json}"),
587                Err(e) => tracing::error!("Export failed: {e}"),
588            }
589        }
590        "stats" => {
591            let store = core::gotcha_tracker::GotchaStore::load(&project_root);
592            println!("Bug Memory Stats:");
593            println!("  Active gotchas:      {}", store.gotchas.len());
594            println!(
595                "  Errors detected:     {}",
596                store.stats.total_errors_detected
597            );
598            println!(
599                "  Fixes correlated:    {}",
600                store.stats.total_fixes_correlated
601            );
602            println!("  Bugs prevented:      {}", store.stats.total_prevented);
603            println!("  Promoted to knowledge: {}", store.stats.gotchas_promoted);
604            println!("  Decayed/archived:    {}", store.stats.gotchas_decayed);
605            println!("  Session logs:        {}", store.error_log.len());
606        }
607        _ => {
608            println!("Usage: lean-ctx gotchas [list|clear|export|stats]");
609        }
610    }
611}
612
613pub fn cmd_buddy(args: &[String]) {
614    let cfg = core::config::Config::load();
615    if !cfg.buddy_enabled {
616        println!("Buddy is disabled. Enable with: lean-ctx config buddy_enabled true");
617        return;
618    }
619
620    let action = args.first().map_or("show", std::string::String::as_str);
621    let buddy = core::buddy::BuddyState::compute();
622    let theme = core::theme::load_theme(&cfg.theme);
623
624    match action {
625        "show" | "status" | "stats" => {
626            println!("{}", core::buddy::format_buddy_full(&buddy, &theme));
627        }
628        "ascii" => {
629            for line in &buddy.ascii_art {
630                println!("  {line}");
631            }
632        }
633        "json" => match serde_json::to_string_pretty(&buddy) {
634            Ok(json) => println!("{json}"),
635            Err(e) => tracing::error!("JSON error: {e}"),
636        },
637        _ => {
638            println!("Usage: lean-ctx buddy [show|stats|ascii|json]");
639        }
640    }
641}
642
643pub fn cmd_upgrade() {
644    println!("'upgrade' has been renamed to 'update'. Running 'lean-ctx update' instead.\n");
645    core::updater::run(&[]);
646}