1use std::path::PathBuf;
2
3use crate::core::editor_registry::{ConfigType, EditorTarget, WriteAction, WriteOptions};
4use crate::core::portable_binary::resolve_portable_binary;
5use crate::core::setup_report::{PlatformInfo, SetupItem, SetupReport, SetupStepReport};
6use crate::hooks::{recommend_hook_mode, HookMode};
7use chrono::Utc;
8use std::ffi::OsString;
9
10pub fn claude_config_json_path(home: &std::path::Path) -> PathBuf {
11 crate::core::editor_registry::claude_mcp_json_path(home)
12}
13
14pub fn claude_config_dir(home: &std::path::Path) -> PathBuf {
15 crate::core::editor_registry::claude_state_dir(home)
16}
17
18pub(crate) struct EnvVarGuard {
19 key: &'static str,
20 previous: Option<OsString>,
21}
22
23impl EnvVarGuard {
24 pub(crate) fn set(key: &'static str, value: &str) -> Self {
25 let previous = std::env::var_os(key);
26 std::env::set_var(key, value);
27 Self { key, previous }
28 }
29}
30
31impl Drop for EnvVarGuard {
32 fn drop(&mut self) {
33 if let Some(previous) = &self.previous {
34 std::env::set_var(self.key, previous);
35 } else {
36 std::env::remove_var(self.key);
37 }
38 }
39}
40
41pub fn run_setup() {
42 use crate::terminal_ui;
43
44 if crate::shell::is_non_interactive() {
45 eprintln!("Non-interactive terminal detected (no TTY on stdin).");
46 eprintln!("Running in non-interactive mode (equivalent to: lean-ctx setup --non-interactive --yes)");
47 eprintln!();
48 let opts = SetupOptions {
49 non_interactive: true,
50 yes: true,
51 ..Default::default()
52 };
53 match run_setup_with_options(opts) {
54 Ok(report) => {
55 if !report.warnings.is_empty() {
56 for w in &report.warnings {
57 tracing::warn!("{w}");
58 }
59 }
60 }
61 Err(e) => tracing::error!("Setup error: {e}"),
62 }
63 return;
64 }
65
66 let Some(home) = dirs::home_dir() else {
67 tracing::error!("Cannot determine home directory");
68 std::process::exit(1);
69 };
70
71 let binary = resolve_portable_binary();
72
73 let home_str = home.to_string_lossy().to_string();
74
75 terminal_ui::print_setup_header();
76
77 terminal_ui::print_step_header(1, 11, "Shell Hook");
79 crate::cli::cmd_init(&["--global".to_string()]);
80 crate::shell_hook::install_all(false);
81
82 terminal_ui::print_step_header(2, 11, "Daemon");
84 if crate::daemon::is_daemon_running() {
85 terminal_ui::print_status_ok("Daemon running — restarting with current binary…");
86 let _ = crate::daemon::stop_daemon();
87 std::thread::sleep(std::time::Duration::from_millis(500));
88 if let Err(e) = crate::daemon::start_daemon(&[]) {
89 terminal_ui::print_status_warn(&format!("Daemon restart failed: {e}"));
90 }
91 } else if let Err(e) = crate::daemon::start_daemon(&[]) {
92 terminal_ui::print_status_warn(&format!("Daemon start failed: {e}"));
93 }
94
95 terminal_ui::print_step_header(3, 11, "AI Tool Detection");
97
98 let targets = crate::core::editor_registry::build_targets(&home);
99 let mut newly_configured: Vec<&str> = Vec::new();
100 let mut already_configured: Vec<&str> = Vec::new();
101 let mut not_installed: Vec<&str> = Vec::new();
102 let mut errors: Vec<&str> = Vec::new();
103
104 for target in &targets {
105 let short_path = shorten_path(&target.config_path.to_string_lossy(), &home_str);
106
107 if !target.detect_path.exists() {
108 not_installed.push(target.name);
109 continue;
110 }
111
112 let mode = if target.agent_key.is_empty() {
113 HookMode::Mcp
114 } else {
115 recommend_hook_mode(&target.agent_key)
116 };
117
118 match crate::core::editor_registry::write_config_with_options(
119 target,
120 &binary,
121 WriteOptions {
122 overwrite_invalid: false,
123 },
124 ) {
125 Ok(res) if res.action == WriteAction::Already => {
126 terminal_ui::print_status_ok(&format!(
127 "{:<20} \x1b[36m{mode}\x1b[0m \x1b[2m{short_path}\x1b[0m",
128 target.name
129 ));
130 already_configured.push(target.name);
131 }
132 Ok(_) => {
133 terminal_ui::print_status_new(&format!(
134 "{:<20} \x1b[36m{mode}\x1b[0m \x1b[2m{short_path}\x1b[0m",
135 target.name
136 ));
137 newly_configured.push(target.name);
138 }
139 Err(e) => {
140 terminal_ui::print_status_warn(&format!("{}: {e}", target.name));
141 errors.push(target.name);
142 }
143 }
144 }
145
146 let total_ok = newly_configured.len() + already_configured.len();
147 if total_ok == 0 && errors.is_empty() {
148 terminal_ui::print_status_warn(
149 "No AI tools detected. Install one and re-run: lean-ctx setup",
150 );
151 }
152
153 if !not_installed.is_empty() {
154 println!(
155 " \x1b[2m○ {} not detected: {}\x1b[0m",
156 not_installed.len(),
157 not_installed.join(", ")
158 );
159 }
160
161 terminal_ui::print_step_header(4, 11, "Agent Rules");
163 let rules_result = crate::rules_inject::inject_all_rules(&home);
164 for name in &rules_result.injected {
165 terminal_ui::print_status_new(&format!("{name:<20} \x1b[2mrules injected\x1b[0m"));
166 }
167 for name in &rules_result.updated {
168 terminal_ui::print_status_new(&format!("{name:<20} \x1b[2mrules updated\x1b[0m"));
169 }
170 for name in &rules_result.already {
171 terminal_ui::print_status_ok(&format!("{name:<20} \x1b[2mrules up-to-date\x1b[0m"));
172 }
173 for err in &rules_result.errors {
174 terminal_ui::print_status_warn(err);
175 }
176 if rules_result.injected.is_empty()
177 && rules_result.updated.is_empty()
178 && rules_result.already.is_empty()
179 && rules_result.errors.is_empty()
180 {
181 terminal_ui::print_status_skip("No agent rules needed");
182 }
183
184 for target in &targets {
186 if !target.detect_path.exists() || target.agent_key.is_empty() {
187 continue;
188 }
189 let mode = recommend_hook_mode(&target.agent_key);
190 crate::hooks::install_agent_hook_with_mode(&target.agent_key, true, mode);
191 }
192
193 terminal_ui::print_step_header(5, 11, "API Proxy");
195 let proxy_port = crate::proxy_setup::default_port();
196 crate::proxy_autostart::install(proxy_port, false);
197 std::thread::sleep(std::time::Duration::from_millis(500));
199 crate::proxy_setup::install_proxy_env(&home, proxy_port, false);
200
201 terminal_ui::print_step_header(6, 11, "Skill Files");
203 let skill_result = install_skill_files(&home);
204 for (name, installed) in &skill_result {
205 if *installed {
206 terminal_ui::print_status_new(&format!("{name:<20} \x1b[2mSKILL.md installed\x1b[0m"));
207 } else {
208 terminal_ui::print_status_ok(&format!("{name:<20} \x1b[2mSKILL.md up-to-date\x1b[0m"));
209 }
210 }
211 if skill_result.is_empty() {
212 terminal_ui::print_status_skip("No skill directories to install");
213 }
214
215 terminal_ui::print_step_header(7, 11, "Environment Check");
217 let lean_dir = home.join(".lean-ctx");
218 if lean_dir.exists() {
219 terminal_ui::print_status_ok("~/.lean-ctx/ ready");
220 } else {
221 let _ = std::fs::create_dir_all(&lean_dir);
222 terminal_ui::print_status_new("Created ~/.lean-ctx/");
223 }
224 crate::doctor::run_compact();
225
226 terminal_ui::print_step_header(8, 11, "Help Improve lean-ctx");
228 println!(" Share anonymous compression stats to make lean-ctx better.");
229 println!(" \x1b[1mNo code, no file names, no personal data — ever.\x1b[0m");
230 println!();
231 print!(" Enable anonymous data sharing? \x1b[1m[y/N]\x1b[0m ");
232 use std::io::Write;
233 std::io::stdout().flush().ok();
234
235 let mut input = String::new();
236 let contribute = if std::io::stdin().read_line(&mut input).is_ok() {
237 let answer = input.trim().to_lowercase();
238 answer == "y" || answer == "yes"
239 } else {
240 false
241 };
242
243 if contribute {
244 let config_dir = home.join(".lean-ctx");
245 let _ = std::fs::create_dir_all(&config_dir);
246 let config_path = config_dir.join("config.toml");
247 let mut config_content = std::fs::read_to_string(&config_path).unwrap_or_default();
248 if !config_content.contains("[cloud]") {
249 if !config_content.is_empty() && !config_content.ends_with('\n') {
250 config_content.push('\n');
251 }
252 config_content.push_str("\n[cloud]\ncontribute_enabled = true\n");
253 let _ = std::fs::write(&config_path, config_content);
254 }
255 terminal_ui::print_status_ok("Enabled — thank you!");
256 } else {
257 terminal_ui::print_status_skip("Skipped — enable later with: lean-ctx config");
258 }
259
260 terminal_ui::print_step_header(9, 11, "Auto-Updates");
262 println!(" Keep lean-ctx up to date automatically.");
263 println!(" \x1b[1mChecks GitHub every 6h, installs only when a new release exists.\x1b[0m");
264 println!(
265 " \x1b[2mNo restarts mid-session. Change anytime: lean-ctx update --schedule off\x1b[0m"
266 );
267 println!();
268 print!(" Enable automatic updates? \x1b[1m[y/N]\x1b[0m ");
269 std::io::stdout().flush().ok();
270
271 let mut auto_input = String::new();
272 let auto_update = if std::io::stdin().read_line(&mut auto_input).is_ok() {
273 let answer = auto_input.trim().to_lowercase();
274 answer == "y" || answer == "yes"
275 } else {
276 false
277 };
278
279 if auto_update {
280 let cfg = crate::core::config::Config::load();
281 let hours = cfg.updates.check_interval_hours;
282 match crate::core::update_scheduler::install_schedule(hours) {
283 Ok(info) => {
284 crate::core::update_scheduler::set_auto_update(true, false, hours);
285 terminal_ui::print_status_ok(&format!("Enabled — {info}"));
286 }
287 Err(e) => {
288 terminal_ui::print_status_warn(&format!("Scheduler setup failed: {e}"));
289 terminal_ui::print_status_skip("Enable later: lean-ctx update --schedule");
290 }
291 }
292 } else {
293 crate::core::update_scheduler::set_auto_update(false, false, 6);
294 terminal_ui::print_status_skip("Skipped — enable later: lean-ctx update --schedule");
295 }
296
297 terminal_ui::print_step_header(10, 11, "Premium Features");
299 configure_premium_features(&home);
300
301 terminal_ui::print_step_header(11, 11, "Code Intelligence");
303 let cwd = std::env::current_dir().ok();
304 let cwd_is_home = cwd
305 .as_ref()
306 .is_some_and(|d| dirs::home_dir().is_some_and(|h| d.as_path() == h.as_path()));
307 if cwd_is_home {
308 terminal_ui::print_status_warn(
309 "Running from $HOME — graph build skipped to avoid scanning your entire home directory.",
310 );
311 println!();
312 println!(" \x1b[1mSet a default project root to avoid this:\x1b[0m");
313 println!(" \x1b[2mEnter your main project path (or press Enter to skip):\x1b[0m");
314 print!(" \x1b[1m>\x1b[0m ");
315 use std::io::Write;
316 std::io::stdout().flush().ok();
317 let mut root_input = String::new();
318 if std::io::stdin().read_line(&mut root_input).is_ok() {
319 let root_trimmed = root_input.trim();
320 if root_trimmed.is_empty() {
321 terminal_ui::print_status_skip("No project root set. Set later: lean-ctx config set project_root /path/to/project");
322 } else {
323 let root_path = std::path::Path::new(root_trimmed);
324 if root_path.exists() && root_path.is_dir() {
325 let config_path = home.join(".lean-ctx").join("config.toml");
326 let mut content = std::fs::read_to_string(&config_path).unwrap_or_default();
327 if content.contains("project_root") {
328 if let Ok(re) = regex::Regex::new(r#"(?m)^project_root\s*=\s*"[^"]*""#) {
329 content = re
330 .replace(&content, &format!("project_root = \"{root_trimmed}\""))
331 .to_string();
332 }
333 } else {
334 if !content.is_empty() && !content.ends_with('\n') {
335 content.push('\n');
336 }
337 content.push_str(&format!("project_root = \"{root_trimmed}\"\n"));
338 }
339 let _ = std::fs::write(&config_path, &content);
340 terminal_ui::print_status_ok(&format!("Project root set: {root_trimmed}"));
341 if root_path.join(".git").exists()
342 || root_path.join("Cargo.toml").exists()
343 || root_path.join("package.json").exists()
344 {
345 spawn_index_build_background(root_path);
346 terminal_ui::print_status_ok("Graph build started (background)");
347 }
348 } else {
349 terminal_ui::print_status_warn(&format!(
350 "Path not found: {root_trimmed} — skipped"
351 ));
352 }
353 }
354 }
355 } else {
356 let is_project = cwd.as_ref().is_some_and(|d| {
357 d.join(".git").exists()
358 || d.join("Cargo.toml").exists()
359 || d.join("package.json").exists()
360 || d.join("go.mod").exists()
361 });
362 if is_project {
363 println!(" \x1b[2mBuilding code graph for graph-aware reads, impact analysis,\x1b[0m");
364 println!(" \x1b[2mand smart search fusion in the background...\x1b[0m");
365 if let Some(ref root) = cwd {
366 spawn_index_build_background(root);
367 }
368 terminal_ui::print_status_ok("Graph build started (background)");
369 } else {
370 println!(
371 " \x1b[2mRun `lean-ctx impact build` inside any git project to enable\x1b[0m"
372 );
373 println!(
374 " \x1b[2mgraph-aware reads, impact analysis, and smart search fusion.\x1b[0m"
375 );
376 }
377 }
378 println!();
379
380 {
382 let tools = crate::core::editor_registry::writers::auto_approve_tools();
383 println!();
384 println!(
385 " \x1b[33m⚡ Auto-approved tools ({} total):\x1b[0m",
386 tools.len()
387 );
388 for chunk in tools.chunks(6) {
389 let names: Vec<_> = chunk.iter().map(|t| format!("\x1b[2m{t}\x1b[0m")).collect();
390 println!(" {}", names.join(", "));
391 }
392 println!(" \x1b[2mDisable with: lean-ctx setup --no-auto-approve\x1b[0m");
393 }
394
395 println!();
397 println!(
398 " \x1b[1;32m✓ Setup complete!\x1b[0m \x1b[1m{}\x1b[0m configured, \x1b[2m{} already set, {} skipped\x1b[0m",
399 newly_configured.len(),
400 already_configured.len(),
401 not_installed.len()
402 );
403
404 if !errors.is_empty() {
405 println!(
406 " \x1b[33m⚠ {} error{}: {}\x1b[0m",
407 errors.len(),
408 if errors.len() == 1 { "" } else { "s" },
409 errors.join(", ")
410 );
411 }
412
413 let shell = std::env::var("SHELL").unwrap_or_default();
415 let source_cmd = if shell.contains("zsh") {
416 "source ~/.zshrc"
417 } else if shell.contains("fish") {
418 "source ~/.config/fish/config.fish"
419 } else if shell.contains("bash") {
420 "source ~/.bashrc"
421 } else {
422 "Restart your shell"
423 };
424
425 let dim = "\x1b[2m";
426 let bold = "\x1b[1m";
427 let cyan = "\x1b[36m";
428 let yellow = "\x1b[33m";
429 let rst = "\x1b[0m";
430
431 println!();
432 println!(" {bold}Next steps:{rst}");
433 println!();
434 println!(" {cyan}1.{rst} Reload your shell:");
435 println!(" {bold}{source_cmd}{rst}");
436 println!();
437
438 let mut tools_to_restart: Vec<String> = newly_configured
439 .iter()
440 .map(std::string::ToString::to_string)
441 .collect();
442 for name in rules_result
443 .injected
444 .iter()
445 .chain(rules_result.updated.iter())
446 {
447 if !tools_to_restart.iter().any(|t| t == name) {
448 tools_to_restart.push(name.clone());
449 }
450 }
451
452 if !tools_to_restart.is_empty() {
453 println!(" {cyan}2.{rst} {yellow}{bold}Restart your IDE / AI tool:{rst}");
454 println!(" {bold}{}{rst}", tools_to_restart.join(", "));
455 println!(
456 " {dim}Changes take effect after a full restart (MCP may be enabled or disabled depending on mode).{rst}"
457 );
458 println!(" {dim}Close and re-open the application completely.{rst}");
459 } else if !already_configured.is_empty() {
460 println!(
461 " {cyan}2.{rst} {dim}Your tools are already configured — no restart needed.{rst}"
462 );
463 }
464
465 println!();
466 println!(
467 " {dim}After restart, lean-ctx will automatically optimize every AI interaction.{rst}"
468 );
469 println!(" {dim}Verify with:{rst} {bold}lean-ctx gain{rst}");
470
471 println!();
473 terminal_ui::print_logo_animated();
474 terminal_ui::print_command_box();
475}
476
477#[derive(Debug, Clone, Copy, Default)]
478pub struct SetupOptions {
479 pub non_interactive: bool,
480 pub yes: bool,
481 pub fix: bool,
482 pub json: bool,
483 pub no_auto_approve: bool,
484}
485
486pub fn run_setup_with_options(opts: SetupOptions) -> Result<SetupReport, String> {
487 let _quiet_guard = opts.json.then(|| EnvVarGuard::set("LEAN_CTX_QUIET", "1"));
488 let started_at = Utc::now();
489 let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
490 let binary = resolve_portable_binary();
491 let home_str = home.to_string_lossy().to_string();
492
493 let mut steps: Vec<SetupStepReport> = Vec::new();
494
495 let mut shell_step = SetupStepReport {
497 name: "shell_hook".to_string(),
498 ok: true,
499 items: Vec::new(),
500 warnings: Vec::new(),
501 errors: Vec::new(),
502 };
503 if !opts.non_interactive || opts.yes {
504 if opts.json {
505 crate::cli::cmd_init_quiet(&["--global".to_string()]);
506 } else {
507 crate::cli::cmd_init(&["--global".to_string()]);
508 }
509 crate::shell_hook::install_all(opts.json);
510 #[cfg(not(windows))]
511 {
512 let hook_content = crate::cli::generate_hook_posix(&binary);
513 if crate::shell::is_container() {
514 crate::cli::write_env_sh_for_containers(&hook_content);
515 shell_step.items.push(SetupItem {
516 name: "env_sh".to_string(),
517 status: "created".to_string(),
518 path: Some("~/.lean-ctx/env.sh".to_string()),
519 note: Some("Docker/CI helper (BASH_ENV / CLAUDE_ENV_FILE)".to_string()),
520 });
521 } else {
522 shell_step.items.push(SetupItem {
523 name: "env_sh".to_string(),
524 status: "skipped".to_string(),
525 path: None,
526 note: Some("not a container environment".to_string()),
527 });
528 }
529 }
530 shell_step.items.push(SetupItem {
531 name: "init --global".to_string(),
532 status: "ran".to_string(),
533 path: None,
534 note: None,
535 });
536 shell_step.items.push(SetupItem {
537 name: "universal_shell_hook".to_string(),
538 status: "installed".to_string(),
539 path: None,
540 note: Some("~/.zshenv, ~/.bashenv, agent aliases".to_string()),
541 });
542 } else {
543 shell_step
544 .warnings
545 .push("non_interactive_without_yes: shell hook not installed (use --yes)".to_string());
546 shell_step.ok = false;
547 shell_step.items.push(SetupItem {
548 name: "init --global".to_string(),
549 status: "skipped".to_string(),
550 path: None,
551 note: Some("requires --yes in --non-interactive mode".to_string()),
552 });
553 }
554 steps.push(shell_step);
555
556 let mut daemon_step = SetupStepReport {
558 name: "daemon".to_string(),
559 ok: true,
560 items: Vec::new(),
561 warnings: Vec::new(),
562 errors: Vec::new(),
563 };
564 {
565 let was_running = crate::daemon::is_daemon_running();
566 if was_running {
567 let _ = crate::daemon::stop_daemon();
568 std::thread::sleep(std::time::Duration::from_millis(500));
569 }
570 match crate::daemon::start_daemon(&[]) {
571 Ok(()) => {
572 let action = if was_running { "restarted" } else { "started" };
573 daemon_step.items.push(SetupItem {
574 name: "serve --daemon".to_string(),
575 status: action.to_string(),
576 path: Some(crate::daemon::daemon_addr().display()),
577 note: Some("CLI commands can route via IPC when running".to_string()),
578 });
579 }
580 Err(e) => {
581 daemon_step
582 .warnings
583 .push(format!("daemon start failed (non-fatal): {e}"));
584 daemon_step.items.push(SetupItem {
585 name: "serve --daemon".to_string(),
586 status: "skipped".to_string(),
587 path: None,
588 note: Some(format!("optional — {e}")),
589 });
590 }
591 }
592 }
593 steps.push(daemon_step);
594
595 let mut editor_step = SetupStepReport {
597 name: "editors".to_string(),
598 ok: true,
599 items: Vec::new(),
600 warnings: Vec::new(),
601 errors: Vec::new(),
602 };
603
604 let targets = crate::core::editor_registry::build_targets(&home);
605 for target in &targets {
606 let short_path = shorten_path(&target.config_path.to_string_lossy(), &home_str);
607 if !target.detect_path.exists() {
608 editor_step.items.push(SetupItem {
609 name: target.name.to_string(),
610 status: "not_detected".to_string(),
611 path: Some(short_path),
612 note: None,
613 });
614 continue;
615 }
616
617 let mode = if target.agent_key.is_empty() {
618 HookMode::Mcp
619 } else {
620 recommend_hook_mode(&target.agent_key)
621 };
622
623 let res = crate::core::editor_registry::write_config_with_options(
624 target,
625 &binary,
626 WriteOptions {
627 overwrite_invalid: opts.fix,
628 },
629 );
630 match res {
631 Ok(w) => {
632 let note_parts: Vec<String> = [Some(format!("mode={mode}")), w.note]
633 .into_iter()
634 .flatten()
635 .collect();
636 editor_step.items.push(SetupItem {
637 name: target.name.to_string(),
638 status: match w.action {
639 WriteAction::Created => "created".to_string(),
640 WriteAction::Updated => "updated".to_string(),
641 WriteAction::Already => "already".to_string(),
642 },
643 path: Some(short_path),
644 note: Some(note_parts.join("; ")),
645 });
646 }
647 Err(e) => {
648 editor_step.ok = false;
649 editor_step.items.push(SetupItem {
650 name: target.name.to_string(),
651 status: "error".to_string(),
652 path: Some(short_path),
653 note: Some(e),
654 });
655 }
656 }
657 }
658 steps.push(editor_step);
659
660 let mut rules_step = SetupStepReport {
662 name: "agent_rules".to_string(),
663 ok: true,
664 items: Vec::new(),
665 warnings: Vec::new(),
666 errors: Vec::new(),
667 };
668 let rules_result = crate::rules_inject::inject_all_rules(&home);
669 for n in rules_result.injected {
670 rules_step.items.push(SetupItem {
671 name: n,
672 status: "injected".to_string(),
673 path: None,
674 note: None,
675 });
676 }
677 for n in rules_result.updated {
678 rules_step.items.push(SetupItem {
679 name: n,
680 status: "updated".to_string(),
681 path: None,
682 note: None,
683 });
684 }
685 for n in rules_result.already {
686 rules_step.items.push(SetupItem {
687 name: n,
688 status: "already".to_string(),
689 path: None,
690 note: None,
691 });
692 }
693 for e in rules_result.errors {
694 rules_step.ok = false;
695 rules_step.errors.push(e);
696 }
697 steps.push(rules_step);
698
699 let mut skill_step = SetupStepReport {
701 name: "skill_files".to_string(),
702 ok: true,
703 items: Vec::new(),
704 warnings: Vec::new(),
705 errors: Vec::new(),
706 };
707 let skill_results = crate::rules_inject::install_all_skills(&home);
708 for (name, is_new) in &skill_results {
709 skill_step.items.push(SetupItem {
710 name: name.clone(),
711 status: if *is_new { "installed" } else { "already" }.to_string(),
712 path: None,
713 note: Some("SKILL.md".to_string()),
714 });
715 }
716 if !skill_step.items.is_empty() {
717 steps.push(skill_step);
718 }
719
720 let mut hooks_step = SetupStepReport {
722 name: "agent_hooks".to_string(),
723 ok: true,
724 items: Vec::new(),
725 warnings: Vec::new(),
726 errors: Vec::new(),
727 };
728 for target in &targets {
729 if !target.detect_path.exists() || target.agent_key.is_empty() {
730 continue;
731 }
732 let mode = recommend_hook_mode(&target.agent_key);
733 crate::hooks::install_agent_hook_with_mode(&target.agent_key, true, mode);
734 let mcp_note = match configure_agent_mcp(&target.agent_key) {
735 Ok(()) => "; MCP config updated".to_string(),
736 Err(e) => format!("; MCP config skipped: {e}"),
737 };
738 hooks_step.items.push(SetupItem {
739 name: format!("{} hooks", target.name),
740 status: "installed".to_string(),
741 path: Some(target.detect_path.to_string_lossy().to_string()),
742 note: Some(format!(
743 "mode={mode}; merge-based install/repair (preserves other hooks/plugins){mcp_note}"
744 )),
745 });
746 }
747 if !hooks_step.items.is_empty() {
748 steps.push(hooks_step);
749 }
750
751 let mut proxy_step = SetupStepReport {
753 name: "proxy".to_string(),
754 ok: true,
755 items: Vec::new(),
756 warnings: Vec::new(),
757 errors: Vec::new(),
758 };
759 let proxy_port = crate::proxy_setup::default_port();
760 crate::proxy_autostart::install(proxy_port, true);
761 std::thread::sleep(std::time::Duration::from_millis(500));
762 crate::proxy_setup::install_proxy_env(&home, proxy_port, opts.json);
763 proxy_step.items.push(SetupItem {
764 name: "proxy_autostart".to_string(),
765 status: "installed".to_string(),
766 path: None,
767 note: Some("LaunchAgent/systemd auto-start on login".to_string()),
768 });
769 proxy_step.items.push(SetupItem {
770 name: "proxy_env".to_string(),
771 status: "configured".to_string(),
772 path: None,
773 note: Some("ANTHROPIC_BASE_URL, OPENAI_BASE_URL, GEMINI_API_BASE_URL".to_string()),
774 });
775 steps.push(proxy_step);
776
777 let mut env_step = SetupStepReport {
779 name: "doctor_compact".to_string(),
780 ok: true,
781 items: Vec::new(),
782 warnings: Vec::new(),
783 errors: Vec::new(),
784 };
785 let (passed, total) = crate::doctor::compact_score();
786 env_step.items.push(SetupItem {
787 name: "doctor".to_string(),
788 status: format!("{passed}/{total}"),
789 path: None,
790 note: None,
791 });
792 if passed != total {
793 env_step.warnings.push(format!(
794 "doctor compact not fully passing: {passed}/{total}"
795 ));
796 }
797 steps.push(env_step);
798
799 {
801 let has_env_root = std::env::var("LEAN_CTX_PROJECT_ROOT")
802 .ok()
803 .is_some_and(|v| !v.is_empty());
804 let cfg = crate::core::config::Config::load();
805 let has_cfg_root = cfg.project_root.as_ref().is_some_and(|v| !v.is_empty());
806 if !has_env_root && !has_cfg_root {
807 if let Ok(cwd) = std::env::current_dir() {
808 let is_home = dirs::home_dir().is_some_and(|h| cwd == h);
809 if is_home {
810 let mut root_step = SetupStepReport {
811 name: "project_root".to_string(),
812 ok: true,
813 items: Vec::new(),
814 warnings: vec![
815 "No project_root configured. Running from $HOME can cause excessive scanning. \
816 Set via: lean-ctx config set project_root /path/to/project".to_string()
817 ],
818 errors: Vec::new(),
819 };
820 root_step.items.push(SetupItem {
821 name: "project_root".to_string(),
822 status: "unconfigured".to_string(),
823 path: None,
824 note: Some(
825 "Set LEAN_CTX_PROJECT_ROOT or add project_root to config.toml"
826 .to_string(),
827 ),
828 });
829 steps.push(root_step);
830 }
831 }
832 }
833 }
834
835 if let Ok(cwd) = std::env::current_dir() {
837 let is_project = cwd.join(".git").exists()
838 || cwd.join("Cargo.toml").exists()
839 || cwd.join("package.json").exists()
840 || cwd.join("go.mod").exists();
841 if is_project {
842 spawn_index_build_background(&cwd);
843 }
844 }
845
846 let finished_at = Utc::now();
847 let success = steps.iter().all(|s| s.ok);
848 let report = SetupReport {
849 schema_version: 1,
850 started_at,
851 finished_at,
852 success,
853 platform: PlatformInfo {
854 os: std::env::consts::OS.to_string(),
855 arch: std::env::consts::ARCH.to_string(),
856 },
857 steps,
858 warnings: Vec::new(),
859 errors: Vec::new(),
860 };
861
862 let path = SetupReport::default_path()?;
863 let mut content =
864 serde_json::to_string_pretty(&report).map_err(|e| format!("serialize report: {e}"))?;
865 content.push('\n');
866 crate::config_io::write_atomic(&path, &content)?;
867
868 Ok(report)
869}
870
871fn spawn_index_build_background(root: &std::path::Path) {
872 if std::env::var("LEAN_CTX_DISABLED").is_ok()
873 || matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
874 {
875 return;
876 }
877 let root_str = crate::core::graph_index::normalize_project_root(&root.to_string_lossy());
878 if !crate::core::graph_index::is_safe_scan_root_public(&root_str) {
879 tracing::info!("[setup: skipping background graph build for unsafe root {root_str}]");
880 return;
881 }
882
883 let binary = std::env::current_exe().map_or_else(
884 |_| resolve_portable_binary(),
885 |p| p.to_string_lossy().to_string(),
886 );
887
888 #[cfg(unix)]
889 {
890 let mut cmd = std::process::Command::new("nice");
891 cmd.args(["-n", "19"]);
892 if which_ionice_available() {
893 cmd.arg("ionice").args(["-c", "3"]);
894 }
895 cmd.arg(&binary)
896 .args(["index", "build-graph", "--root"])
897 .arg(root)
898 .stdout(std::process::Stdio::null())
899 .stderr(std::process::Stdio::null())
900 .stdin(std::process::Stdio::null());
901 let _ = cmd.spawn();
902 }
903
904 #[cfg(windows)]
905 {
906 use std::os::windows::process::CommandExt;
907 const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
908 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
909 let _ = std::process::Command::new(&binary)
910 .args(["index", "build-graph", "--root"])
911 .arg(root)
912 .stdout(std::process::Stdio::null())
913 .stderr(std::process::Stdio::null())
914 .stdin(std::process::Stdio::null())
915 .creation_flags(CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW)
916 .spawn();
917 }
918}
919
920#[cfg(unix)]
921fn which_ionice_available() -> bool {
922 std::process::Command::new("ionice")
923 .arg("--version")
924 .stdout(std::process::Stdio::null())
925 .stderr(std::process::Stdio::null())
926 .status()
927 .is_ok()
928}
929
930pub fn configure_agent_mcp(agent: &str) -> Result<(), String> {
931 let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
932 let binary = resolve_portable_binary();
933
934 let targets = agent_mcp_targets(agent, &home)?;
935
936 for t in &targets {
937 crate::core::editor_registry::write_config_with_options(
938 t,
939 &binary,
940 WriteOptions {
941 overwrite_invalid: true,
942 },
943 )?;
944 }
945
946 if agent == "kiro" {
947 install_kiro_steering(&home);
948 }
949
950 Ok(())
951}
952
953fn agent_mcp_targets(agent: &str, home: &std::path::Path) -> Result<Vec<EditorTarget>, String> {
954 let mut targets = Vec::<EditorTarget>::new();
955
956 let push = |targets: &mut Vec<EditorTarget>,
957 name: &'static str,
958 config_path: PathBuf,
959 config_type: ConfigType| {
960 targets.push(EditorTarget {
961 name,
962 agent_key: agent.to_string(),
963 detect_path: PathBuf::from("/nonexistent"), config_path,
965 config_type,
966 });
967 };
968
969 let pi_cfg = home.join(".pi").join("agent").join("mcp.json");
970
971 match agent {
972 "cursor" => push(
973 &mut targets,
974 "Cursor",
975 home.join(".cursor/mcp.json"),
976 ConfigType::McpJson,
977 ),
978 "claude" | "claude-code" => push(
979 &mut targets,
980 "Claude Code",
981 crate::core::editor_registry::claude_mcp_json_path(home),
982 ConfigType::McpJson,
983 ),
984 "windsurf" => push(
985 &mut targets,
986 "Windsurf",
987 home.join(".codeium/windsurf/mcp_config.json"),
988 ConfigType::McpJson,
989 ),
990 "codex" => {
991 let codex_dir =
992 crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
993 push(
994 &mut targets,
995 "Codex CLI",
996 codex_dir.join("config.toml"),
997 ConfigType::Codex,
998 );
999 }
1000 "gemini" => {
1001 push(
1002 &mut targets,
1003 "Gemini CLI",
1004 home.join(".gemini/settings.json"),
1005 ConfigType::GeminiSettings,
1006 );
1007 push(
1008 &mut targets,
1009 "Antigravity",
1010 home.join(".gemini/antigravity/mcp_config.json"),
1011 ConfigType::McpJson,
1012 );
1013 }
1014 "antigravity" => push(
1015 &mut targets,
1016 "Antigravity",
1017 home.join(".gemini/antigravity/mcp_config.json"),
1018 ConfigType::McpJson,
1019 ),
1020 "copilot" => push(
1021 &mut targets,
1022 "VS Code / Copilot",
1023 crate::core::editor_registry::vscode_mcp_path(),
1024 ConfigType::VsCodeMcp,
1025 ),
1026 "crush" => push(
1027 &mut targets,
1028 "Crush",
1029 home.join(".config/crush/crush.json"),
1030 ConfigType::Crush,
1031 ),
1032 "pi" => push(&mut targets, "Pi Coding Agent", pi_cfg, ConfigType::McpJson),
1033 "qoder" => {
1034 for path in crate::core::editor_registry::qoder_all_mcp_paths(home) {
1035 push(&mut targets, "Qoder", path, ConfigType::QoderSettings);
1036 }
1037 }
1038 "qoderwork" => push(
1039 &mut targets,
1040 "QoderWork",
1041 crate::core::editor_registry::qoderwork_mcp_path(home),
1042 ConfigType::McpJson,
1043 ),
1044 "cline" => push(
1045 &mut targets,
1046 "Cline",
1047 crate::core::editor_registry::cline_mcp_path(),
1048 ConfigType::McpJson,
1049 ),
1050 "roo" => push(
1051 &mut targets,
1052 "Roo Code",
1053 crate::core::editor_registry::roo_mcp_path(),
1054 ConfigType::McpJson,
1055 ),
1056 "kiro" => push(
1057 &mut targets,
1058 "AWS Kiro",
1059 home.join(".kiro/settings/mcp.json"),
1060 ConfigType::McpJson,
1061 ),
1062 "verdent" => push(
1063 &mut targets,
1064 "Verdent",
1065 home.join(".verdent/mcp.json"),
1066 ConfigType::McpJson,
1067 ),
1068 "jetbrains" | "amp" => {
1069 }
1071 "qwen" => push(
1072 &mut targets,
1073 "Qwen Code",
1074 home.join(".qwen/settings.json"),
1075 ConfigType::McpJson,
1076 ),
1077 "trae" => push(
1078 &mut targets,
1079 "Trae",
1080 home.join(".trae/mcp.json"),
1081 ConfigType::McpJson,
1082 ),
1083 "amazonq" => push(
1084 &mut targets,
1085 "Amazon Q Developer",
1086 home.join(".aws/amazonq/default.json"),
1087 ConfigType::McpJson,
1088 ),
1089 "opencode" => {
1090 #[cfg(windows)]
1091 let opencode_path = if let Ok(appdata) = std::env::var("APPDATA") {
1092 std::path::PathBuf::from(appdata)
1093 .join("opencode")
1094 .join("opencode.json")
1095 } else {
1096 home.join(".config/opencode/opencode.json")
1097 };
1098 #[cfg(not(windows))]
1099 let opencode_path = home.join(".config/opencode/opencode.json");
1100 push(
1101 &mut targets,
1102 "OpenCode",
1103 opencode_path,
1104 ConfigType::OpenCode,
1105 );
1106 }
1107 "hermes" => push(
1108 &mut targets,
1109 "Hermes Agent",
1110 home.join(".hermes/config.yaml"),
1111 ConfigType::HermesYaml,
1112 ),
1113 "vscode" => push(
1114 &mut targets,
1115 "VS Code",
1116 crate::core::editor_registry::vscode_mcp_path(),
1117 ConfigType::VsCodeMcp,
1118 ),
1119 "zed" => push(
1120 &mut targets,
1121 "Zed",
1122 crate::core::editor_registry::zed_settings_path(home),
1123 ConfigType::Zed,
1124 ),
1125 "aider" => push(
1126 &mut targets,
1127 "Aider",
1128 home.join(".aider/mcp.json"),
1129 ConfigType::McpJson,
1130 ),
1131 "continue" => push(
1132 &mut targets,
1133 "Continue",
1134 home.join(".continue/mcp.json"),
1135 ConfigType::McpJson,
1136 ),
1137 "neovim" => push(
1138 &mut targets,
1139 "Neovim (mcphub.nvim)",
1140 home.join(".config/mcphub/servers.json"),
1141 ConfigType::McpJson,
1142 ),
1143 "emacs" => push(
1144 &mut targets,
1145 "Emacs (mcp.el)",
1146 home.join(".emacs.d/mcp.json"),
1147 ConfigType::McpJson,
1148 ),
1149 "sublime" => push(
1150 &mut targets,
1151 "Sublime Text",
1152 home.join(".config/sublime-text/mcp.json"),
1153 ConfigType::McpJson,
1154 ),
1155 _ => {
1156 return Err(format!("Unknown agent '{agent}'"));
1157 }
1158 }
1159
1160 Ok(targets)
1161}
1162
1163pub fn disable_agent_mcp(agent: &str, overwrite_invalid: bool) -> Result<(), String> {
1164 let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
1165
1166 let mut targets = Vec::<EditorTarget>::new();
1167
1168 let push = |targets: &mut Vec<EditorTarget>,
1169 name: &'static str,
1170 config_path: PathBuf,
1171 config_type: ConfigType| {
1172 targets.push(EditorTarget {
1173 name,
1174 agent_key: agent.to_string(),
1175 detect_path: PathBuf::from("/nonexistent"),
1176 config_path,
1177 config_type,
1178 });
1179 };
1180
1181 let pi_cfg = home.join(".pi").join("agent").join("mcp.json");
1182
1183 match agent {
1184 "cursor" => push(
1185 &mut targets,
1186 "Cursor",
1187 home.join(".cursor/mcp.json"),
1188 ConfigType::McpJson,
1189 ),
1190 "claude" | "claude-code" => push(
1191 &mut targets,
1192 "Claude Code",
1193 crate::core::editor_registry::claude_mcp_json_path(&home),
1194 ConfigType::McpJson,
1195 ),
1196 "windsurf" => push(
1197 &mut targets,
1198 "Windsurf",
1199 home.join(".codeium/windsurf/mcp_config.json"),
1200 ConfigType::McpJson,
1201 ),
1202 "codex" => {
1203 let codex_dir =
1204 crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
1205 push(
1206 &mut targets,
1207 "Codex CLI",
1208 codex_dir.join("config.toml"),
1209 ConfigType::Codex,
1210 );
1211 }
1212 "gemini" => {
1213 push(
1214 &mut targets,
1215 "Gemini CLI",
1216 home.join(".gemini/settings.json"),
1217 ConfigType::GeminiSettings,
1218 );
1219 push(
1220 &mut targets,
1221 "Antigravity",
1222 home.join(".gemini/antigravity/mcp_config.json"),
1223 ConfigType::McpJson,
1224 );
1225 }
1226 "antigravity" => push(
1227 &mut targets,
1228 "Antigravity",
1229 home.join(".gemini/antigravity/mcp_config.json"),
1230 ConfigType::McpJson,
1231 ),
1232 "copilot" => push(
1233 &mut targets,
1234 "VS Code / Copilot",
1235 crate::core::editor_registry::vscode_mcp_path(),
1236 ConfigType::VsCodeMcp,
1237 ),
1238 "crush" => push(
1239 &mut targets,
1240 "Crush",
1241 home.join(".config/crush/crush.json"),
1242 ConfigType::Crush,
1243 ),
1244 "pi" => push(&mut targets, "Pi Coding Agent", pi_cfg, ConfigType::McpJson),
1245 "qoder" => {
1246 for path in crate::core::editor_registry::qoder_all_mcp_paths(&home) {
1247 push(&mut targets, "Qoder", path, ConfigType::QoderSettings);
1248 }
1249 }
1250 "qoderwork" => push(
1251 &mut targets,
1252 "QoderWork",
1253 crate::core::editor_registry::qoderwork_mcp_path(&home),
1254 ConfigType::McpJson,
1255 ),
1256 "cline" => push(
1257 &mut targets,
1258 "Cline",
1259 crate::core::editor_registry::cline_mcp_path(),
1260 ConfigType::McpJson,
1261 ),
1262 "roo" => push(
1263 &mut targets,
1264 "Roo Code",
1265 crate::core::editor_registry::roo_mcp_path(),
1266 ConfigType::McpJson,
1267 ),
1268 "kiro" => push(
1269 &mut targets,
1270 "AWS Kiro",
1271 home.join(".kiro/settings/mcp.json"),
1272 ConfigType::McpJson,
1273 ),
1274 "verdent" => push(
1275 &mut targets,
1276 "Verdent",
1277 home.join(".verdent/mcp.json"),
1278 ConfigType::McpJson,
1279 ),
1280 "jetbrains" | "amp" => {
1281 }
1283 "qwen" => push(
1284 &mut targets,
1285 "Qwen Code",
1286 home.join(".qwen/settings.json"),
1287 ConfigType::McpJson,
1288 ),
1289 "trae" => push(
1290 &mut targets,
1291 "Trae",
1292 home.join(".trae/mcp.json"),
1293 ConfigType::McpJson,
1294 ),
1295 "amazonq" => push(
1296 &mut targets,
1297 "Amazon Q Developer",
1298 home.join(".aws/amazonq/default.json"),
1299 ConfigType::McpJson,
1300 ),
1301 "opencode" => {
1302 #[cfg(windows)]
1303 let opencode_path = if let Ok(appdata) = std::env::var("APPDATA") {
1304 std::path::PathBuf::from(appdata)
1305 .join("opencode")
1306 .join("opencode.json")
1307 } else {
1308 home.join(".config/opencode/opencode.json")
1309 };
1310 #[cfg(not(windows))]
1311 let opencode_path = home.join(".config/opencode/opencode.json");
1312 push(
1313 &mut targets,
1314 "OpenCode",
1315 opencode_path,
1316 ConfigType::OpenCode,
1317 );
1318 }
1319 "hermes" => push(
1320 &mut targets,
1321 "Hermes Agent",
1322 home.join(".hermes/config.yaml"),
1323 ConfigType::HermesYaml,
1324 ),
1325 "vscode" => push(
1326 &mut targets,
1327 "VS Code",
1328 crate::core::editor_registry::vscode_mcp_path(),
1329 ConfigType::VsCodeMcp,
1330 ),
1331 "zed" => push(
1332 &mut targets,
1333 "Zed",
1334 crate::core::editor_registry::zed_settings_path(&home),
1335 ConfigType::Zed,
1336 ),
1337 "aider" => push(
1338 &mut targets,
1339 "Aider",
1340 home.join(".aider/mcp.json"),
1341 ConfigType::McpJson,
1342 ),
1343 "continue" => push(
1344 &mut targets,
1345 "Continue",
1346 home.join(".continue/mcp.json"),
1347 ConfigType::McpJson,
1348 ),
1349 "neovim" => push(
1350 &mut targets,
1351 "Neovim (mcphub.nvim)",
1352 home.join(".config/mcphub/servers.json"),
1353 ConfigType::McpJson,
1354 ),
1355 "emacs" => push(
1356 &mut targets,
1357 "Emacs (mcp.el)",
1358 home.join(".emacs.d/mcp.json"),
1359 ConfigType::McpJson,
1360 ),
1361 "sublime" => push(
1362 &mut targets,
1363 "Sublime Text",
1364 home.join(".config/sublime-text/mcp.json"),
1365 ConfigType::McpJson,
1366 ),
1367 _ => {
1368 return Err(format!("Unknown agent '{agent}'"));
1369 }
1370 }
1371
1372 for t in &targets {
1373 crate::core::editor_registry::remove_lean_ctx_server(
1374 t,
1375 WriteOptions { overwrite_invalid },
1376 )?;
1377 }
1378
1379 Ok(())
1380}
1381
1382pub fn install_skill_files(home: &std::path::Path) -> Vec<(String, bool)> {
1383 crate::rules_inject::install_all_skills(home)
1384}
1385
1386fn install_kiro_steering(home: &std::path::Path) {
1387 let cwd = std::env::current_dir().unwrap_or_else(|_| home.to_path_buf());
1388 let steering_dir = cwd.join(".kiro").join("steering");
1389 let steering_file = steering_dir.join("lean-ctx.md");
1390
1391 if steering_file.exists()
1392 && std::fs::read_to_string(&steering_file)
1393 .unwrap_or_default()
1394 .contains("lean-ctx")
1395 {
1396 println!(" Kiro steering file already exists at .kiro/steering/lean-ctx.md");
1397 return;
1398 }
1399
1400 let _ = std::fs::create_dir_all(&steering_dir);
1401 let _ = std::fs::write(&steering_file, crate::hooks::KIRO_STEERING_TEMPLATE);
1402 println!(" \x1b[32m✓\x1b[0m Created .kiro/steering/lean-ctx.md (Kiro will now prefer lean-ctx tools)");
1403}
1404
1405fn shorten_path(path: &str, home: &str) -> String {
1406 if let Some(stripped) = path.strip_prefix(home) {
1407 format!("~{stripped}")
1408 } else {
1409 path.to_string()
1410 }
1411}
1412
1413fn upsert_toml_key(content: &mut String, key: &str, value: &str) {
1414 let pattern = format!("{key} = ");
1415 if let Some(start) = content.find(&pattern) {
1416 let line_end = content[start..]
1417 .find('\n')
1418 .map_or(content.len(), |p| start + p);
1419 content.replace_range(start..line_end, &format!("{key} = \"{value}\""));
1420 } else {
1421 if !content.is_empty() && !content.ends_with('\n') {
1422 content.push('\n');
1423 }
1424 content.push_str(&format!("{key} = \"{value}\"\n"));
1425 }
1426}
1427
1428fn remove_toml_key(content: &mut String, key: &str) {
1429 let pattern = format!("{key} = ");
1430 if let Some(start) = content.find(&pattern) {
1431 let line_end = content[start..]
1432 .find('\n')
1433 .map_or(content.len(), |p| start + p + 1);
1434 content.replace_range(start..line_end, "");
1435 }
1436}
1437
1438fn configure_premium_features(home: &std::path::Path) {
1439 use crate::terminal_ui;
1440 use std::io::Write;
1441
1442 let config_dir = home.join(".lean-ctx");
1443 let _ = std::fs::create_dir_all(&config_dir);
1444 let config_path = config_dir.join("config.toml");
1445 let mut config_content = std::fs::read_to_string(&config_path).unwrap_or_default();
1446
1447 let dim = "\x1b[2m";
1448 let bold = "\x1b[1m";
1449 let cyan = "\x1b[36m";
1450 let rst = "\x1b[0m";
1451
1452 println!("\n {bold}Compression Level{rst} {dim}(controls all token optimization layers){rst}");
1454 println!(" {dim}Applies to tool output, agent prompts, and protocol mode.{rst}");
1455 println!();
1456 println!(" {cyan}off{rst} — No compression (full verbose output)");
1457 println!(" {cyan}lite{rst} — Light: concise output, basic terse filtering {dim}(~25% savings){rst}");
1458 println!(" {cyan}standard{rst} — Dense output + compact protocol + pattern-aware {dim}(~45% savings){rst}");
1459 println!(" {cyan}max{rst} — Expert mode: TDD protocol, all layers active {dim}(~65% savings){rst}");
1460 println!();
1461 print!(" Compression level? {bold}[off/lite/standard/max]{rst} {dim}(default: off){rst} ");
1462 std::io::stdout().flush().ok();
1463
1464 let mut level_input = String::new();
1465 let level = if std::io::stdin().read_line(&mut level_input).is_ok() {
1466 match level_input.trim().to_lowercase().as_str() {
1467 "lite" => "lite",
1468 "standard" | "std" => "standard",
1469 "max" => "max",
1470 _ => "off",
1471 }
1472 } else {
1473 "off"
1474 };
1475
1476 let effective_level = if level != "off" {
1477 upsert_toml_key(&mut config_content, "compression_level", level);
1478 remove_toml_key(&mut config_content, "terse_agent");
1479 remove_toml_key(&mut config_content, "output_density");
1480 terminal_ui::print_status_ok(&format!("Compression: {level}"));
1481 crate::core::config::CompressionLevel::from_str_label(level)
1482 } else if config_content.contains("compression_level") {
1483 upsert_toml_key(&mut config_content, "compression_level", "off");
1484 terminal_ui::print_status_ok("Compression: off");
1485 Some(crate::core::config::CompressionLevel::Off)
1486 } else {
1487 terminal_ui::print_status_skip(
1488 "Compression: off (change later with: lean-ctx compression <level>)",
1489 );
1490 Some(crate::core::config::CompressionLevel::Off)
1491 };
1492
1493 if let Some(lvl) = effective_level {
1494 let n = crate::core::terse::rules_inject::inject(&lvl);
1495 if n > 0 {
1496 terminal_ui::print_status_ok(&format!(
1497 "Updated {n} rules file(s) with compression prompt"
1498 ));
1499 }
1500 }
1501
1502 println!(
1504 "\n {bold}Tool Result Archive{rst} {dim}(zero-loss: large outputs archived, retrievable via ctx_expand){rst}"
1505 );
1506 print!(" Enable auto-archive? {bold}[Y/n]{rst} ");
1507 std::io::stdout().flush().ok();
1508
1509 let mut archive_input = String::new();
1510 let archive_on = if std::io::stdin().read_line(&mut archive_input).is_ok() {
1511 let a = archive_input.trim().to_lowercase();
1512 a.is_empty() || a == "y" || a == "yes"
1513 } else {
1514 true
1515 };
1516
1517 if archive_on && !config_content.contains("[archive]") {
1518 if !config_content.is_empty() && !config_content.ends_with('\n') {
1519 config_content.push('\n');
1520 }
1521 config_content.push_str("\n[archive]\nenabled = true\n");
1522 terminal_ui::print_status_ok("Tool Result Archive: enabled");
1523 } else if !archive_on {
1524 terminal_ui::print_status_skip("Archive: off (enable later in config.toml)");
1525 }
1526
1527 let _ = std::fs::write(&config_path, config_content);
1528}
1529
1530#[cfg(all(test, target_os = "macos"))]
1531mod tests {
1532 use super::*;
1533
1534 #[test]
1535 #[cfg(target_os = "macos")]
1536 fn qoder_agent_targets_include_all_macos_mcp_locations() {
1537 let home = std::path::Path::new("/Users/tester");
1538 let targets = agent_mcp_targets("qoder", home).unwrap();
1539 let paths: Vec<_> = targets.iter().map(|t| t.config_path.as_path()).collect();
1540
1541 assert_eq!(
1542 paths,
1543 vec![
1544 home.join(".qoder/mcp.json").as_path(),
1545 home.join("Library/Application Support/Qoder/User/mcp.json")
1546 .as_path(),
1547 home.join("Library/Application Support/Qoder/SharedClientCache/mcp.json")
1548 .as_path(),
1549 ]
1550 );
1551 assert!(targets
1552 .iter()
1553 .all(|t| t.config_type == ConfigType::QoderSettings));
1554 }
1555}