Skip to main content

lean_ctx/core/config/
shell_activation.rs

1//! Shell activation mode — controls when lean-ctx aliases auto-activate.
2
3use serde::{Deserialize, Serialize};
4
5use super::Config;
6
7/// Controls when the shell hook auto-activates command aliases.
8///
9/// - `AgentsOnly`: (Default since #699) Aliases only activate when an AI agent
10///   env var is detected (`LEAN_CTX_AGENT`, `CURSOR_AGENT`, `CLAUDECODE`,
11///   `CODEBUDDY`, `CODEX_CLI_SESSION`, `GEMINI_SESSION`). lean-ctx exists to
12///   save *agent* tokens — in a plain human terminal the aliases add overhead
13///   and surface allowlist diagnostics with no benefit (GH #699).
14/// - `Always`: Aliases are active in every interactive shell — the pre-#699
15///   default, still available for `lean-ctx wrapped` fans who want their own
16///   shell usage tracked.
17/// - `Off`: Aliases never auto-activate. The user must call `lean-ctx-on` manually.
18#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
19#[serde(rename_all = "kebab-case")]
20pub enum ShellActivation {
21    Always,
22    #[default]
23    AgentsOnly,
24    Off,
25}
26
27impl ShellActivation {
28    pub fn from_env() -> Option<Self> {
29        std::env::var("LEAN_CTX_SHELL_ACTIVATION")
30            .ok()
31            .and_then(|v| match v.trim().to_lowercase().as_str() {
32                "always" => Some(Self::Always),
33                "agents-only" | "agents_only" | "agentsonly" => Some(Self::AgentsOnly),
34                "off" | "none" | "manual" => Some(Self::Off),
35                _ => None,
36            })
37    }
38
39    pub fn effective(config: &Config) -> Self {
40        if let Some(env_val) = Self::from_env() {
41            return env_val;
42        }
43        config.shell_activation.clone()
44    }
45
46    /// Returns the shell condition snippet that guards auto-activation.
47    /// Used in generated shell hooks (posix, fish, powershell).
48    pub fn posix_guard(&self) -> &'static str {
49        match self {
50            Self::Always => {
51                r#"if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ -z "${LEAN_CTX_DISABLED:-}" ] && [ "${LEAN_CTX_ENABLED:-1}" != "0" ]; then"#
52            }
53            Self::AgentsOnly => {
54                r#"if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ -z "${LEAN_CTX_DISABLED:-}" ] && [ "${LEAN_CTX_ENABLED:-1}" != "0" ] && { [ -n "${LEAN_CTX_AGENT:-}" ] || [ -n "${CURSOR_AGENT:-}" ] || [ -n "${CLAUDECODE:-}" ] || [ -n "${CODEBUDDY:-}" ] || [ -n "${CODEX_CLI_SESSION:-}" ] || [ -n "${GEMINI_SESSION:-}" ]; }; then"#
55            }
56            Self::Off => "",
57        }
58    }
59
60    pub fn fish_guard(&self) -> &'static str {
61        match self {
62            Self::Always => {
63                "if not set -q LEAN_CTX_ACTIVE; and not set -q LEAN_CTX_DISABLED; and test (set -q LEAN_CTX_ENABLED; and echo $LEAN_CTX_ENABLED; or echo 1) != '0'"
64            }
65            Self::AgentsOnly => {
66                "if not set -q LEAN_CTX_ACTIVE; and not set -q LEAN_CTX_DISABLED; and test (set -q LEAN_CTX_ENABLED; and echo $LEAN_CTX_ENABLED; or echo 1) != '0'; and begin; set -q LEAN_CTX_AGENT; or set -q CURSOR_AGENT; or set -q CLAUDECODE; or set -q CODEBUDDY; or set -q CODEX_CLI_SESSION; or set -q GEMINI_SESSION; end"
67            }
68            Self::Off => "",
69        }
70    }
71
72    pub fn powershell_guard(&self) -> &'static str {
73        match self {
74            Self::Always => {
75                "if (-not $env:LEAN_CTX_ACTIVE -and -not $env:LEAN_CTX_DISABLED -and -not $env:LEAN_CTX_NO_HOOK)"
76            }
77            Self::AgentsOnly => {
78                "if (-not $env:LEAN_CTX_ACTIVE -and -not $env:LEAN_CTX_DISABLED -and -not $env:LEAN_CTX_NO_HOOK -and ($env:LEAN_CTX_AGENT -or $env:CURSOR_AGENT -or $env:CLAUDECODE -or $env:CODEBUDDY -or $env:CODEX_CLI_SESSION -or $env:GEMINI_SESSION))"
79            }
80            Self::Off => "",
81        }
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    /// GH #699: lean-ctx must be transparent in a plain human terminal —
90    /// aliases (and their allowlist diagnostics) only belong in agent
91    /// sessions unless the user explicitly opts into `always`.
92    #[test]
93    fn default_is_agents_only() {
94        assert_eq!(ShellActivation::default(), ShellActivation::AgentsOnly);
95    }
96
97    #[test]
98    fn serde_roundtrip() {
99        let toml_str = r#"shell_activation = "agents-only""#;
100        #[derive(Deserialize)]
101        struct Wrapper {
102            shell_activation: ShellActivation,
103        }
104        let w: Wrapper = toml::from_str(toml_str).unwrap();
105        assert_eq!(w.shell_activation, ShellActivation::AgentsOnly);
106    }
107
108    #[test]
109    fn posix_guard_always_has_content() {
110        assert!(!ShellActivation::Always.posix_guard().is_empty());
111    }
112
113    #[test]
114    fn posix_guard_agents_checks_env_vars() {
115        let guard = ShellActivation::AgentsOnly.posix_guard();
116        assert!(guard.contains("LEAN_CTX_AGENT"));
117        assert!(guard.contains("CURSOR_AGENT"));
118        assert!(guard.contains("CLAUDECODE"));
119        assert!(guard.contains("CODEBUDDY"));
120        assert!(guard.contains("CODEX_CLI_SESSION"));
121        assert!(guard.contains("GEMINI_SESSION"));
122    }
123
124    /// The agents-only default only works if every guard flavor recognizes
125    /// the same agent markers — a shell where Cursor's env var is missing
126    /// from one variant silently loses the hook there.
127    #[test]
128    fn all_guards_recognize_cursor_agent() {
129        assert!(
130            ShellActivation::AgentsOnly
131                .fish_guard()
132                .contains("CURSOR_AGENT")
133        );
134        assert!(
135            ShellActivation::AgentsOnly
136                .powershell_guard()
137                .contains("CURSOR_AGENT")
138        );
139    }
140
141    #[test]
142    fn posix_guard_off_is_empty() {
143        assert!(ShellActivation::Off.posix_guard().is_empty());
144    }
145}