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}