use std::io::IsTerminal;
pub const DISCLOSURE: &[&str] = &[
"random installation ID (not derived from your machine or account)",
"LeanCTX version, OS, CPU architecture, install channel (cargo/npm/homebrew/…)",
"AI client family (Claude, Cursor, Codex, Cline, JetBrains, …) or that no AI client is connected, and setup profile / integrations",
"runtime environment (local, container, Codespaces, …), installation age and number of active days, each as a coarse range",
"daily counts per built-in tool: calls, failures by class, total latency, latency buckets",
"the most frequent error messages per tool, with every path, name, value and number replaced by a placeholder on your machine",
"daily counts of the LeanCTX commands and background features you use (for example `pack export` or an index build) and how many failed, never their arguments",
"your daily usage record (as `lean-ctx gain` shows it, last 90 days): operations and tokens before/after compression, lifetime totals, month of first use",
"session counts and uptime (including MCP server starts that answered no tool call), error categories, version upgrades",
"how each session ended, as daily counts: no failed call, recovered from failed calls, or a tool whose last call failed (and whether that failure was on LeanCTX's side), plus how many calls retried a failed one",
"aggregate autopilot, sync and plan events (counts only)",
];
pub const NEVER_SENT: &str = "No prompts, code, file names, paths, commands, raw error messages or secrets. The server reduces the connection's IP address to a keyed network hash and the network operator's public name; the address is never stored.";
const NOTICE_VERSION: u32 = 3;
pub(crate) const CI_MARKERS: &[&str] = &[
"GITHUB_ACTIONS",
"GITLAB_CI",
"BUILDKITE",
"CIRCLECI",
"TRAVIS",
"JENKINS_URL",
"TF_BUILD",
"TEAMCITY_VERSION",
"BITBUCKET_BUILD_NUMBER",
"CODEBUILD_BUILD_ID",
"DRONE",
"APPVEYOR",
"SEMAPHORE",
"HEROKU_TEST_RUN_ID",
"CONTINUOUS_INTEGRATION",
];
pub fn ci_detected(lookup: impl Fn(&str) -> Option<String>) -> bool {
let set = |key: &str| lookup(key).is_some_and(|value| !value.trim().is_empty());
let ci_flag = lookup("CI").is_some_and(|value| {
let value = value.trim().to_ascii_lowercase();
!value.is_empty() && value != "false" && value != "0"
});
ci_flag || CI_MARKERS.iter().any(|key| set(key))
}
pub fn running_in_ci() -> bool {
if crate::core::data_dir::sandboxed() {
return false;
}
let opted_in = crate::core::host_env::var("LEAN_CTX_TELEMETRY_IN_CI")
.is_some_and(|value| value.trim() == "1");
!opted_in && ci_detected(crate::core::host_env::var)
}
pub fn disclosure_lines() -> Vec<String> {
let mut lines: Vec<String> = DISCLOSURE.iter().map(|item| format!("• {item}")).collect();
lines.push(NEVER_SENT.to_string());
lines
}
pub fn persist_choice(enabled: bool) -> Result<(), String> {
crate::core::config::setter::set_many_by_key(&consent_updates(enabled))
.map(|_| ())
.map_err(|error| error.to_string())
}
pub fn consent_updates(enabled: bool) -> [(&'static str, &'static str); 2] {
let (value, preference) = if enabled {
("true", "explicitly_enabled")
} else {
("false", "explicitly_disabled")
};
[
("telemetry.enabled", value),
("telemetry.preference", preference),
]
}
fn legacy_reset_path() -> Result<std::path::PathBuf, String> {
crate::core::paths::state_dir().map(|dir| dir.join("telemetry_legacy_opt_out_reset"))
}
pub(crate) fn legacy_opt_out_reset_pending() -> bool {
legacy_reset_path().is_ok_and(|path| !path.exists())
}
pub(crate) fn mark_legacy_opt_out_reset() {
let Ok(path) = legacy_reset_path() else {
return;
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(path, "1\n");
}
fn notice_path() -> Result<std::path::PathBuf, String> {
crate::core::paths::state_dir().map(|dir| dir.join("telemetry_notice_version"))
}
fn notice_seen() -> bool {
notice_path()
.ok()
.and_then(|path| std::fs::read_to_string(path).ok())
.and_then(|text| text.trim().parse::<u32>().ok())
.is_some_and(|seen| seen >= NOTICE_VERSION)
}
pub fn mark_notice_seen() {
let Ok(path) = notice_path() else { return };
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(path, format!("{NOTICE_VERSION}\n"));
}
pub fn hint_lines() -> Vec<String> {
let config = crate::core::config::Config::path().map_or_else(
|| "config.toml".to_string(),
|path| path.display().to_string(),
);
vec![
"Anonymous usage telemetry is on (daily counts only — no code, paths or prompts)."
.to_string(),
format!(
"Turn off: lean-ctx telemetry off · or set `enabled = false` under [telemetry] in {config}"
),
"What is sent: lean-ctx telemetry show · https://leanctx.com/privacy".to_string(),
]
}
fn notice_text() -> String {
let mut lines = hint_lines().into_iter();
let mut text = format!("\x1b[1m{}\x1b[0m\n", lines.next().unwrap_or_default());
for line in lines {
text.push_str(&format!(" \x1b[2m{line}\x1b[0m\n"));
}
text
}
pub fn maybe_show_notice() {
if !(std::io::stderr().is_terminal() && crate::core::host_env::stdin_is_terminal()) {
return;
}
if notice_seen() || !telemetry_would_send() {
return;
}
eprint!("{}", notice_text());
eprintln!();
mark_notice_seen();
}
pub fn telemetry_would_send() -> bool {
let Ok(config) = crate::core::config::Config::try_load_global() else {
return false;
};
config.telemetry.send_eligible(
crate::core::host_env::var("DO_NOT_TRACK").as_deref(),
crate::core::host_env::var("LEAN_CTX_TELEMETRY").as_deref(),
) && !running_in_ci()
}
#[cfg(test)]
mod tests {
use super::*;
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + 'static {
let owned: Vec<(String, String)> = pairs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect();
move |key| owned.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone())
}
#[test]
fn plain_workstation_is_not_ci() {
assert!(!ci_detected(env(&[])));
assert!(!ci_detected(env(&[("CI", "false")])));
assert!(!ci_detected(env(&[("CI", "0")])));
assert!(!ci_detected(env(&[("CI", "")])));
assert!(!ci_detected(env(&[("GITHUB_ACTIONS", " ")])));
}
#[test]
fn common_ci_markers_are_detected() {
assert!(ci_detected(env(&[("CI", "true")])));
assert!(ci_detected(env(&[("CI", "1")])));
for marker in CI_MARKERS {
assert!(ci_detected(env(&[(marker, "x")])), "{marker}");
}
}
#[test]
fn consent_updates_only_write_schema_keys() {
let schema = crate::core::config::schema::ConfigSchema::generate();
for enabled in [true, false] {
for (key, _) in consent_updates(enabled) {
assert!(schema.lookup(key).is_some(), "unknown config key {key}");
}
}
}
#[test]
fn disclosure_names_the_exclusions() {
let text = disclosure_lines().join("\n");
assert_eq!(disclosure_lines().len(), DISCLOSURE.len() + 1);
assert!(text.contains(NEVER_SENT));
}
#[test]
fn notice_is_a_short_hint_with_every_way_out() {
let text = notice_text();
assert_eq!(text.lines().count(), 3);
assert!(text.contains("lean-ctx telemetry off"));
assert!(text.contains("[telemetry]"));
assert!(text.contains("lean-ctx telemetry show"));
assert!(!text.contains('?'), "the notice must not ask anything");
}
}