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