Skip to main content

lean_ctx/cli/
session_cmd.rs

1use crate::core::session::SessionState;
2use crate::core::stats;
3use crate::tools::ctx_session::{self, SessionToolOptions};
4
5use super::common::{format_tokens_cli, load_shell_history};
6
7pub fn cmd_session_action(args: &[String]) {
8    let action = args.first().map(String::as_str);
9
10    match action {
11        Some("task") => {
12            let desc = args.get(1).map_or("(no description)", String::as_str);
13            #[cfg(unix)]
14            {
15                #[cfg(unix)]
16                if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
17                    "ctx_session",
18                    Some(serde_json::json!({ "action": "task", "value": desc })),
19                ) {
20                    println!("{out}");
21                    return;
22                }
23            }
24            let mut session = load_or_create_session();
25            let out =
26                ctx_session::handle(&mut session, &[], "task", Some(desc), None, default_opts());
27            let _ = session.save();
28            println!("{out}");
29        }
30        Some("finding") => {
31            let summary = args.get(1).map_or("(no summary)", String::as_str);
32            #[cfg(unix)]
33            {
34                #[cfg(unix)]
35                if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
36                    "ctx_session",
37                    Some(serde_json::json!({ "action": "finding", "value": summary })),
38                ) {
39                    println!("{out}");
40                    return;
41                }
42            }
43            let mut session = load_or_create_session();
44            let out = ctx_session::handle(
45                &mut session,
46                &[],
47                "finding",
48                Some(summary),
49                None,
50                default_opts(),
51            );
52            let _ = session.save();
53            println!("{out}");
54        }
55        Some("save") => {
56            #[cfg(unix)]
57            {
58                #[cfg(unix)]
59                if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
60                    "ctx_session",
61                    Some(serde_json::json!({ "action": "save" })),
62                ) {
63                    println!("{out}");
64                    return;
65                }
66            }
67            let mut session = load_or_create_session();
68            let out = ctx_session::handle(&mut session, &[], "save", None, None, default_opts());
69            println!("{out}");
70        }
71        Some("load") => {
72            let id = args.get(1).map(String::as_str);
73            #[cfg(unix)]
74            {
75                #[cfg(unix)]
76                if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
77                    "ctx_session",
78                    Some(serde_json::json!({ "action": "load", "session_id": id })),
79                ) {
80                    println!("{out}");
81                    return;
82                }
83            }
84            let mut session = SessionState::new();
85            let out = ctx_session::handle(&mut session, &[], "load", None, id, default_opts());
86            println!("{out}");
87        }
88        Some("status") => {
89            #[cfg(unix)]
90            {
91                #[cfg(unix)]
92                if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
93                    "ctx_session",
94                    Some(serde_json::json!({ "action": "status" })),
95                ) {
96                    println!("{out}");
97                    return;
98                }
99            }
100            let mut session = load_or_create_session();
101            let out = ctx_session::handle(&mut session, &[], "status", None, None, default_opts());
102            println!("{out}");
103        }
104        Some("decision") => {
105            let desc = args.get(1).map_or("(no description)", String::as_str);
106            #[cfg(unix)]
107            {
108                #[cfg(unix)]
109                if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
110                    "ctx_session",
111                    Some(serde_json::json!({ "action": "decision", "value": desc })),
112                ) {
113                    println!("{out}");
114                    return;
115                }
116            }
117            let mut session = load_or_create_session();
118            let out = ctx_session::handle(
119                &mut session,
120                &[],
121                "decision",
122                Some(desc),
123                None,
124                default_opts(),
125            );
126            let _ = session.save();
127            println!("{out}");
128        }
129        Some("reset" | "new") => {
130            #[cfg(unix)]
131            {
132                #[cfg(unix)]
133                if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
134                    "ctx_session",
135                    Some(serde_json::json!({ "action": "reset" })),
136                ) {
137                    println!("{out}");
138                    return;
139                }
140            }
141            let mut session = load_or_create_session();
142            let out = ctx_session::handle(&mut session, &[], "reset", None, None, default_opts());
143            println!("{out}");
144        }
145        None => {
146            cmd_session_legacy();
147        }
148        Some(other) => {
149            eprintln!("Unknown session action: {other}");
150            print_session_help();
151            std::process::exit(1);
152        }
153    }
154}
155
156fn load_or_create_session() -> SessionState {
157    let mut session = SessionState::load_latest().unwrap_or_default();
158    // Stamp the project root on bare-CLI sessions the way the MCP daemon does
159    // from its roots handshake. Without it a CLI-only flow
160    // (`session task … ; snapshot create`) saves a rootless session that
161    // `load_latest_for_project_root` can never match — the snapshot would then
162    // silently drop its session slice.
163    if session.project_root.is_none()
164        && let Ok(cwd) = std::env::current_dir()
165        && !crate::core::pathutil::is_broad_or_unsafe_root(&cwd)
166    {
167        session.project_root = Some(cwd.to_string_lossy().to_string());
168    }
169    session
170}
171
172fn default_opts() -> SessionToolOptions<'static> {
173    SessionToolOptions {
174        format: None,
175        path: None,
176        write: false,
177        privacy: None,
178        terse: None,
179        agent_id: None,
180    }
181}
182
183fn print_session_help() {
184    eprintln!(
185        "\
186lean-ctx session — Session management
187
188Usage:
189  lean-ctx session                      Show adoption statistics
190  lean-ctx session task <description>   Set current task
191  lean-ctx session finding <summary>    Record a finding
192  lean-ctx session decision <summary>   Record a decision
193  lean-ctx session save                 Save current session
194  lean-ctx session load [session-id]    Load a session (latest if no ID)
195  lean-ctx session status               Show session status
196  lean-ctx session reset|new            Reset session
197
198Examples:
199  lean-ctx session task \"implement JWT authentication\"
200  lean-ctx session finding \"auth.rs:42 — missing token validation\"
201  lean-ctx session save
202  lean-ctx session load"
203    );
204}
205
206fn cmd_session_legacy() {
207    let history = load_shell_history();
208    let gain = stats::load_stats();
209
210    let compressible_commands = [
211        "git ",
212        "npm ",
213        "yarn ",
214        "pnpm ",
215        "cargo ",
216        "docker ",
217        "kubectl ",
218        "gh ",
219        "pip ",
220        "pip3 ",
221        "eslint",
222        "prettier",
223        "ruff ",
224        "go ",
225        "golangci-lint",
226        "curl ",
227        "wget ",
228        "grep ",
229        "rg ",
230        "find ",
231        "ls ",
232    ];
233
234    let mut total = 0u32;
235    let mut via_hook = 0u32;
236
237    for line in &history {
238        let cmd = line.trim().to_lowercase();
239        if cmd.starts_with("lean-ctx") {
240            via_hook += 1;
241            total += 1;
242        } else {
243            for p in &compressible_commands {
244                if cmd.starts_with(p) {
245                    total += 1;
246                    break;
247                }
248            }
249        }
250    }
251
252    let pct = if total > 0 {
253        (via_hook as f64 / total as f64 * 100.0).round() as u32
254    } else {
255        0
256    };
257
258    println!("lean-ctx session statistics\n");
259    println!("Adoption:    {pct}% ({via_hook}/{total} compressible commands)");
260    println!("Saved:       {} tokens total", gain.total_saved);
261    println!("Calls:       {} compressed", gain.total_calls);
262
263    if total > via_hook {
264        let missed = total - via_hook;
265        let est = missed * 150;
266        println!("Missed:      {missed} commands (~{est} tokens saveable)");
267    }
268
269    println!("\nRun 'lean-ctx discover' for details on missed commands.");
270}
271
272pub fn cmd_wrapped(args: &[String]) {
273    let period = if args.iter().any(|a| a == "--month") {
274        "month"
275    } else if args.iter().any(|a| a == "--all") {
276        "all"
277    } else {
278        "week"
279    };
280
281    eprintln!("[DEPRECATED] Use `lean-ctx gain --wrapped`.");
282    println!(
283        "{}",
284        crate::tools::ctx_gain::handle("wrapped", Some(period), None, None)
285    );
286}
287
288pub fn cmd_sessions(args: &[String]) {
289    use crate::core::session::SessionState;
290
291    let action = args.first().map_or("list", std::string::String::as_str);
292
293    match action {
294        "list" | "ls" => {
295            let sessions = SessionState::list_sessions();
296            if sessions.is_empty() {
297                println!("No sessions found.");
298                return;
299            }
300            println!("Sessions ({}):\n", sessions.len());
301            for s in sessions.iter().take(20) {
302                let task = s.task.as_deref().unwrap_or("(no task)");
303                let task_short: String = task.chars().take(50).collect();
304                let date = s.updated_at.format("%Y-%m-%d %H:%M");
305                println!(
306                    "  {} | v{:3} | {:5} calls | {:>8} tok | {} | {}",
307                    s.id,
308                    s.version,
309                    s.tool_calls,
310                    format_tokens_cli(s.tokens_saved),
311                    date,
312                    task_short
313                );
314            }
315            if sessions.len() > 20 {
316                println!("  ... +{} more", sessions.len() - 20);
317            }
318        }
319        "show" => {
320            let id = args.get(1);
321            // Explicit, cross-project UX: show the current project's session if
322            // present, else fall back to the global latest pointer so `show`
323            // works from any directory. (load_latest itself stays project-scoped
324            // to avoid leaking knowledge into a new project's context.)
325            let session = if let Some(id) = id {
326                SessionState::load_by_id(id)
327            } else {
328                SessionState::load_latest().or_else(SessionState::load_global_latest_pointer)
329            };
330            match session {
331                Some(s) => println!("{}", s.format_compact()),
332                None => println!("Session not found."),
333            }
334        }
335        "cleanup" => {
336            let days = args.get(1).and_then(|s| s.parse::<i64>().ok()).unwrap_or(7);
337            let removed = SessionState::cleanup_old_sessions(days);
338            let (wf_removed, wf_freed) = crate::core::workflow::cleanup_expired();
339            println!("Cleaned up {removed} session(s) older than {days} days.");
340            if wf_removed > 0 {
341                println!(
342                    "Cleaned up {wf_removed} expired workflow file(s) ({:.1} KB freed).",
343                    wf_freed as f64 / 1024.0
344                );
345            }
346        }
347        "delete" | "rm" => {
348            let Some(id) = args.get(1) else {
349                eprintln!("Usage: lean-ctx sessions delete <id>");
350                std::process::exit(1);
351            };
352            match SessionState::delete_session(id) {
353                Ok(true) => println!("Deleted session {id}."),
354                Ok(false) => {
355                    eprintln!("Session not found: {id}");
356                    std::process::exit(1);
357                }
358                Err(e) => {
359                    eprintln!("Failed to delete session {id}: {e}");
360                    std::process::exit(1);
361                }
362            }
363        }
364        "doctor" => {
365            let apply = args.iter().any(|a| a == "--apply" || a == "--fix");
366            let (found, quarantined) = SessionState::doctor_quarantine_unsafe_roots(apply);
367            if found.is_empty() {
368                println!("session doctor: no contaminated sessions found.");
369            } else {
370                println!(
371                    "session doctor: {} session(s) rooted at a broad/unsafe path (HOME/'/'/agent dir):",
372                    found.len()
373                );
374                for (id, root) in &found {
375                    println!("  {id} | root: {root}");
376                }
377                if apply {
378                    println!("\nQuarantined {quarantined} session(s) to sessions/quarantine/.");
379                } else {
380                    println!("\nRun `lean-ctx sessions doctor --apply` to quarantine them.");
381                }
382            }
383        }
384        _ => {
385            eprintln!(
386                "Usage: lean-ctx sessions [list|show [id]|delete <id>|cleanup [days]|doctor [--apply]]"
387            );
388            std::process::exit(1);
389        }
390    }
391}