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    // GH #439: mode_stats.json lives in the data dir — read it through the typed
422    // resolver (matching cloud_sync) instead of a stale ~/.lean-ctx path.
423    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|business] [--interval monthly|yearly]"
556            );
557        }
558    }
559}
560
561/// `lean-ctx cloud status` — your Personal Cloud, from the terminal. Shows the
562/// same privacy-preserving footprint as leanctx.com/account/cloud: per-bucket
563/// `lean-ctx cloud autosync <on|off|status>` — toggle the daily background
564/// Personal-Cloud push (GL #384). The flag lives in `[cloud] auto_sync`.
565fn 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
610/// `lean-ctx cloud autoindex <on|off|status>` — toggle the daily background
611/// hosted-index push (GL #392). Separate flag from `autosync` because index
612/// bundles are megabytes, not kilobytes. The flag lives in `[cloud] auto_index`.
613fn 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
660/// counts + last sync, buddy, and the all-time usage totals. Free accounts see
661/// the connection state plus what upgrading unlocks.
662fn 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    // Mirror the website's bucket order and labels.
694    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
748/// `lean-ctx cloud pull` — the read side of the Pro "Personal Cloud". `lean-ctx
749/// sync` pushes your knowledge to the account; this restores it onto the current
750/// machine, so your context follows you across devices. Facts are merged into the
751/// current project's local store with skip-existing semantics, so a local fact is
752/// never clobbered and re-running is idempotent. A Free account hits the 402 gate
753/// and gets the same upgrade hint as `sync`.
754fn 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    // #326/#594-A4: import under the project lock so a cloud-pull cannot clobber
800    // a concurrent foreground remember/import. The closure reloads the latest
801    // on-disk state inside the lock before merging.
802    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
824/// Map the server's `{category, key, value, updated_by, updated_at}` rows onto the
825/// import schema (`value` + `source`/`timestamp` provenance) and reuse the
826/// battle-tested [`parse_import_data`] importer rather than re-deriving the
827/// `KnowledgeFact` shape here.
828fn 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
853/// `lean-ctx cloud upgrade [--plan pro|team|business] [--interval monthly|yearly]`
854/// — start a hosted Stripe Checkout for the logged-in account and print the URL
855/// to open. Defaults to Pro monthly (the self-serve Personal Cloud tier).
856fn 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|business] [--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
886/// Parse the optional `--plan` / `--interval` flags for `cloud upgrade`. Defaults
887/// are Pro + monthly. Only `pro`/`team`/`business` and `monthly`/`yearly` are
888/// accepted; an unknown value is an error (so a typo never silently buys the
889/// wrong plan). Enterprise stays sales-assisted and is deliberately absent.
890fn 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
899                    .get(i)
900                    .ok_or("--plan needs a value (pro|team|business)")?;
901                if !matches!(v.as_str(), "pro" | "team" | "business") {
902                    return Err(format!("unknown plan '{v}' (use pro, team or business)"));
903                }
904                plan.clone_from(v);
905            }
906            "--interval" => {
907                i += 1;
908                let v = args
909                    .get(i)
910                    .ok_or("--interval needs a value (monthly|yearly)")?;
911                if !matches!(v.as_str(), "monthly" | "yearly") {
912                    return Err(format!("unknown interval '{v}' (use monthly or yearly)"));
913                }
914                interval.clone_from(v);
915            }
916            "--yearly" => interval = "yearly".to_string(),
917            "--monthly" => interval = "monthly".to_string(),
918            other => return Err(format!("unknown option '{other}'")),
919        }
920        i += 1;
921    }
922    Ok((plan, interval))
923}
924
925pub fn cmd_gotchas(args: &[String]) {
926    let action = args.first().map_or("list", std::string::String::as_str);
927    let project_root = std::env::current_dir()
928        .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string());
929
930    match action {
931        "list" | "ls" => {
932            let store = core::gotcha_tracker::GotchaStore::load(&project_root);
933            println!("{}", store.format_list());
934        }
935        "clear" => {
936            let mut store = core::gotcha_tracker::GotchaStore::load(&project_root);
937            let count = store.gotchas.len();
938            store.clear();
939            let _ = store.save(&project_root);
940            println!("Cleared {count} gotchas.");
941        }
942        "export" => {
943            let store = core::gotcha_tracker::GotchaStore::load(&project_root);
944            match serde_json::to_string_pretty(&store.gotchas) {
945                Ok(json) => println!("{json}"),
946                Err(e) => tracing::error!("Export failed: {e}"),
947            }
948        }
949        "stats" => {
950            let store = core::gotcha_tracker::GotchaStore::load(&project_root);
951            println!("Bug Memory Stats:");
952            println!("  Active gotchas:      {}", store.gotchas.len());
953            println!(
954                "  Errors detected:     {}",
955                store.stats.total_errors_detected
956            );
957            println!(
958                "  Fixes correlated:    {}",
959                store.stats.total_fixes_correlated
960            );
961            println!("  Bugs prevented:      {}", store.stats.total_prevented);
962            println!("  Promoted to knowledge: {}", store.stats.gotchas_promoted);
963            println!("  Decayed/archived:    {}", store.stats.gotchas_decayed);
964            println!("  Session logs:        {}", store.error_log.len());
965        }
966        "reflect" | "ledger" => {
967            let store = core::gotcha_tracker::GotchaStore::load(&project_root);
968            println!("{}", core::gotcha_tracker::format_ledger(&store));
969        }
970        _ => {
971            println!("Usage: lean-ctx gotchas [list|clear|export|stats|reflect]");
972        }
973    }
974}
975
976pub fn cmd_buddy(args: &[String]) {
977    let cfg = core::config::Config::load();
978    if !cfg.buddy_enabled {
979        println!("Buddy is disabled. Enable with: lean-ctx config buddy_enabled true");
980        return;
981    }
982
983    let action = args.first().map_or("show", std::string::String::as_str);
984    let buddy = core::buddy::BuddyState::compute();
985    let theme = core::theme::load_theme(&cfg.theme);
986
987    match action {
988        "show" | "status" | "stats" => {
989            println!("{}", core::buddy::format_buddy_full(&buddy, &theme));
990        }
991        "ascii" => {
992            for line in &buddy.ascii_art {
993                println!("  {line}");
994            }
995        }
996        "json" => match serde_json::to_string_pretty(&buddy) {
997            Ok(json) => println!("{json}"),
998            Err(e) => tracing::error!("JSON error: {e}"),
999        },
1000        _ => {
1001            println!("Usage: lean-ctx buddy [show|stats|ascii|json]");
1002        }
1003    }
1004}
1005
1006pub fn cmd_upgrade() {
1007    println!("'upgrade' has been renamed to 'update'. Running 'lean-ctx update' instead.\n");
1008    core::updater::run(&[]);
1009}
1010
1011#[cfg(test)]
1012mod tests {
1013    use super::*;
1014
1015    #[test]
1016    fn pro_gate_hit_detects_402_only() {
1017        // The server's Pro gate surfaces as a 402 inside the error string.
1018        assert!(pro_gate_hit(
1019            "Push failed: http status 402 Payment Required"
1020        ));
1021        // Other failures must NOT be treated as the gate (they stay errors).
1022        assert!(!pro_gate_hit("Push failed: http status 500"));
1023        assert!(!pro_gate_hit("Push failed: connection refused"));
1024        assert!(!pro_gate_hit("401 Unauthorized"));
1025    }
1026
1027    fn s(args: &[&str]) -> Vec<String> {
1028        args.iter().map(|a| (*a).to_string()).collect()
1029    }
1030
1031    #[test]
1032    fn upgrade_args_default_to_pro_monthly() {
1033        assert_eq!(
1034            parse_upgrade_args(&[]).unwrap(),
1035            ("pro".to_string(), "monthly".to_string())
1036        );
1037    }
1038
1039    #[test]
1040    fn upgrade_args_accept_team_and_yearly() {
1041        assert_eq!(
1042            parse_upgrade_args(&s(&["--plan", "team", "--interval", "yearly"])).unwrap(),
1043            ("team".to_string(), "yearly".to_string())
1044        );
1045        // Shorthand cadence flags.
1046        assert_eq!(
1047            parse_upgrade_args(&s(&["--yearly"])).unwrap(),
1048            ("pro".to_string(), "yearly".to_string())
1049        );
1050        // Business is self-serve too (GL #533).
1051        assert_eq!(
1052            parse_upgrade_args(&s(&["--plan", "business"])).unwrap(),
1053            ("business".to_string(), "monthly".to_string())
1054        );
1055    }
1056
1057    #[test]
1058    fn upgrade_args_reject_unknown_values() {
1059        // A typo'd plan must error, never silently fall back to a purchase.
1060        assert!(parse_upgrade_args(&s(&["--plan", "enterprise"])).is_err());
1061        assert!(parse_upgrade_args(&s(&["--interval", "weekly"])).is_err());
1062        assert!(parse_upgrade_args(&s(&["--plan"])).is_err());
1063        assert!(parse_upgrade_args(&s(&["--bogus"])).is_err());
1064    }
1065
1066    #[test]
1067    fn parse_pulled_knowledge_maps_server_rows() {
1068        // The GET /api/sync/knowledge contract: {category, key, value,
1069        // updated_by, updated_at}. The pull path must map these onto facts and
1070        // carry provenance (updated_by -> source_session).
1071        let rows = vec![
1072            serde_json::json!({
1073                "category": "architecture",
1074                "key": "db",
1075                "value": "PostgreSQL 16 with pgvector",
1076                "updated_by": "me@example.com",
1077                "updated_at": "2026-01-02T03:04:05Z"
1078            }),
1079            serde_json::json!({
1080                "category": "decision",
1081                "key": "auth",
1082                "value": "JWT RS256"
1083            }),
1084        ];
1085        let facts = parse_pulled_knowledge(&rows).expect("rows must parse");
1086        assert_eq!(facts.len(), 2);
1087        assert_eq!(facts[0].category, "architecture");
1088        assert_eq!(facts[0].key, "db");
1089        assert_eq!(facts[0].value, "PostgreSQL 16 with pgvector");
1090        assert_eq!(facts[0].source_session, "me@example.com");
1091        // Rows without updated_by fall back to the importer's default source.
1092        assert_eq!(facts[1].value, "JWT RS256");
1093    }
1094
1095    #[test]
1096    fn parse_pulled_knowledge_handles_empty() {
1097        assert!(parse_pulled_knowledge(&[]).unwrap().is_empty());
1098    }
1099}