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 Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
424 let mode_stats_path = data_dir.join("mode_stats.json");
425 if let Ok(data) = std::fs::read_to_string(&mode_stats_path)
426 && let Ok(predictor) = serde_json::from_str::<serde_json::Value>(&data)
427 && let Some(history) = predictor["history"].as_object()
428 {
429 for (_sig_key, outcomes) in history {
430 if let Some(arr) = outcomes.as_array() {
431 for outcome in arr.iter().rev().take(5) {
432 let ext = outcome["ext"].as_str().unwrap_or("unknown");
433 let mode = outcome["mode"].as_str().unwrap_or("full");
434 let tokens_in = outcome["tokens_in"].as_u64().unwrap_or(0);
435 let tokens_out = outcome["tokens_out"].as_u64().unwrap_or(0);
436 let ratio = if tokens_in > 0 {
437 1.0 - tokens_out as f64 / tokens_in as f64
438 } else {
439 0.0
440 };
441 let bucket = match tokens_in {
442 0..=500 => "0-500",
443 501..=2000 => "500-2k",
444 2001..=10000 => "2k-10k",
445 _ => "10k+",
446 };
447 entries.push(serde_json::json!({
448 "file_ext": format!(".{ext}"),
449 "size_bucket": bucket,
450 "best_mode": mode,
451 "compression_ratio": (ratio * 100.0).round() / 100.0,
452 }));
453 if entries.len() >= 500 {
454 break;
455 }
456 }
457 }
458 if entries.len() >= 500 {
459 break;
460 }
461 }
462 }
463 }
464
465 if entries.is_empty() {
466 let stats_data = core::stats::format_gain_json();
467 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&stats_data) {
468 let original = parsed["cep"]["total_tokens_original"].as_u64().unwrap_or(0);
469 let compressed = parsed["cep"]["total_tokens_compressed"]
470 .as_u64()
471 .unwrap_or(0);
472 let overall_ratio = if original > 0 {
473 1.0 - compressed as f64 / original as f64
474 } else {
475 0.0
476 };
477
478 if let Some(modes) = parsed["cep"]["modes"].as_object() {
479 let read_modes = ["full", "map", "signatures", "auto", "aggressive", "entropy"];
480 for (mode, count) in modes {
481 if !read_modes.contains(&mode.as_str()) || count.as_u64().unwrap_or(0) == 0 {
482 continue;
483 }
484 entries.push(serde_json::json!({
485 "file_ext": "mixed",
486 "size_bucket": "mixed",
487 "best_mode": mode,
488 "compression_ratio": (overall_ratio * 100.0).round() / 100.0,
489 }));
490 }
491 }
492 }
493 }
494
495 if entries.is_empty() {
496 println!("No compression data to contribute yet. Use lean-ctx for a while first.");
497 return;
498 }
499
500 println!("Contributing {} data points...", entries.len());
501 match cloud_client::contribute(&entries) {
502 Ok(msg) => println!("{msg}"),
503 Err(e) => {
504 tracing::error!("Contribute failed: {e}");
505 std::process::exit(1);
506 }
507 }
508}
509
510pub fn cmd_cloud(args: &[String]) {
511 let action = args.first().map_or("help", std::string::String::as_str);
512
513 match action {
514 "pull-models" => {
515 println!("Updating adaptive models...");
516 match cloud_client::pull_cloud_models() {
517 Ok(data) => {
518 let count = data
519 .get("models")
520 .and_then(|v| v.as_array())
521 .map_or(0, std::vec::Vec::len);
522
523 if let Err(e) = cloud_client::save_cloud_models(&data) {
524 tracing::warn!("Could not save models: {e}");
525 return;
526 }
527 println!("{count} adaptive models updated.");
528 if let Some(est) = data
529 .get("improvement_estimate")
530 .and_then(serde_json::Value::as_f64)
531 {
532 println!("Estimated compression improvement: +{:.0}%", est * 100.0);
533 }
534 }
535 Err(e) => {
536 tracing::error!("{e}");
537 std::process::exit(1);
538 }
539 }
540 }
541 "status" => cmd_cloud_status(),
542 "pull" => cmd_cloud_pull(),
543 "autosync" => cmd_cloud_autosync(args.get(1).map(String::as_str)),
544 "autoindex" => cmd_cloud_autoindex(args.get(1).map(String::as_str)),
545 "upgrade" | "subscribe" => cloud_upgrade(&args[1..]),
546 _ => {
547 println!("Usage: lean-ctx cloud <command>");
548 println!(" pull-models — Update adaptive compression models");
549 println!(" pull — Restore your Personal Cloud knowledge onto this machine");
550 println!(" autosync — on|off|status: daily background Personal Cloud push (Pro)");
551 println!(" autoindex — on|off|status: daily background hosted-index push (Pro)");
552 println!(" status — Show cloud connection status");
553 println!(
554 " upgrade — Subscribe to Pro (Personal Cloud) or Team \
555 [--plan pro|team] [--interval monthly|yearly]"
556 );
557 }
558 }
559}
560
561fn cmd_cloud_autosync(arg: Option<&str>) {
566 let mut config = core::config::Config::load_global();
567 match arg {
568 Some("on") => {
569 config.cloud.auto_sync = true;
570 if let Err(e) = config.save() {
571 tracing::error!("Could not save config: {e}");
572 std::process::exit(1);
573 }
574 println!(
575 "Auto-sync enabled — your Personal Cloud (knowledge, commands, CEP, gotchas, buddy, feedback)"
576 );
577 println!(
578 "is pushed silently once per day at session end. Requires Pro and an active login."
579 );
580 if !cloud_client::is_logged_in() {
581 println!("Note: you are not logged in yet. Run: lean-ctx login <email>");
582 }
583 }
584 Some("off") => {
585 config.cloud.auto_sync = false;
586 if let Err(e) = config.save() {
587 tracing::error!("Could not save config: {e}");
588 std::process::exit(1);
589 }
590 println!("Auto-sync disabled. Manual sync stays available via: lean-ctx sync");
591 }
592 Some("status") | None => {
593 let state = if config.cloud.auto_sync { "on" } else { "off" };
594 println!("Auto-sync: {state}");
595 match config.cloud.last_auto_sync.as_deref() {
596 Some(date) => println!("Last auto-sync: {date}"),
597 None => println!("Last auto-sync: never"),
598 }
599 if !config.cloud.auto_sync {
600 println!("Enable with: lean-ctx cloud autosync on");
601 }
602 }
603 Some(other) => {
604 tracing::error!("Unknown autosync action: {other}. Use on|off|status.");
605 std::process::exit(1);
606 }
607 }
608}
609
610fn cmd_cloud_autoindex(arg: Option<&str>) {
614 let mut config = core::config::Config::load_global();
615 match arg {
616 Some("on") => {
617 config.cloud.auto_index = true;
618 if let Err(e) = config.save() {
619 tracing::error!("Could not save config: {e}");
620 std::process::exit(1);
621 }
622 println!(
623 "Auto-index enabled — this project's encrypted retrieval index is pushed \
624 silently once per day when it changes. Requires Pro and an active login."
625 );
626 if !cloud_client::is_logged_in() {
627 println!("Note: you are not logged in yet. Run: lean-ctx login <email>");
628 }
629 }
630 Some("off") => {
631 config.cloud.auto_index = false;
632 if let Err(e) = config.save() {
633 tracing::error!("Could not save config: {e}");
634 std::process::exit(1);
635 }
636 println!(
637 "Auto-index disabled. Manual push stays available via: lean-ctx sync index push"
638 );
639 }
640 Some("status") | None => {
641 let state = if config.cloud.auto_index { "on" } else { "off" };
642 println!("Auto-index: {state}");
643 let root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
644 let hash = core::index_namespace::namespace_hash(&root);
645 match config.cloud.last_index_push.get(&hash) {
646 Some(date) => println!("Last push (this project): {date}"),
647 None => println!("Last push (this project): never"),
648 }
649 if !config.cloud.auto_index {
650 println!("Enable with: lean-ctx cloud autoindex on");
651 }
652 }
653 Some(other) => {
654 tracing::error!("Unknown autoindex action: {other}. Use on|off|status.");
655 std::process::exit(1);
656 }
657 }
658}
659
660fn cmd_cloud_status() {
663 if !cloud_client::is_logged_in() {
664 println!("Not connected to LeanCTX Cloud.");
665 println!("Get started: lean-ctx login <email>");
666 return;
667 }
668 let email = cloud_client::account_email().unwrap_or_default();
669 println!("Connected to LeanCTX Cloud as {email}.");
670
671 let d = match cloud_client::fetch_account_cloud() {
672 Ok(d) => d,
673 Err(e) => {
674 tracing::warn!("Could not fetch cloud status: {e}");
675 return;
676 }
677 };
678
679 let plan = d.get("plan").and_then(|v| v.as_str()).unwrap_or("free");
680 println!("Plan: {plan}");
681
682 if d.get("cloud_sync").and_then(serde_json::Value::as_bool) != Some(true) {
683 println!("Personal Cloud sync: locked on this plan.");
684 super::upgrade_hint::hint_for("cloud_sync");
685 return;
686 }
687
688 match d.get("last_synced_at").and_then(|v| v.as_str()) {
689 Some(ts) => println!("Last synced: {ts}"),
690 None => println!("Last synced: never — run `lean-ctx sync` on this machine."),
691 }
692
693 const BUCKETS: [(&str, &str, &str); 6] = [
695 ("knowledge", "Knowledge & memory", "facts"),
696 ("commands", "Learned shell patterns", "patterns"),
697 ("cep", "CEP score history", "snapshots"),
698 ("gain", "GAIN score history", "snapshots"),
699 ("gotchas", "Gotchas", "fixes"),
700 ("feedback", "Feedback thresholds", "languages"),
701 ];
702 println!("\nSynced to your Personal Cloud:");
703 for (key, label, unit) in BUCKETS {
704 let count = d
705 .get("buckets")
706 .and_then(|b| b.get(key))
707 .and_then(|b| b.get("count"))
708 .and_then(serde_json::Value::as_i64)
709 .unwrap_or(0);
710 if count > 0 {
711 println!(" {label:<24} {count} {unit}");
712 } else {
713 println!(" {label:<24} —");
714 }
715 }
716
717 if let Some(buddy) = d
718 .get("buddy")
719 .filter(|b| b.get("present").and_then(serde_json::Value::as_bool) == Some(true))
720 {
721 let name = buddy
722 .get("name")
723 .and_then(|v| v.as_str())
724 .unwrap_or("Buddy");
725 let level = buddy
726 .get("level")
727 .and_then(serde_json::Value::as_i64)
728 .unwrap_or(1);
729 println!(" {:<24} {name} (level {level})", "Buddy");
730 }
731
732 if let Some(totals) = d.get("usage").and_then(|u| u.get("totals")) {
733 let tokens = totals
734 .get("tokens_saved")
735 .and_then(serde_json::Value::as_i64)
736 .unwrap_or(0);
737 let sessions = totals
738 .get("sessions")
739 .and_then(serde_json::Value::as_i64)
740 .unwrap_or(0);
741 if sessions > 0 {
742 println!("\nAll-time: {tokens} tokens saved across {sessions} synced sessions.");
743 }
744 }
745 println!("\nFull dashboard: https://leanctx.com/account/cloud/");
746}
747
748fn cmd_cloud_pull() {
755 if !cloud_client::is_logged_in() {
756 eprintln!("Not logged in. Run: lean-ctx login <email>");
757 std::process::exit(1);
758 }
759
760 let project_root = std::env::current_dir()
761 .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string());
762
763 println!("Pulling knowledge from LeanCTX Cloud...");
764 let entries = match cloud_client::pull_knowledge() {
765 Ok(e) => e,
766 Err(e) if pro_gate_hit(&e) => {
767 print_pro_upgrade_hint();
768 std::process::exit(1);
769 }
770 Err(e) => {
771 tracing::error!("Pull failed: {e}");
772 std::process::exit(1);
773 }
774 };
775
776 if entries.is_empty() {
777 println!(
778 "No cloud knowledge to restore yet. Run `lean-ctx sync` on another machine first."
779 );
780 return;
781 }
782
783 let facts = match parse_pulled_knowledge(&entries) {
784 Ok(f) => f,
785 Err(e) => {
786 tracing::error!("Could not parse pulled knowledge: {e}");
787 std::process::exit(1);
788 }
789 };
790
791 let policy = match crate::tools::knowledge_shared::load_policy_or_error() {
792 Ok(p) => p,
793 Err(e) => {
794 eprintln!("{e}");
795 std::process::exit(1);
796 }
797 };
798
799 match core::knowledge::ProjectKnowledge::mutate_locked(&project_root, |knowledge| {
803 knowledge.import_facts(
804 facts,
805 core::knowledge::ImportMerge::SkipExisting,
806 "cloud-pull",
807 &policy,
808 )
809 }) {
810 Ok((_, result)) => {
811 println!(
812 " Knowledge: {} restored, {} already present (into {project_root})",
813 result.added, result.skipped
814 );
815 println!("Pull complete.");
816 }
817 Err(e) => {
818 tracing::error!("Knowledge restore failed: {e}");
819 std::process::exit(1);
820 }
821 }
822}
823
824fn parse_pulled_knowledge(
829 entries: &[serde_json::Value],
830) -> Result<Vec<core::knowledge::KnowledgeFact>, String> {
831 let str_field = |e: &serde_json::Value, k: &str| {
832 e.get(k)
833 .and_then(serde_json::Value::as_str)
834 .unwrap_or_default()
835 .to_string()
836 };
837 let simple: Vec<serde_json::Value> = entries
838 .iter()
839 .map(|e| {
840 serde_json::json!({
841 "category": str_field(e, "category"),
842 "key": str_field(e, "key"),
843 "value": str_field(e, "value"),
844 "source": e.get("updated_by").and_then(serde_json::Value::as_str),
845 "timestamp": e.get("updated_at").and_then(serde_json::Value::as_str),
846 })
847 })
848 .collect();
849 let data = serde_json::to_string(&simple).map_err(|e| e.to_string())?;
850 core::knowledge::parse_import_data(&data)
851}
852
853fn cloud_upgrade(args: &[String]) {
857 if !cloud_client::is_logged_in() {
858 eprintln!("Not logged in. Run: lean-ctx login <email>");
859 std::process::exit(1);
860 }
861 let (plan, interval) = match parse_upgrade_args(args) {
862 Ok(pi) => pi,
863 Err(e) => {
864 eprintln!("{e}");
865 eprintln!(
866 "Usage: lean-ctx cloud upgrade [--plan pro|team] [--interval monthly|yearly]"
867 );
868 std::process::exit(1);
869 }
870 };
871
872 println!("Starting {plan} checkout ({interval})...");
873 match cloud_client::start_checkout(&plan, &interval) {
874 Ok(url) => {
875 println!();
876 println!("Open this link to complete your subscription:");
877 println!(" {url}");
878 }
879 Err(e) => {
880 tracing::error!("Could not start checkout: {e}");
881 std::process::exit(1);
882 }
883 }
884}
885
886fn parse_upgrade_args(args: &[String]) -> Result<(String, String), String> {
891 let mut plan = "pro".to_string();
892 let mut interval = "monthly".to_string();
893 let mut i = 0;
894 while i < args.len() {
895 match args[i].as_str() {
896 "--plan" => {
897 i += 1;
898 let v = args.get(i).ok_or("--plan needs a value (pro|team)")?;
899 if !matches!(v.as_str(), "pro" | "team") {
900 return Err(format!("unknown plan '{v}' (use pro or team)"));
901 }
902 plan.clone_from(v);
903 }
904 "--interval" => {
905 i += 1;
906 let v = args
907 .get(i)
908 .ok_or("--interval needs a value (monthly|yearly)")?;
909 if !matches!(v.as_str(), "monthly" | "yearly") {
910 return Err(format!("unknown interval '{v}' (use monthly or yearly)"));
911 }
912 interval.clone_from(v);
913 }
914 "--yearly" => interval = "yearly".to_string(),
915 "--monthly" => interval = "monthly".to_string(),
916 other => return Err(format!("unknown option '{other}'")),
917 }
918 i += 1;
919 }
920 Ok((plan, interval))
921}
922
923pub fn cmd_gotchas(args: &[String]) {
924 let action = args.first().map_or("list", std::string::String::as_str);
925 let project_root = std::env::current_dir()
926 .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string());
927
928 match action {
929 "list" | "ls" => {
930 let store = core::gotcha_tracker::GotchaStore::load(&project_root);
931 println!("{}", store.format_list());
932 }
933 "clear" => {
934 let mut store = core::gotcha_tracker::GotchaStore::load(&project_root);
935 let count = store.gotchas.len();
936 store.clear();
937 let _ = store.save(&project_root);
938 println!("Cleared {count} gotchas.");
939 }
940 "export" => {
941 let store = core::gotcha_tracker::GotchaStore::load(&project_root);
942 match serde_json::to_string_pretty(&store.gotchas) {
943 Ok(json) => println!("{json}"),
944 Err(e) => tracing::error!("Export failed: {e}"),
945 }
946 }
947 "stats" => {
948 let store = core::gotcha_tracker::GotchaStore::load(&project_root);
949 println!("Bug Memory Stats:");
950 println!(" Active gotchas: {}", store.gotchas.len());
951 println!(
952 " Errors detected: {}",
953 store.stats.total_errors_detected
954 );
955 println!(
956 " Fixes correlated: {}",
957 store.stats.total_fixes_correlated
958 );
959 println!(" Bugs prevented: {}", store.stats.total_prevented);
960 println!(" Promoted to knowledge: {}", store.stats.gotchas_promoted);
961 println!(" Decayed/archived: {}", store.stats.gotchas_decayed);
962 println!(" Session logs: {}", store.error_log.len());
963 }
964 "reflect" | "ledger" => {
965 let store = core::gotcha_tracker::GotchaStore::load(&project_root);
966 println!("{}", core::gotcha_tracker::format_ledger(&store));
967 }
968 _ => {
969 println!("Usage: lean-ctx gotchas [list|clear|export|stats|reflect]");
970 }
971 }
972}
973
974pub fn cmd_buddy(args: &[String]) {
975 let cfg = core::config::Config::load();
976 if !cfg.buddy_enabled {
977 println!("Buddy is disabled. Enable with: lean-ctx config buddy_enabled true");
978 return;
979 }
980
981 let action = args.first().map_or("show", std::string::String::as_str);
982 let buddy = core::buddy::BuddyState::compute();
983 let theme = core::theme::load_theme(&cfg.theme);
984
985 match action {
986 "show" | "status" | "stats" => {
987 println!("{}", core::buddy::format_buddy_full(&buddy, &theme));
988 }
989 "ascii" => {
990 for line in &buddy.ascii_art {
991 println!(" {line}");
992 }
993 }
994 "json" => match serde_json::to_string_pretty(&buddy) {
995 Ok(json) => println!("{json}"),
996 Err(e) => tracing::error!("JSON error: {e}"),
997 },
998 _ => {
999 println!("Usage: lean-ctx buddy [show|stats|ascii|json]");
1000 }
1001 }
1002}
1003
1004pub fn cmd_upgrade() {
1005 println!("'upgrade' has been renamed to 'update'. Running 'lean-ctx update' instead.\n");
1006 core::updater::run(&[]);
1007}
1008
1009#[cfg(test)]
1010mod tests {
1011 use super::*;
1012
1013 #[test]
1014 fn pro_gate_hit_detects_402_only() {
1015 assert!(pro_gate_hit(
1017 "Push failed: http status 402 Payment Required"
1018 ));
1019 assert!(!pro_gate_hit("Push failed: http status 500"));
1021 assert!(!pro_gate_hit("Push failed: connection refused"));
1022 assert!(!pro_gate_hit("401 Unauthorized"));
1023 }
1024
1025 fn s(args: &[&str]) -> Vec<String> {
1026 args.iter().map(|a| (*a).to_string()).collect()
1027 }
1028
1029 #[test]
1030 fn upgrade_args_default_to_pro_monthly() {
1031 assert_eq!(
1032 parse_upgrade_args(&[]).unwrap(),
1033 ("pro".to_string(), "monthly".to_string())
1034 );
1035 }
1036
1037 #[test]
1038 fn upgrade_args_accept_team_and_yearly() {
1039 assert_eq!(
1040 parse_upgrade_args(&s(&["--plan", "team", "--interval", "yearly"])).unwrap(),
1041 ("team".to_string(), "yearly".to_string())
1042 );
1043 assert_eq!(
1045 parse_upgrade_args(&s(&["--yearly"])).unwrap(),
1046 ("pro".to_string(), "yearly".to_string())
1047 );
1048 assert!(parse_upgrade_args(&s(&["--plan", "business"])).is_err());
1050 }
1051
1052 #[test]
1053 fn upgrade_args_reject_unknown_values() {
1054 assert!(parse_upgrade_args(&s(&["--plan", "enterprise"])).is_err());
1056 assert!(parse_upgrade_args(&s(&["--interval", "weekly"])).is_err());
1057 assert!(parse_upgrade_args(&s(&["--plan"])).is_err());
1058 assert!(parse_upgrade_args(&s(&["--bogus"])).is_err());
1059 }
1060
1061 #[test]
1062 fn parse_pulled_knowledge_maps_server_rows() {
1063 let rows = vec![
1067 serde_json::json!({
1068 "category": "architecture",
1069 "key": "db",
1070 "value": "PostgreSQL 16 with pgvector",
1071 "updated_by": "me@example.com",
1072 "updated_at": "2026-01-02T03:04:05Z"
1073 }),
1074 serde_json::json!({
1075 "category": "decision",
1076 "key": "auth",
1077 "value": "JWT RS256"
1078 }),
1079 ];
1080 let facts = parse_pulled_knowledge(&rows).expect("rows must parse");
1081 assert_eq!(facts.len(), 2);
1082 assert_eq!(facts[0].category, "architecture");
1083 assert_eq!(facts[0].key, "db");
1084 assert_eq!(facts[0].value, "PostgreSQL 16 with pgvector");
1085 assert_eq!(facts[0].source_session, "me@example.com");
1086 assert_eq!(facts[1].value, "JWT RS256");
1088 }
1089
1090 #[test]
1091 fn parse_pulled_knowledge_handles_empty() {
1092 assert!(parse_pulled_knowledge(&[]).unwrap().is_empty());
1093 }
1094}