Skip to main content

lean_ctx/cli/
cloud.rs

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    // Upgrade remote auth to OAuth2 client_credentials when supported by the API.
74    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    // Stats roll-up is account-level and stays free for everyone.
172    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    // Everything below is the Pro "Personal Cloud" (cross-device sync of your own
185    // context). On a Free account the server returns 402; detect it once and show
186    // a friendly upgrade hint instead of one failure per surface.
187    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
199/// `lean-ctx sync index <push|pull|status>` — the hosted Personal Index
200/// (GL #392): encrypted cross-device sync of the project's retrieval index.
201fn 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
272/// One human line for the server's billing-plane-v2 `storage` block (GL #392):
273/// green/yellow/red by threshold state, with the headroom or overage spelled
274/// out. `None` when the server (older deploy) sent no block — print nothing
275/// rather than guessing.
276fn 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        // "none" (no entitlement) and future states: the usage line above
293        // already says everything actionable.
294        _ => return None,
295    })
296}
297
298/// Whether a `cloud_client` error string is the server's Pro gate (HTTP 402),
299/// mirroring the existing 403 string-match in `cloud_client::pull_cloud_models`.
300fn pro_gate_hit(err: &str) -> bool {
301    err.contains("402")
302}
303
304#[derive(PartialEq, Eq)]
305enum CloudSyncOutcome {
306    Done,
307    Gated,
308}
309
310/// Push the Pro-gated "Personal Cloud" surfaces. Returns [`CloudSyncOutcome::Gated`]
311/// at the first 402 (a Free account) so the caller shows a single upgrade hint
312/// rather than one error per surface. A self-hosted backend with the gate open
313/// (billing unset / `LEANCTX_CLOUD_SYNC_OPEN`) never returns 402, so all sync.
314fn 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
387/// Friendly, non-error hint shown when the server gates cloud sync behind Pro.
388/// Delegates to the central, entitlement-aware hint helper (#346) so the message
389/// reflects the user's actual plan and the cheapest unlocking tier.
390fn 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
560/// `lean-ctx cloud status` — your Personal Cloud, from the terminal. Shows the
561/// same privacy-preserving footprint as leanctx.com/account/cloud: per-bucket
562/// `lean-ctx cloud autosync <on|off|status>` — toggle the daily background
563/// Personal-Cloud push (GL #384). The flag lives in `[cloud] auto_sync`.
564fn 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
607/// `lean-ctx cloud autoindex <on|off|status>` — toggle the daily background
608/// hosted-index push (GL #392). Separate flag from `autosync` because index
609/// bundles are megabytes, not kilobytes. The flag lives in `[cloud] auto_index`.
610fn 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
657/// counts + last sync, buddy, and the all-time usage totals. Free accounts see
658/// the connection state plus what upgrading unlocks.
659fn 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    // Mirror the website's bucket order and labels.
691    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
745/// `lean-ctx cloud pull` — the read side of the Pro "Personal Cloud". `lean-ctx
746/// sync` pushes your knowledge to the account; this restores it onto the current
747/// machine, so your context follows you across devices. Facts are merged into the
748/// current project's local store with skip-existing semantics, so a local fact is
749/// never clobbered and re-running is idempotent. A Free account hits the 402 gate
750/// and gets the same upgrade hint as `sync`.
751fn 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
819/// Map the server's `{category, key, value, updated_by, updated_at}` rows onto the
820/// import schema (`value` + `source`/`timestamp` provenance) and reuse the
821/// battle-tested [`parse_import_data`] importer rather than re-deriving the
822/// `KnowledgeFact` shape here.
823fn 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
848/// `lean-ctx cloud upgrade [--plan pro|team|business] [--interval monthly|yearly]`
849/// — start a hosted Stripe Checkout for the logged-in account and print the URL
850/// to open. Defaults to Pro monthly (the self-serve Personal Cloud tier).
851fn 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
881/// Parse the optional `--plan` / `--interval` flags for `cloud upgrade`. Defaults
882/// are Pro + monthly. Only `pro`/`team`/`business` and `monthly`/`yearly` are
883/// accepted; an unknown value is an error (so a typo never silently buys the
884/// wrong plan). Enterprise stays sales-assisted and is deliberately absent.
885fn 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        // The server's Pro gate surfaces as a 402 inside the error string.
1009        assert!(pro_gate_hit(
1010            "Push failed: http status 402 Payment Required"
1011        ));
1012        // Other failures must NOT be treated as the gate (they stay errors).
1013        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        // Shorthand cadence flags.
1037        assert_eq!(
1038            parse_upgrade_args(&s(&["--yearly"])).unwrap(),
1039            ("pro".to_string(), "yearly".to_string())
1040        );
1041        // Business is self-serve too (GL #533).
1042        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        // A typo'd plan must error, never silently fall back to a purchase.
1051        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        // The GET /api/sync/knowledge contract: {category, key, value,
1060        // updated_by, updated_at}. The pull path must map these onto facts and
1061        // carry provenance (updated_by -> source_session).
1062        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        // Rows without updated_by fall back to the importer's default source.
1083        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}