Skip to main content

lean_ctx/cli/
cloud.rs

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