Skip to main content

lean_ctx/cli/
cloud.rs

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