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