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