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[..local.floor_char_boundary(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(rest: &[String]) {
162 if rest.first().map(String::as_str) == Some("index") {
163 cmd_sync_index(&rest[1..]);
164 return;
165 }
166 if !cloud_client::is_logged_in() {
167 tracing::error!("Not logged in. Run: lean-ctx login <email>");
168 std::process::exit(1);
169 }
170
171 println!("Syncing stats...");
173 let store = core::stats::load();
174 let entries = build_sync_entries(&store);
175 if entries.is_empty() {
176 println!("No stats to sync yet.");
177 } else {
178 match cloud_client::sync_stats(&entries) {
179 Ok(_) => println!(" Stats: synced"),
180 Err(e) => tracing::error!("Stats sync failed: {e}"),
181 }
182 }
183
184 if sync_personal_cloud(&store) == CloudSyncOutcome::Gated {
188 print_pro_upgrade_hint();
189 return;
190 }
191
192 if let Ok(plan) = cloud_client::fetch_plan() {
193 let _ = cloud_client::save_plan(&plan);
194 }
195
196 println!("Sync complete.");
197}
198
199fn cmd_sync_index(args: &[String]) {
202 let sub = args.first().map_or("help", String::as_str);
203 let root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
204
205 match sub {
206 "push" => match cloud_client::push_index_bundle(&root) {
207 Ok((project_hash, bytes)) => {
208 println!(
209 "\x1b[32m✓\x1b[0m Index pushed ({:.1} MB encrypted, project {})",
210 bytes as f64 / 1_048_576.0,
211 &project_hash[..12.min(project_hash.len())]
212 );
213 println!(" Pull on any device: lean-ctx sync index pull");
214 }
215 Err(e) => {
216 eprintln!("\x1b[31m✗\x1b[0m {e}");
217 std::process::exit(1);
218 }
219 },
220 "pull" => match cloud_client::pull_index_bundle(&root) {
221 Ok(manifest) => {
222 println!(
223 "\x1b[32m✓\x1b[0m Index restored ({} files, built {} by v{})",
224 manifest.files.len(),
225 manifest.created_at,
226 manifest.engine_version
227 );
228 println!(" Semantic search is ready — no local re-index needed.");
229 }
230 Err(e) => {
231 eprintln!("\x1b[31m✗\x1b[0m {e}");
232 std::process::exit(1);
233 }
234 },
235 "status" => match cloud_client::index_bundle_status() {
236 Ok(v) => {
237 let used_mb = v["used_bytes"].as_u64().unwrap_or(0) as f64 / 1_048_576.0;
238 let quota_mb = v["quota_mb"].as_u64().unwrap_or(0);
239 println!("Hosted Personal Index");
240 println!(" Usage: {used_mb:.1} MB / {quota_mb} MB");
241 if let Some(line) = render_quota_state(&v["storage"]) {
242 println!(" {line}");
243 }
244 if let Some(buckets) = v["projects"].as_array() {
245 if buckets.is_empty() {
246 println!(" No project bundles yet. Push one: lean-ctx sync index push");
247 }
248 for b in buckets {
249 println!(
250 " • {} {:.1} MB (updated {})",
251 b["project_hash"].as_str().unwrap_or("?"),
252 b["size_bytes"].as_u64().unwrap_or(0) as f64 / 1_048_576.0,
253 b["updated_at"].as_str().unwrap_or("?")
254 );
255 }
256 }
257 }
258 Err(e) => {
259 eprintln!("\x1b[31m✗\x1b[0m {e}");
260 std::process::exit(1);
261 }
262 },
263 _ => {
264 println!("Usage: lean-ctx sync index <push|pull|status>");
265 println!(" push Pack, encrypt and upload this project's retrieval index");
266 println!(" pull Download and restore the hosted index on this device");
267 println!(" status Show hosted buckets and quota usage");
268 }
269 }
270}
271
272fn render_quota_state(storage: &serde_json::Value) -> Option<String> {
277 let state = storage["state"].as_str()?;
278 let percent = storage["percent"].as_f64();
279 let pct = percent.map_or(String::new(), |p| format!(" ({p:.0}% of quota)"));
280 Some(match state {
281 "ok" => format!("State: \x1b[32mok\x1b[0m{pct}"),
282 "warn" => format!("State: \x1b[33mwarn\x1b[0m{pct} — consider pruning old buckets"),
283 "critical" => {
284 format!("State: \x1b[31mcritical\x1b[0m{pct} — next push may exceed the quota")
285 }
286 "over" => {
287 let over_mb = storage["overage_bytes"].as_u64().unwrap_or(0) as f64 / 1_000_000.0;
288 format!(
289 "State: \x1b[31mover\x1b[0m{pct} — {over_mb:.1} MB over; pushes are blocked (nothing is billed). Free space: lean-ctx sync index status / delete"
290 )
291 }
292 _ => return None,
295 })
296}
297
298fn pro_gate_hit(err: &str) -> bool {
301 err.contains("402")
302}
303
304#[derive(PartialEq, Eq)]
305enum CloudSyncOutcome {
306 Done,
307 Gated,
308}
309
310fn sync_personal_cloud(store: &core::stats::StatsStore) -> CloudSyncOutcome {
315 println!("Syncing commands...");
316 let command_entries = collect_command_entries(store);
317 if command_entries.is_empty() {
318 println!(" No command data to sync.");
319 } else {
320 match cloud_client::push_commands(&command_entries) {
321 Ok(_) => println!(" Commands: synced"),
322 Err(e) if pro_gate_hit(&e) => return CloudSyncOutcome::Gated,
323 Err(e) => tracing::error!("Commands sync failed: {e}"),
324 }
325 }
326
327 println!("Syncing CEP scores...");
328 let cep_entries = collect_cep_entries(store);
329 if cep_entries.is_empty() {
330 println!(" No CEP sessions to sync.");
331 } else {
332 match cloud_client::push_cep(&cep_entries) {
333 Ok(_) => println!(" CEP: synced"),
334 Err(e) if pro_gate_hit(&e) => return CloudSyncOutcome::Gated,
335 Err(e) => tracing::error!("CEP sync failed: {e}"),
336 }
337 }
338
339 println!("Syncing knowledge...");
340 let knowledge_entries = collect_knowledge_entries();
341 if knowledge_entries.is_empty() {
342 println!(" No knowledge to sync.");
343 } else {
344 match cloud_client::push_knowledge(&knowledge_entries) {
345 Ok(_) => println!(" Knowledge: synced"),
346 Err(e) if pro_gate_hit(&e) => return CloudSyncOutcome::Gated,
347 Err(e) => tracing::error!("Knowledge sync failed: {e}"),
348 }
349 }
350
351 println!("Syncing gotchas...");
352 let gotcha_entries = collect_gotcha_entries();
353 if gotcha_entries.is_empty() {
354 println!(" No gotchas to sync.");
355 } else {
356 match cloud_client::push_gotchas(&gotcha_entries) {
357 Ok(_) => println!(" Gotchas: synced"),
358 Err(e) if pro_gate_hit(&e) => return CloudSyncOutcome::Gated,
359 Err(e) => tracing::error!("Gotchas sync failed: {e}"),
360 }
361 }
362
363 println!("Syncing buddy...");
364 let buddy = core::buddy::BuddyState::compute();
365 let buddy_data = serde_json::to_value(&buddy).unwrap_or_default();
366 match cloud_client::push_buddy(&buddy_data) {
367 Ok(_) => println!(" Buddy: synced"),
368 Err(e) if pro_gate_hit(&e) => return CloudSyncOutcome::Gated,
369 Err(e) => tracing::error!("Buddy sync failed: {e}"),
370 }
371
372 println!("Syncing feedback thresholds...");
373 let feedback_entries = collect_feedback_entries();
374 if feedback_entries.is_empty() {
375 println!(" No feedback thresholds to sync.");
376 } else {
377 match cloud_client::push_feedback(&feedback_entries) {
378 Ok(_) => println!(" Feedback: synced"),
379 Err(e) if pro_gate_hit(&e) => return CloudSyncOutcome::Gated,
380 Err(e) => tracing::error!("Feedback sync failed: {e}"),
381 }
382 }
383
384 CloudSyncOutcome::Done
385}
386
387fn print_pro_upgrade_hint() {
391 super::upgrade_hint::hint_for("cloud_sync");
392}
393
394fn build_sync_entries(store: &core::stats::StatsStore) -> Vec<serde_json::Value> {
395 crate::cloud_sync::build_sync_entries(store)
396}
397
398fn collect_knowledge_entries() -> Vec<serde_json::Value> {
399 crate::cloud_sync::collect_knowledge_entries()
400}
401
402fn collect_command_entries(store: &core::stats::StatsStore) -> Vec<serde_json::Value> {
403 crate::cloud_sync::collect_command_entries(store)
404}
405
406fn collect_cep_entries(store: &core::stats::StatsStore) -> Vec<serde_json::Value> {
407 crate::cloud_sync::collect_cep_entries(store)
408}
409
410fn collect_gotcha_entries() -> Vec<serde_json::Value> {
411 crate::cloud_sync::collect_gotcha_entries()
412}
413
414fn collect_feedback_entries() -> Vec<serde_json::Value> {
415 crate::cloud_sync::collect_feedback_entries()
416}
417
418pub fn cmd_contribute() {
419 let mut entries = Vec::new();
420
421 if let Some(home) = dirs::home_dir() {
422 let mode_stats_path = home.join(".lean-ctx").join("mode_stats.json");
423 if let Ok(data) = std::fs::read_to_string(&mode_stats_path) {
424 if let Ok(predictor) = serde_json::from_str::<serde_json::Value>(&data) {
425 if let Some(history) = predictor["history"].as_object() {
426 for (_sig_key, outcomes) in history {
427 if let Some(arr) = outcomes.as_array() {
428 for outcome in arr.iter().rev().take(5) {
429 let ext = outcome["ext"].as_str().unwrap_or("unknown");
430 let mode = outcome["mode"].as_str().unwrap_or("full");
431 let tokens_in = outcome["tokens_in"].as_u64().unwrap_or(0);
432 let tokens_out = outcome["tokens_out"].as_u64().unwrap_or(0);
433 let ratio = if tokens_in > 0 {
434 1.0 - tokens_out as f64 / tokens_in as f64
435 } else {
436 0.0
437 };
438 let bucket = match tokens_in {
439 0..=500 => "0-500",
440 501..=2000 => "500-2k",
441 2001..=10000 => "2k-10k",
442 _ => "10k+",
443 };
444 entries.push(serde_json::json!({
445 "file_ext": format!(".{ext}"),
446 "size_bucket": bucket,
447 "best_mode": mode,
448 "compression_ratio": (ratio * 100.0).round() / 100.0,
449 }));
450 if entries.len() >= 500 {
451 break;
452 }
453 }
454 }
455 if entries.len() >= 500 {
456 break;
457 }
458 }
459 }
460 }
461 }
462 }
463
464 if entries.is_empty() {
465 let stats_data = core::stats::format_gain_json();
466 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&stats_data) {
467 let original = parsed["cep"]["total_tokens_original"].as_u64().unwrap_or(0);
468 let compressed = parsed["cep"]["total_tokens_compressed"]
469 .as_u64()
470 .unwrap_or(0);
471 let overall_ratio = if original > 0 {
472 1.0 - compressed as f64 / original as f64
473 } else {
474 0.0
475 };
476
477 if let Some(modes) = parsed["cep"]["modes"].as_object() {
478 let read_modes = ["full", "map", "signatures", "auto", "aggressive", "entropy"];
479 for (mode, count) in modes {
480 if !read_modes.contains(&mode.as_str()) || count.as_u64().unwrap_or(0) == 0 {
481 continue;
482 }
483 entries.push(serde_json::json!({
484 "file_ext": "mixed",
485 "size_bucket": "mixed",
486 "best_mode": mode,
487 "compression_ratio": (overall_ratio * 100.0).round() / 100.0,
488 }));
489 }
490 }
491 }
492 }
493
494 if entries.is_empty() {
495 println!("No compression data to contribute yet. Use lean-ctx for a while first.");
496 return;
497 }
498
499 println!("Contributing {} data points...", entries.len());
500 match cloud_client::contribute(&entries) {
501 Ok(msg) => println!("{msg}"),
502 Err(e) => {
503 tracing::error!("Contribute failed: {e}");
504 std::process::exit(1);
505 }
506 }
507}
508
509pub fn cmd_cloud(args: &[String]) {
510 let action = args.first().map_or("help", std::string::String::as_str);
511
512 match action {
513 "pull-models" => {
514 println!("Updating adaptive models...");
515 match cloud_client::pull_cloud_models() {
516 Ok(data) => {
517 let count = data
518 .get("models")
519 .and_then(|v| v.as_array())
520 .map_or(0, std::vec::Vec::len);
521
522 if let Err(e) = cloud_client::save_cloud_models(&data) {
523 tracing::warn!("Could not save models: {e}");
524 return;
525 }
526 println!("{count} adaptive models updated.");
527 if let Some(est) = data
528 .get("improvement_estimate")
529 .and_then(serde_json::Value::as_f64)
530 {
531 println!("Estimated compression improvement: +{:.0}%", est * 100.0);
532 }
533 }
534 Err(e) => {
535 tracing::error!("{e}");
536 std::process::exit(1);
537 }
538 }
539 }
540 "status" => cmd_cloud_status(),
541 "pull" => cmd_cloud_pull(),
542 "autosync" => cmd_cloud_autosync(args.get(1).map(String::as_str)),
543 "autoindex" => cmd_cloud_autoindex(args.get(1).map(String::as_str)),
544 "upgrade" | "subscribe" => cloud_upgrade(&args[1..]),
545 _ => {
546 println!("Usage: lean-ctx cloud <command>");
547 println!(" pull-models — Update adaptive compression models");
548 println!(" pull — Restore your Personal Cloud knowledge onto this machine");
549 println!(" autosync — on|off|status: daily background Personal Cloud push (Pro)");
550 println!(" autoindex — on|off|status: daily background hosted-index push (Pro)");
551 println!(" status — Show cloud connection status");
552 println!(
553 " upgrade — Subscribe to Pro (Personal Cloud) or Team \
554 [--plan pro|team|business] [--interval monthly|yearly]"
555 );
556 }
557 }
558}
559
560fn cmd_cloud_autosync(arg: Option<&str>) {
565 let mut config = core::config::Config::load();
566 match arg {
567 Some("on") => {
568 config.cloud.auto_sync = true;
569 if let Err(e) = config.save() {
570 tracing::error!("Could not save config: {e}");
571 std::process::exit(1);
572 }
573 println!("Auto-sync enabled — your Personal Cloud (knowledge, commands, CEP, gotchas, buddy, feedback)");
574 println!(
575 "is pushed silently once per day at session end. Requires Pro and an active login."
576 );
577 if !cloud_client::is_logged_in() {
578 println!("Note: you are not logged in yet. Run: lean-ctx login <email>");
579 }
580 }
581 Some("off") => {
582 config.cloud.auto_sync = false;
583 if let Err(e) = config.save() {
584 tracing::error!("Could not save config: {e}");
585 std::process::exit(1);
586 }
587 println!("Auto-sync disabled. Manual sync stays available via: lean-ctx sync");
588 }
589 Some("status") | None => {
590 let state = if config.cloud.auto_sync { "on" } else { "off" };
591 println!("Auto-sync: {state}");
592 match config.cloud.last_auto_sync.as_deref() {
593 Some(date) => println!("Last auto-sync: {date}"),
594 None => println!("Last auto-sync: never"),
595 }
596 if !config.cloud.auto_sync {
597 println!("Enable with: lean-ctx cloud autosync on");
598 }
599 }
600 Some(other) => {
601 tracing::error!("Unknown autosync action: {other}. Use on|off|status.");
602 std::process::exit(1);
603 }
604 }
605}
606
607fn cmd_cloud_autoindex(arg: Option<&str>) {
611 let mut config = core::config::Config::load();
612 match arg {
613 Some("on") => {
614 config.cloud.auto_index = true;
615 if let Err(e) = config.save() {
616 tracing::error!("Could not save config: {e}");
617 std::process::exit(1);
618 }
619 println!(
620 "Auto-index enabled — this project's encrypted retrieval index is pushed \
621 silently once per day when it changes. Requires Pro and an active login."
622 );
623 if !cloud_client::is_logged_in() {
624 println!("Note: you are not logged in yet. Run: lean-ctx login <email>");
625 }
626 }
627 Some("off") => {
628 config.cloud.auto_index = false;
629 if let Err(e) = config.save() {
630 tracing::error!("Could not save config: {e}");
631 std::process::exit(1);
632 }
633 println!(
634 "Auto-index disabled. Manual push stays available via: lean-ctx sync index push"
635 );
636 }
637 Some("status") | None => {
638 let state = if config.cloud.auto_index { "on" } else { "off" };
639 println!("Auto-index: {state}");
640 let root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
641 let hash = core::index_namespace::namespace_hash(&root);
642 match config.cloud.last_index_push.get(&hash) {
643 Some(date) => println!("Last push (this project): {date}"),
644 None => println!("Last push (this project): never"),
645 }
646 if !config.cloud.auto_index {
647 println!("Enable with: lean-ctx cloud autoindex on");
648 }
649 }
650 Some(other) => {
651 tracing::error!("Unknown autoindex action: {other}. Use on|off|status.");
652 std::process::exit(1);
653 }
654 }
655}
656
657fn cmd_cloud_status() {
660 if !cloud_client::is_logged_in() {
661 println!("Not connected to LeanCTX Cloud.");
662 println!("Get started: lean-ctx login <email>");
663 return;
664 }
665 let email = cloud_client::account_email().unwrap_or_default();
666 println!("Connected to LeanCTX Cloud as {email}.");
667
668 let d = match cloud_client::fetch_account_cloud() {
669 Ok(d) => d,
670 Err(e) => {
671 tracing::warn!("Could not fetch cloud status: {e}");
672 return;
673 }
674 };
675
676 let plan = d.get("plan").and_then(|v| v.as_str()).unwrap_or("free");
677 println!("Plan: {plan}");
678
679 if d.get("cloud_sync").and_then(serde_json::Value::as_bool) != Some(true) {
680 println!("Personal Cloud sync: locked on this plan.");
681 super::upgrade_hint::hint_for("cloud_sync");
682 return;
683 }
684
685 match d.get("last_synced_at").and_then(|v| v.as_str()) {
686 Some(ts) => println!("Last synced: {ts}"),
687 None => println!("Last synced: never — run `lean-ctx sync` on this machine."),
688 }
689
690 const BUCKETS: [(&str, &str, &str); 6] = [
692 ("knowledge", "Knowledge & memory", "facts"),
693 ("commands", "Learned shell patterns", "patterns"),
694 ("cep", "CEP score history", "snapshots"),
695 ("gain", "GAIN score history", "snapshots"),
696 ("gotchas", "Gotchas", "fixes"),
697 ("feedback", "Feedback thresholds", "languages"),
698 ];
699 println!("\nSynced to your Personal Cloud:");
700 for (key, label, unit) in BUCKETS {
701 let count = d
702 .get("buckets")
703 .and_then(|b| b.get(key))
704 .and_then(|b| b.get("count"))
705 .and_then(serde_json::Value::as_i64)
706 .unwrap_or(0);
707 if count > 0 {
708 println!(" {label:<24} {count} {unit}");
709 } else {
710 println!(" {label:<24} —");
711 }
712 }
713
714 if let Some(buddy) = d
715 .get("buddy")
716 .filter(|b| b.get("present").and_then(serde_json::Value::as_bool) == Some(true))
717 {
718 let name = buddy
719 .get("name")
720 .and_then(|v| v.as_str())
721 .unwrap_or("Buddy");
722 let level = buddy
723 .get("level")
724 .and_then(serde_json::Value::as_i64)
725 .unwrap_or(1);
726 println!(" {:<24} {name} (level {level})", "Buddy");
727 }
728
729 if let Some(totals) = d.get("usage").and_then(|u| u.get("totals")) {
730 let tokens = totals
731 .get("tokens_saved")
732 .and_then(serde_json::Value::as_i64)
733 .unwrap_or(0);
734 let sessions = totals
735 .get("sessions")
736 .and_then(serde_json::Value::as_i64)
737 .unwrap_or(0);
738 if sessions > 0 {
739 println!("\nAll-time: {tokens} tokens saved across {sessions} synced sessions.");
740 }
741 }
742 println!("\nFull dashboard: https://leanctx.com/account/cloud/");
743}
744
745fn cmd_cloud_pull() {
752 if !cloud_client::is_logged_in() {
753 eprintln!("Not logged in. Run: lean-ctx login <email>");
754 std::process::exit(1);
755 }
756
757 let project_root = std::env::current_dir()
758 .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string());
759
760 println!("Pulling knowledge from LeanCTX Cloud...");
761 let entries = match cloud_client::pull_knowledge() {
762 Ok(e) => e,
763 Err(e) if pro_gate_hit(&e) => {
764 print_pro_upgrade_hint();
765 std::process::exit(1);
766 }
767 Err(e) => {
768 tracing::error!("Pull failed: {e}");
769 std::process::exit(1);
770 }
771 };
772
773 if entries.is_empty() {
774 println!(
775 "No cloud knowledge to restore yet. Run `lean-ctx sync` on another machine first."
776 );
777 return;
778 }
779
780 let facts = match parse_pulled_knowledge(&entries) {
781 Ok(f) => f,
782 Err(e) => {
783 tracing::error!("Could not parse pulled knowledge: {e}");
784 std::process::exit(1);
785 }
786 };
787
788 let policy = match crate::tools::knowledge_shared::load_policy_or_error() {
789 Ok(p) => p,
790 Err(e) => {
791 eprintln!("{e}");
792 std::process::exit(1);
793 }
794 };
795
796 let mut knowledge = core::knowledge::ProjectKnowledge::load_or_create(&project_root);
797 let result = knowledge.import_facts(
798 facts,
799 core::knowledge::ImportMerge::SkipExisting,
800 "cloud-pull",
801 &policy,
802 );
803
804 match knowledge.save() {
805 Ok(()) => {
806 println!(
807 " Knowledge: {} restored, {} already present (into {project_root})",
808 result.added, result.skipped
809 );
810 println!("Pull complete.");
811 }
812 Err(e) => {
813 tracing::error!("Restored {} facts but save failed: {e}", result.added);
814 std::process::exit(1);
815 }
816 }
817}
818
819fn parse_pulled_knowledge(
824 entries: &[serde_json::Value],
825) -> Result<Vec<core::knowledge::KnowledgeFact>, String> {
826 let str_field = |e: &serde_json::Value, k: &str| {
827 e.get(k)
828 .and_then(serde_json::Value::as_str)
829 .unwrap_or_default()
830 .to_string()
831 };
832 let simple: Vec<serde_json::Value> = entries
833 .iter()
834 .map(|e| {
835 serde_json::json!({
836 "category": str_field(e, "category"),
837 "key": str_field(e, "key"),
838 "value": str_field(e, "value"),
839 "source": e.get("updated_by").and_then(serde_json::Value::as_str),
840 "timestamp": e.get("updated_at").and_then(serde_json::Value::as_str),
841 })
842 })
843 .collect();
844 let data = serde_json::to_string(&simple).map_err(|e| e.to_string())?;
845 core::knowledge::parse_import_data(&data)
846}
847
848fn cloud_upgrade(args: &[String]) {
852 if !cloud_client::is_logged_in() {
853 eprintln!("Not logged in. Run: lean-ctx login <email>");
854 std::process::exit(1);
855 }
856 let (plan, interval) = match parse_upgrade_args(args) {
857 Ok(pi) => pi,
858 Err(e) => {
859 eprintln!("{e}");
860 eprintln!(
861 "Usage: lean-ctx cloud upgrade [--plan pro|team|business] [--interval monthly|yearly]"
862 );
863 std::process::exit(1);
864 }
865 };
866
867 println!("Starting {plan} checkout ({interval})...");
868 match cloud_client::start_checkout(&plan, &interval) {
869 Ok(url) => {
870 println!();
871 println!("Open this link to complete your subscription:");
872 println!(" {url}");
873 }
874 Err(e) => {
875 tracing::error!("Could not start checkout: {e}");
876 std::process::exit(1);
877 }
878 }
879}
880
881fn parse_upgrade_args(args: &[String]) -> Result<(String, String), String> {
886 let mut plan = "pro".to_string();
887 let mut interval = "monthly".to_string();
888 let mut i = 0;
889 while i < args.len() {
890 match args[i].as_str() {
891 "--plan" => {
892 i += 1;
893 let v = args
894 .get(i)
895 .ok_or("--plan needs a value (pro|team|business)")?;
896 if !matches!(v.as_str(), "pro" | "team" | "business") {
897 return Err(format!("unknown plan '{v}' (use pro, team or business)"));
898 }
899 plan.clone_from(v);
900 }
901 "--interval" => {
902 i += 1;
903 let v = args
904 .get(i)
905 .ok_or("--interval needs a value (monthly|yearly)")?;
906 if !matches!(v.as_str(), "monthly" | "yearly") {
907 return Err(format!("unknown interval '{v}' (use monthly or yearly)"));
908 }
909 interval.clone_from(v);
910 }
911 "--yearly" => interval = "yearly".to_string(),
912 "--monthly" => interval = "monthly".to_string(),
913 other => return Err(format!("unknown option '{other}'")),
914 }
915 i += 1;
916 }
917 Ok((plan, interval))
918}
919
920pub fn cmd_gotchas(args: &[String]) {
921 let action = args.first().map_or("list", std::string::String::as_str);
922 let project_root = std::env::current_dir()
923 .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string());
924
925 match action {
926 "list" | "ls" => {
927 let store = core::gotcha_tracker::GotchaStore::load(&project_root);
928 println!("{}", store.format_list());
929 }
930 "clear" => {
931 let mut store = core::gotcha_tracker::GotchaStore::load(&project_root);
932 let count = store.gotchas.len();
933 store.clear();
934 let _ = store.save(&project_root);
935 println!("Cleared {count} gotchas.");
936 }
937 "export" => {
938 let store = core::gotcha_tracker::GotchaStore::load(&project_root);
939 match serde_json::to_string_pretty(&store.gotchas) {
940 Ok(json) => println!("{json}"),
941 Err(e) => tracing::error!("Export failed: {e}"),
942 }
943 }
944 "stats" => {
945 let store = core::gotcha_tracker::GotchaStore::load(&project_root);
946 println!("Bug Memory Stats:");
947 println!(" Active gotchas: {}", store.gotchas.len());
948 println!(
949 " Errors detected: {}",
950 store.stats.total_errors_detected
951 );
952 println!(
953 " Fixes correlated: {}",
954 store.stats.total_fixes_correlated
955 );
956 println!(" Bugs prevented: {}", store.stats.total_prevented);
957 println!(" Promoted to knowledge: {}", store.stats.gotchas_promoted);
958 println!(" Decayed/archived: {}", store.stats.gotchas_decayed);
959 println!(" Session logs: {}", store.error_log.len());
960 }
961 _ => {
962 println!("Usage: lean-ctx gotchas [list|clear|export|stats]");
963 }
964 }
965}
966
967pub fn cmd_buddy(args: &[String]) {
968 let cfg = core::config::Config::load();
969 if !cfg.buddy_enabled {
970 println!("Buddy is disabled. Enable with: lean-ctx config buddy_enabled true");
971 return;
972 }
973
974 let action = args.first().map_or("show", std::string::String::as_str);
975 let buddy = core::buddy::BuddyState::compute();
976 let theme = core::theme::load_theme(&cfg.theme);
977
978 match action {
979 "show" | "status" | "stats" => {
980 println!("{}", core::buddy::format_buddy_full(&buddy, &theme));
981 }
982 "ascii" => {
983 for line in &buddy.ascii_art {
984 println!(" {line}");
985 }
986 }
987 "json" => match serde_json::to_string_pretty(&buddy) {
988 Ok(json) => println!("{json}"),
989 Err(e) => tracing::error!("JSON error: {e}"),
990 },
991 _ => {
992 println!("Usage: lean-ctx buddy [show|stats|ascii|json]");
993 }
994 }
995}
996
997pub fn cmd_upgrade() {
998 println!("'upgrade' has been renamed to 'update'. Running 'lean-ctx update' instead.\n");
999 core::updater::run(&[]);
1000}
1001
1002#[cfg(test)]
1003mod tests {
1004 use super::*;
1005
1006 #[test]
1007 fn pro_gate_hit_detects_402_only() {
1008 assert!(pro_gate_hit(
1010 "Push failed: http status 402 Payment Required"
1011 ));
1012 assert!(!pro_gate_hit("Push failed: http status 500"));
1014 assert!(!pro_gate_hit("Push failed: connection refused"));
1015 assert!(!pro_gate_hit("401 Unauthorized"));
1016 }
1017
1018 fn s(args: &[&str]) -> Vec<String> {
1019 args.iter().map(|a| (*a).to_string()).collect()
1020 }
1021
1022 #[test]
1023 fn upgrade_args_default_to_pro_monthly() {
1024 assert_eq!(
1025 parse_upgrade_args(&[]).unwrap(),
1026 ("pro".to_string(), "monthly".to_string())
1027 );
1028 }
1029
1030 #[test]
1031 fn upgrade_args_accept_team_and_yearly() {
1032 assert_eq!(
1033 parse_upgrade_args(&s(&["--plan", "team", "--interval", "yearly"])).unwrap(),
1034 ("team".to_string(), "yearly".to_string())
1035 );
1036 assert_eq!(
1038 parse_upgrade_args(&s(&["--yearly"])).unwrap(),
1039 ("pro".to_string(), "yearly".to_string())
1040 );
1041 assert_eq!(
1043 parse_upgrade_args(&s(&["--plan", "business"])).unwrap(),
1044 ("business".to_string(), "monthly".to_string())
1045 );
1046 }
1047
1048 #[test]
1049 fn upgrade_args_reject_unknown_values() {
1050 assert!(parse_upgrade_args(&s(&["--plan", "enterprise"])).is_err());
1052 assert!(parse_upgrade_args(&s(&["--interval", "weekly"])).is_err());
1053 assert!(parse_upgrade_args(&s(&["--plan"])).is_err());
1054 assert!(parse_upgrade_args(&s(&["--bogus"])).is_err());
1055 }
1056
1057 #[test]
1058 fn parse_pulled_knowledge_maps_server_rows() {
1059 let rows = vec![
1063 serde_json::json!({
1064 "category": "architecture",
1065 "key": "db",
1066 "value": "PostgreSQL 16 with pgvector",
1067 "updated_by": "me@example.com",
1068 "updated_at": "2026-01-02T03:04:05Z"
1069 }),
1070 serde_json::json!({
1071 "category": "decision",
1072 "key": "auth",
1073 "value": "JWT RS256"
1074 }),
1075 ];
1076 let facts = parse_pulled_knowledge(&rows).expect("rows must parse");
1077 assert_eq!(facts.len(), 2);
1078 assert_eq!(facts[0].category, "architecture");
1079 assert_eq!(facts[0].key, "db");
1080 assert_eq!(facts[0].value, "PostgreSQL 16 with pgvector");
1081 assert_eq!(facts[0].source_session, "me@example.com");
1082 assert_eq!(facts[1].value, "JWT RS256");
1084 }
1085
1086 #[test]
1087 fn parse_pulled_knowledge_handles_empty() {
1088 assert!(parse_pulled_knowledge(&[]).unwrap().is_empty());
1089 }
1090}