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") => {
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    }
180}
181
182fn print_session_help() {
183    eprintln!(
184        "\
185lean-ctx session — Session management
186
187Usage:
188  lean-ctx session                      Show adoption statistics
189  lean-ctx session task <description>   Set current task
190  lean-ctx session finding <summary>    Record a finding
191  lean-ctx session decision <summary>   Record a decision
192  lean-ctx session save                 Save current session
193  lean-ctx session load [session-id]    Load a session (latest if no ID)
194  lean-ctx session status               Show session status
195  lean-ctx session reset                Reset session
196
197Examples:
198  lean-ctx session task \"implement JWT authentication\"
199  lean-ctx session finding \"auth.rs:42 — missing token validation\"
200  lean-ctx session save
201  lean-ctx session load"
202    );
203}
204
205fn cmd_session_legacy() {
206    let history = load_shell_history();
207    let gain = stats::load_stats();
208
209    let compressible_commands = [
210        "git ",
211        "npm ",
212        "yarn ",
213        "pnpm ",
214        "cargo ",
215        "docker ",
216        "kubectl ",
217        "gh ",
218        "pip ",
219        "pip3 ",
220        "eslint",
221        "prettier",
222        "ruff ",
223        "go ",
224        "golangci-lint",
225        "curl ",
226        "wget ",
227        "grep ",
228        "rg ",
229        "find ",
230        "ls ",
231    ];
232
233    let mut total = 0u32;
234    let mut via_hook = 0u32;
235
236    for line in &history {
237        let cmd = line.trim().to_lowercase();
238        if cmd.starts_with("lean-ctx") {
239            via_hook += 1;
240            total += 1;
241        } else {
242            for p in &compressible_commands {
243                if cmd.starts_with(p) {
244                    total += 1;
245                    break;
246                }
247            }
248        }
249    }
250
251    let pct = if total > 0 {
252        (via_hook as f64 / total as f64 * 100.0).round() as u32
253    } else {
254        0
255    };
256
257    println!("lean-ctx session statistics\n");
258    println!("Adoption:    {pct}% ({via_hook}/{total} compressible commands)");
259    println!("Saved:       {} tokens total", gain.total_saved);
260    println!("Calls:       {} compressed", gain.total_calls);
261
262    if total > via_hook {
263        let missed = total - via_hook;
264        let est = missed * 150;
265        println!("Missed:      {missed} commands (~{est} tokens saveable)");
266    }
267
268    println!("\nRun 'lean-ctx discover' for details on missed commands.");
269}
270
271pub fn cmd_wrapped(args: &[String]) {
272    let period = if args.iter().any(|a| a == "--month") {
273        "month"
274    } else if args.iter().any(|a| a == "--all") {
275        "all"
276    } else {
277        "week"
278    };
279
280    eprintln!("[DEPRECATED] Use `lean-ctx gain --wrapped`.");
281    println!(
282        "{}",
283        crate::tools::ctx_gain::handle("wrapped", Some(period), None, None)
284    );
285}
286
287pub fn cmd_sessions(args: &[String]) {
288    use crate::core::session::SessionState;
289
290    let action = args.first().map_or("list", std::string::String::as_str);
291
292    match action {
293        "list" | "ls" => {
294            let sessions = SessionState::list_sessions();
295            if sessions.is_empty() {
296                println!("No sessions found.");
297                return;
298            }
299            println!("Sessions ({}):\n", sessions.len());
300            for s in sessions.iter().take(20) {
301                let task = s.task.as_deref().unwrap_or("(no task)");
302                let task_short: String = task.chars().take(50).collect();
303                let date = s.updated_at.format("%Y-%m-%d %H:%M");
304                println!(
305                    "  {} | v{:3} | {:5} calls | {:>8} tok | {} | {}",
306                    s.id,
307                    s.version,
308                    s.tool_calls,
309                    format_tokens_cli(s.tokens_saved),
310                    date,
311                    task_short
312                );
313            }
314            if sessions.len() > 20 {
315                println!("  ... +{} more", sessions.len() - 20);
316            }
317        }
318        "show" => {
319            let id = args.get(1);
320            // Explicit, cross-project UX: show the current project's session if
321            // present, else fall back to the global latest pointer so `show`
322            // works from any directory. (load_latest itself stays project-scoped
323            // to avoid leaking knowledge into a new project's context.)
324            let session = if let Some(id) = id {
325                SessionState::load_by_id(id)
326            } else {
327                SessionState::load_latest().or_else(SessionState::load_global_latest_pointer)
328            };
329            match session {
330                Some(s) => println!("{}", s.format_compact()),
331                None => println!("Session not found."),
332            }
333        }
334        "cleanup" => {
335            let days = args.get(1).and_then(|s| s.parse::<i64>().ok()).unwrap_or(7);
336            let removed = SessionState::cleanup_old_sessions(days);
337            let (wf_removed, wf_freed) = crate::core::workflow::cleanup_expired();
338            println!("Cleaned up {removed} session(s) older than {days} days.");
339            if wf_removed > 0 {
340                println!(
341                    "Cleaned up {wf_removed} expired workflow file(s) ({:.1} KB freed).",
342                    wf_freed as f64 / 1024.0
343                );
344            }
345        }
346        "doctor" => {
347            let apply = args.iter().any(|a| a == "--apply" || a == "--fix");
348            let (found, quarantined) = SessionState::doctor_quarantine_unsafe_roots(apply);
349            if found.is_empty() {
350                println!("session doctor: no contaminated sessions found.");
351            } else {
352                println!(
353                    "session doctor: {} session(s) rooted at a broad/unsafe path (HOME/'/'/agent dir):",
354                    found.len()
355                );
356                for (id, root) in &found {
357                    println!("  {id} | root: {root}");
358                }
359                if apply {
360                    println!("\nQuarantined {quarantined} session(s) to sessions/quarantine/.");
361                } else {
362                    println!("\nRun `lean-ctx sessions doctor --apply` to quarantine them.");
363                }
364            }
365        }
366        _ => {
367            eprintln!("Usage: lean-ctx sessions [list|show [id]|cleanup [days]|doctor [--apply]]");
368            std::process::exit(1);
369        }
370    }
371}