1use std::collections::BTreeMap;
8use std::fs::{self, File};
9use std::io::{BufWriter, Write};
10use std::path::{Path, PathBuf};
11use std::time::SystemTime;
12
13use anyhow::{Context, Result, bail};
14use serde_json::Value;
15
16use crate::args::{McpCommand, SetupScope, ToolTarget};
17
18pub fn run(command: &McpCommand) -> Result<()> {
29 match command {
30 McpCommand::Setup {
31 tool,
32 scope,
33 workspace_root,
34 force,
35 dry_run,
36 no_backup,
37 } => run_setup(
38 tool,
39 scope,
40 workspace_root.as_deref(),
41 *force,
42 *dry_run,
43 *no_backup,
44 ),
45 McpCommand::Status { json } => run_status(*json),
46 }
47}
48
49fn find_sqry_mcp_binary() -> Result<PathBuf> {
61 if let Ok(exe) = std::env::current_exe()
63 && let Some(dir) = exe.parent()
64 {
65 let candidate = dir.join("sqry-mcp");
66 if candidate.is_file() {
67 return Ok(candidate);
68 }
69 }
70
71 if let Ok(path) = which::which("sqry-mcp") {
73 return Ok(path);
74 }
75
76 if let Some(home) = dirs::home_dir() {
78 let candidate = home.join(".local/bin/sqry-mcp");
79 if candidate.is_file() {
80 return Ok(candidate);
81 }
82 }
83
84 if let Some(home) = dirs::home_dir() {
86 let candidate = home.join(".cargo/bin/sqry-mcp");
87 if candidate.is_file() {
88 return Ok(candidate);
89 }
90 }
91
92 bail!(
93 "Could not find sqry-mcp binary.\n\
94 Install it with: cargo install --path sqry-mcp\n\
95 Or ensure it is on your PATH."
96 );
97}
98
99fn detect_workspace_root() -> Option<PathBuf> {
107 let cwd = std::env::current_dir().ok()?;
108 let mut dir = cwd.as_path();
109 loop {
110 if dir.join(".sqry/graph").is_dir() || dir.join(".git").exists() {
111 return Some(dir.to_path_buf());
112 }
113 dir = dir.parent()?;
114 }
115}
116
117fn resolve_claude_scope(scope: &SetupScope, workspace_root: Option<&Path>) -> Result<SetupScope> {
127 match scope {
128 SetupScope::Auto => {
129 if workspace_root.is_some() {
130 Ok(SetupScope::Project)
131 } else {
132 bail!(
133 "Not inside a project directory (no .sqry/graph or .git found).\n\
134 Run from inside a project directory, or use --scope global."
135 );
136 }
137 }
138 SetupScope::Project => {
139 if workspace_root.is_none() {
140 bail!(
141 "Project scope requires being inside a project directory \
142 (or use --workspace-root)."
143 );
144 }
145 Ok(SetupScope::Project)
146 }
147 SetupScope::Global => Ok(SetupScope::Global),
148 }
149}
150
151fn detect_tool_installed(tool_name: &str) -> bool {
162 match tool_name {
163 "claude" => {
164 which::which("claude").is_ok() || claude_config_path().is_some_and(|p| p.exists())
165 }
166 "codex" => {
167 which::which("codex").is_ok()
168 || codex_config_path().is_some_and(|p| p.parent().is_some_and(Path::exists))
169 }
170 "gemini" => {
171 which::which("gemini").is_ok()
172 || gemini_config_path().is_some_and(|p| p.parent().is_some_and(Path::exists))
173 }
174 _ => false,
175 }
176}
177
178fn atomic_write(path: &Path, content: &[u8], backup: bool) -> Result<()> {
186 if let Some(parent) = path.parent()
188 && !parent.exists()
189 {
190 fs::create_dir_all(parent)
191 .with_context(|| format!("Failed to create directory: {}", parent.display()))?;
192 }
193
194 if backup && path.exists() {
196 let bak = path.with_extension("bak");
197 fs::copy(path, &bak)
198 .with_context(|| format!("Failed to create backup: {}", bak.display()))?;
199 }
200
201 let temp_name = format!(
203 "{}.tmp.{}",
204 path.file_name()
205 .and_then(|n| n.to_str())
206 .unwrap_or("config"),
207 std::process::id()
208 );
209 let temp_path = path.with_file_name(temp_name);
210 {
211 let file = File::create(&temp_path)
212 .with_context(|| format!("Failed to create temp file: {}", temp_path.display()))?;
213 let mut writer = BufWriter::new(file);
214 writer.write_all(content)?;
215 writer.flush()?;
216 writer
217 .get_ref()
218 .sync_all()
219 .context("Failed to sync temp file")?;
220 }
221
222 fs::rename(&temp_path, path)
224 .with_context(|| format!("Failed to rename temp file to: {}", path.display()))?;
225 Ok(())
226}
227
228fn read_with_mtime(path: &Path) -> Result<(String, SystemTime)> {
233 use std::io::Read;
234 let file = File::open(path).with_context(|| format!("Failed to open: {}", path.display()))?;
235 let mtime = file
236 .metadata()
237 .and_then(|m| m.modified())
238 .with_context(|| format!("Failed to get mtime: {}", path.display()))?;
239 let mut content = String::new();
240 std::io::BufReader::new(file)
241 .read_to_string(&mut content)
242 .with_context(|| format!("Failed to read: {}", path.display()))?;
243 Ok((content, mtime))
244}
245
246fn check_mtime(path: &Path, original_mtime: SystemTime, force: bool) -> Result<()> {
248 if force {
249 return Ok(());
250 }
251 if let Ok(current_mtime) = fs::metadata(path).and_then(|m| m.modified())
252 && current_mtime != original_mtime
253 {
254 bail!(
255 "Config file {} was modified by another process.\n\
256 Re-run the command or use --force to overwrite.",
257 path.display()
258 );
259 }
260 Ok(())
261}
262
263fn claude_config_path() -> Option<PathBuf> {
268 dirs::home_dir().map(|h| h.join(".claude.json"))
269}
270
271fn codex_config_path() -> Option<PathBuf> {
272 dirs::home_dir().map(|h| h.join(".codex/config.toml"))
273}
274
275fn gemini_config_path() -> Option<PathBuf> {
276 dirs::home_dir().map(|h| h.join(".gemini/settings.json"))
277}
278
279fn shim_path() -> Option<PathBuf> {
280 dirs::home_dir().map(|h| h.join(".codex/sqry-mcp-shim.sh"))
281}
282
283#[allow(clippy::too_many_lines)]
288fn run_setup(
289 tool: &ToolTarget,
290 scope: &SetupScope,
291 workspace_root_override: Option<&Path>,
292 force: bool,
293 dry_run: bool,
294 no_backup: bool,
295) -> Result<()> {
296 let workspace_root_validated = if let Some(root) = workspace_root_override {
299 if !root.exists() {
300 bail!("Workspace root does not exist: {}", root.display());
301 }
302 if !root.join(".sqry/graph").is_dir() && !root.join(".git").exists() {
303 bail!(
304 "Workspace root must contain .sqry/graph or .git: {}",
305 root.display()
306 );
307 }
308 match tool {
310 ToolTarget::Codex | ToolTarget::Gemini => {
311 bail!(
312 "Codex/Gemini use global configs -- setting a workspace root would \
313 pin to one repo. Use CWD-based discovery instead (start your tool \
314 from the project directory)."
315 );
316 }
317 ToolTarget::All | ToolTarget::Claude => {}
318 }
319 Some(root.to_path_buf())
320 } else {
321 None
322 };
323
324 let binary = find_sqry_mcp_binary()?;
325 let binary_str = binary.to_string_lossy();
326
327 let detected_root = detect_workspace_root();
329 let workspace_root = workspace_root_validated.or(detected_root);
330
331 let mut configured = Vec::new();
332 let mut skipped = Vec::new();
333 let backup = !no_backup;
334
335 let should_configure_claude = matches!(tool, ToolTarget::All | ToolTarget::Claude);
337 let should_configure_codex = matches!(tool, ToolTarget::All | ToolTarget::Codex);
338 let should_configure_gemini = matches!(tool, ToolTarget::All | ToolTarget::Gemini);
339
340 if should_configure_claude {
341 if !detect_tool_installed("claude") && matches!(tool, ToolTarget::All) {
342 skipped.push((
343 "Claude Code",
344 "not detected (claude not found on PATH, no ~/.claude.json)".to_string(),
345 ));
346 } else {
347 match resolve_claude_scope(scope, workspace_root.as_deref()) {
349 Ok(claude_scope) => {
350 match configure_claude(
351 &binary_str,
352 &claude_scope,
353 workspace_root.as_deref(),
354 force,
355 dry_run,
356 backup,
357 ) {
358 Ok(msg) => configured.push(("Claude Code", msg)),
359 Err(e) => {
360 if matches!(tool, ToolTarget::All) {
361 skipped.push(("Claude Code", format!("{e:#}")));
362 } else {
363 return Err(e);
364 }
365 }
366 }
367 }
368 Err(e) => {
369 if matches!(tool, ToolTarget::All) {
370 skipped.push(("Claude Code", format!("{e:#}")));
371 } else {
372 return Err(e);
373 }
374 }
375 }
376 }
377 }
378
379 if should_configure_codex {
380 if !detect_tool_installed("codex") && matches!(tool, ToolTarget::All) {
382 skipped.push((
383 "Codex",
384 "not detected (codex not found on PATH)".to_string(),
385 ));
386 } else {
387 match configure_codex(&binary_str, force, dry_run, backup) {
388 Ok(msg) => configured.push(("Codex", msg)),
389 Err(e) => {
390 if matches!(tool, ToolTarget::All) {
391 skipped.push(("Codex", format!("{e:#}")));
392 } else {
393 return Err(e);
394 }
395 }
396 }
397 }
398 }
399
400 if should_configure_gemini {
401 if !detect_tool_installed("gemini") && matches!(tool, ToolTarget::All) {
403 skipped.push((
404 "Gemini",
405 "not detected (gemini not found on PATH)".to_string(),
406 ));
407 } else {
408 match configure_gemini(&binary_str, force, dry_run, backup) {
409 Ok(msg) => configured.push(("Gemini", msg)),
410 Err(e) => {
411 if matches!(tool, ToolTarget::All) {
412 skipped.push(("Gemini", format!("{e:#}")));
413 } else {
414 return Err(e);
415 }
416 }
417 }
418 }
419 }
420
421 if dry_run {
423 println!("Dry run complete. No files were modified.");
424 } else {
425 println!("sqry MCP Setup Complete");
426 println!();
427 }
428
429 for (tool_name, msg) in &configured {
430 println!(" {tool_name}: {msg}");
431 }
432 for (tool_name, msg) in &skipped {
433 println!(" {tool_name}: skipped ({msg})");
434 }
435
436 if configured.is_empty() && skipped.is_empty() {
437 println!(" No tools configured.");
438 }
439
440 let codex_gemini_configured = configured
442 .iter()
443 .any(|(name, _)| *name == "Codex" || *name == "Gemini");
444 if codex_gemini_configured {
445 println!();
446 println!(
447 "Note: Codex/Gemini use CWD-based workspace discovery. \
448 Start these tools from within your project directory for \
449 sqry to resolve the correct workspace."
450 );
451 }
452
453 Ok(())
454}
455
456fn configure_claude(
461 binary: &str,
462 scope: &SetupScope,
463 workspace_root: Option<&Path>,
464 force: bool,
465 dry_run: bool,
466 backup: bool,
467) -> Result<String> {
468 let config_path = claude_config_path().context("Could not determine home directory")?;
469
470 let mut entry = serde_json::json!({
472 "type": "stdio",
473 "command": binary,
474 "args": []
475 });
476
477 let scope_label;
478
479 match scope {
480 SetupScope::Project | SetupScope::Auto => {
481 let root = workspace_root.context("No workspace root for project scope")?;
482 let root_str = root.to_string_lossy();
483 entry["env"] = serde_json::json!({
484 "SQRY_MCP_WORKSPACE_ROOT": root_str.as_ref()
485 });
486 scope_label = format!("project ({root_str})");
487
488 if dry_run {
489 println!("Would write to: {}", config_path.display());
490 println!(" Path: projects[\"{root_str}\"].mcpServers.sqry");
491 println!(" Entry: {}", serde_json::to_string_pretty(&entry)?);
492 return Ok(format!("would configure ({scope_label})"));
493 }
494
495 write_claude_project_entry(&config_path, &root_str, &entry, force, backup)?;
496 }
497 SetupScope::Global => {
498 scope_label = "global".to_string();
499
500 if dry_run {
501 println!("Would write to: {}", config_path.display());
502 println!(" Path: mcpServers.sqry");
503 println!(" Entry: {}", serde_json::to_string_pretty(&entry)?);
504 return Ok(format!("would configure ({scope_label})"));
505 }
506
507 write_claude_global_entry(&config_path, &entry, force, backup)?;
508 }
509 }
510
511 Ok(format!("configured ({scope_label})"))
512}
513
514fn write_claude_project_entry(
515 config_path: &Path,
516 project_path: &str,
517 entry: &Value,
518 force: bool,
519 backup: bool,
520) -> Result<()> {
521 let (mut config, mtime) = if config_path.exists() {
522 let (content, mtime) = read_with_mtime(config_path)?;
523 let config: Value =
524 serde_json::from_str(&content).context("Failed to parse ~/.claude.json")?;
525 (config, Some(mtime))
526 } else {
527 (serde_json::json!({}), None)
528 };
529
530 let projects = config
532 .as_object_mut()
533 .context("~/.claude.json is not a JSON object")?
534 .entry("projects")
535 .or_insert_with(|| serde_json::json!({}));
536
537 let project = projects
538 .as_object_mut()
539 .context("projects is not a JSON object")?
540 .entry(project_path)
541 .or_insert_with(|| serde_json::json!({}));
542
543 let mcp_servers = project
544 .as_object_mut()
545 .context("project entry is not a JSON object")?
546 .entry("mcpServers")
547 .or_insert_with(|| serde_json::json!({}));
548
549 let servers = mcp_servers
550 .as_object_mut()
551 .context("mcpServers is not a JSON object")?;
552
553 if servers.contains_key("sqry") && !force {
554 bail!(
555 "Claude Code project entry for sqry already exists at projects[\"{project_path}\"].\n\
556 Use --force to overwrite."
557 );
558 }
559
560 servers.insert("sqry".to_string(), entry.clone());
561
562 if let Some(mt) = mtime {
564 check_mtime(config_path, mt, force)?;
565 }
566 let output = serde_json::to_string_pretty(&config)?;
567 atomic_write(config_path, output.as_bytes(), backup)?;
568 Ok(())
569}
570
571fn write_claude_global_entry(
572 config_path: &Path,
573 entry: &Value,
574 force: bool,
575 backup: bool,
576) -> Result<()> {
577 let (mut config, mtime) = if config_path.exists() {
578 let (content, mtime) = read_with_mtime(config_path)?;
579 let config: Value =
580 serde_json::from_str(&content).context("Failed to parse ~/.claude.json")?;
581 (config, Some(mtime))
582 } else {
583 (serde_json::json!({}), None)
584 };
585
586 let mcp_servers = config
587 .as_object_mut()
588 .context("~/.claude.json is not a JSON object")?
589 .entry("mcpServers")
590 .or_insert_with(|| serde_json::json!({}));
591
592 let servers = mcp_servers
593 .as_object_mut()
594 .context("mcpServers is not a JSON object")?;
595
596 if servers.contains_key("sqry") && !force {
597 bail!(
598 "Claude Code global sqry entry already exists.\n\
599 Use --force to overwrite."
600 );
601 }
602
603 servers.insert("sqry".to_string(), entry.clone());
604
605 if let Some(mt) = mtime {
606 check_mtime(config_path, mt, force)?;
607 }
608 let output = serde_json::to_string_pretty(&config)?;
609 atomic_write(config_path, output.as_bytes(), backup)?;
610 Ok(())
611}
612
613fn configure_codex(binary: &str, force: bool, dry_run: bool, backup: bool) -> Result<String> {
618 let config_path = codex_config_path().context("Could not determine home directory")?;
619
620 if dry_run {
621 println!("Would write to: {}", config_path.display());
622 println!(" Section: [mcp_servers.sqry]");
623 println!(" command = \"{binary}\"");
624 return Ok("would configure (global, CWD discovery)".to_string());
625 }
626
627 if !config_path.exists() {
628 let content = format!("[mcp_servers.sqry]\ncommand = \"{binary}\"\n");
630 atomic_write(&config_path, content.as_bytes(), false)?;
631 return Ok("configured (global, CWD discovery) [created new config]".to_string());
632 }
633
634 let (content, mtime) = read_with_mtime(&config_path)?;
635
636 let mut doc: toml_edit::DocumentMut = content
638 .parse()
639 .context("Failed to parse ~/.codex/config.toml")?;
640
641 let has_sqry = doc.get("mcp_servers").and_then(|s| s.get("sqry")).is_some();
643
644 if has_sqry && !force {
645 bail!(
646 "Codex sqry MCP entry already exists.\n\
647 Use --force to overwrite."
648 );
649 }
650
651 if doc.get("mcp_servers").is_none() {
653 doc["mcp_servers"] = toml_edit::Item::Table(toml_edit::Table::new());
654 }
655
656 let mut sqry_table = toml_edit::Table::new();
658 sqry_table.insert("command", toml_edit::value(binary));
659
660 doc["mcp_servers"]["sqry"] = toml_edit::Item::Table(sqry_table);
662
663 check_mtime(&config_path, mtime, force)?;
664 atomic_write(&config_path, doc.to_string().as_bytes(), backup)?;
665
666 Ok("configured (global, CWD discovery)".to_string())
667}
668
669fn configure_gemini(binary: &str, force: bool, dry_run: bool, backup: bool) -> Result<String> {
674 let config_path = gemini_config_path().context("Could not determine home directory")?;
675
676 let entry = serde_json::json!({
677 "command": binary,
678 "args": [],
679 "env": {}
680 });
681
682 if dry_run {
683 println!("Would write to: {}", config_path.display());
684 println!(" Path: mcpServers.sqry");
685 println!(" Entry: {}", serde_json::to_string_pretty(&entry)?);
686 return Ok("would configure (global, CWD discovery)".to_string());
687 }
688
689 let (mut config, mtime) = if config_path.exists() {
690 let (content, mtime) = read_with_mtime(&config_path)?;
691 let config: Value =
692 serde_json::from_str(&content).context("Failed to parse ~/.gemini/settings.json")?;
693 (config, Some(mtime))
694 } else {
695 (serde_json::json!({}), None)
696 };
697
698 let mcp_servers = config
699 .as_object_mut()
700 .context("~/.gemini/settings.json is not a JSON object")?
701 .entry("mcpServers")
702 .or_insert_with(|| serde_json::json!({}));
703
704 let servers = mcp_servers
705 .as_object_mut()
706 .context("mcpServers is not a JSON object")?;
707
708 if servers.contains_key("sqry") && !force {
709 bail!(
710 "Gemini sqry MCP entry already exists.\n\
711 Use --force to overwrite."
712 );
713 }
714
715 servers.insert("sqry".to_string(), entry);
716
717 if let Some(mt) = mtime {
718 check_mtime(&config_path, mt, force)?;
719 }
720 let output = serde_json::to_string_pretty(&config)?;
721 atomic_write(&config_path, output.as_bytes(), backup)?;
722
723 Ok("configured (global, CWD discovery)".to_string())
724}
725
726fn run_status(json_output: bool) -> Result<()> {
731 let binary = find_sqry_mcp_binary().ok();
732 let binary_display = binary.as_ref().map_or_else(
733 || "not found".to_string(),
734 |p| p.to_string_lossy().to_string(),
735 );
736
737 if json_output {
738 print_status_json(&binary_display)?;
739 } else {
740 print_status_human(&binary_display);
741 }
742
743 Ok(())
744}
745
746fn print_status_human(binary: &str) {
747 println!("sqry MCP Status\n");
748 println!("Binary: {binary}");
749 println!();
750
751 if let Err(e) = print_claude_status_human() {
753 println!("Claude Code: error reading config ({e:#})");
754 }
755 println!();
756
757 if let Err(e) = print_codex_status_human() {
759 println!("Codex: error reading config ({e:#})");
760 }
761 println!();
762
763 if let Err(e) = print_gemini_status_human() {
765 println!("Gemini: error reading config ({e:#})");
766 }
767
768 if let Some(shim) = shim_path()
770 && shim.exists()
771 {
772 println!();
773 println!("Warning: Legacy shim detected at {}", shim.display());
774 println!(" The shim is no longer needed (rmcp 0.11.0 handles MCP protocol natively).");
775 println!(" You can safely remove it.");
776 }
777}
778
779fn print_claude_status_human() -> Result<()> {
780 let Some(config_path) = claude_config_path() else {
781 println!("Claude Code: config path unknown");
782 return Ok(());
783 };
784
785 if !config_path.exists() {
786 println!("Claude Code (~/.claude.json): not detected");
787 return Ok(());
788 }
789
790 println!("Claude Code (~/.claude.json):");
791
792 let content = fs::read_to_string(&config_path)?;
793 let config: Value = serde_json::from_str(&content).context("Failed to parse ~/.claude.json")?;
794
795 if let Some(cmd) = config
797 .get("mcpServers")
798 .and_then(|s| s.get("sqry"))
799 .and_then(|e| e.get("command"))
800 .and_then(Value::as_str)
801 {
802 println!(" Global: configured");
803 println!(" Command: {cmd}");
804 if let Some(root) = config
805 .get("mcpServers")
806 .and_then(|s| s.get("sqry"))
807 .and_then(|e| e.get("env"))
808 .and_then(|e| e.get("SQRY_MCP_WORKSPACE_ROOT"))
809 .and_then(Value::as_str)
810 {
811 println!(" Workspace root: {root}");
812 }
813 } else {
814 println!(" Global: not configured");
815 }
816
817 if let Some(projects) = config.get("projects").and_then(Value::as_object) {
819 for (path, project) in projects {
820 if let Some(cmd) = project
821 .get("mcpServers")
822 .and_then(|s| s.get("sqry"))
823 .and_then(|e| e.get("command"))
824 .and_then(Value::as_str)
825 {
826 println!(" Project ({path}):");
827 println!(" configured");
828 println!(" Command: {cmd}");
829 if let Some(root) = project
830 .get("mcpServers")
831 .and_then(|s| s.get("sqry"))
832 .and_then(|e| e.get("env"))
833 .and_then(|e| e.get("SQRY_MCP_WORKSPACE_ROOT"))
834 .and_then(Value::as_str)
835 {
836 println!(" Workspace root: {root}");
837 }
838
839 if config
841 .get("mcpServers")
842 .and_then(|s| s.get("sqry"))
843 .is_some()
844 {
845 println!(" Note: Project entry overrides global for this project");
846 }
847 }
848 }
849 }
850
851 Ok(())
852}
853
854fn print_codex_status_human() -> Result<()> {
855 let Some(config_path) = codex_config_path() else {
856 println!("Codex: config path unknown");
857 return Ok(());
858 };
859
860 if !config_path.exists() {
861 println!("Codex (~/.codex/config.toml): not detected");
862 return Ok(());
863 }
864
865 println!("Codex (~/.codex/config.toml):");
866
867 let content = fs::read_to_string(&config_path)?;
868 let doc: toml_edit::DocumentMut = content.parse().context("Failed to parse config.toml")?;
869
870 if let Some(cmd) = doc
871 .get("mcp_servers")
872 .and_then(|s| s.get("sqry"))
873 .and_then(|t| t.get("command"))
874 .and_then(|v| v.as_str())
875 {
876 println!(" configured");
877 println!(" Command: {cmd}");
878
879 if let Some(root) = doc
880 .get("mcp_servers")
881 .and_then(|s| s.get("sqry"))
882 .and_then(|t| t.get("env"))
883 .and_then(|e| e.get("SQRY_MCP_WORKSPACE_ROOT"))
884 .and_then(|v| v.as_str())
885 {
886 println!(" Workspace root: {root}");
887 } else {
888 println!(" Workspace root: (CWD discovery)");
889 println!(" Note: Codex must be started from within a project directory");
890 }
891 } else {
892 println!(" sqry not configured");
893 }
894
895 Ok(())
896}
897
898fn print_gemini_status_human() -> Result<()> {
899 let Some(config_path) = gemini_config_path() else {
900 println!("Gemini: config path unknown");
901 return Ok(());
902 };
903
904 if !config_path.exists() {
905 println!("Gemini (~/.gemini/settings.json): not detected");
906 return Ok(());
907 }
908
909 println!("Gemini (~/.gemini/settings.json):");
910
911 let content = fs::read_to_string(&config_path)?;
912 let config: Value =
913 serde_json::from_str(&content).context("Failed to parse ~/.gemini/settings.json")?;
914
915 if let Some(cmd) = config
916 .get("mcpServers")
917 .and_then(|s| s.get("sqry"))
918 .and_then(|e| e.get("command"))
919 .and_then(Value::as_str)
920 {
921 println!(" configured");
922 println!(" Command: {cmd}");
923
924 if let Some(root) = config
925 .get("mcpServers")
926 .and_then(|s| s.get("sqry"))
927 .and_then(|e| e.get("env"))
928 .and_then(|e| e.get("SQRY_MCP_WORKSPACE_ROOT"))
929 .and_then(Value::as_str)
930 {
931 println!(" Workspace root: {root}");
932 } else {
933 println!(" Workspace root: (CWD discovery)");
934 println!(" Note: Gemini must be started from within a project directory");
935 }
936 } else {
937 println!(" sqry not configured");
938 }
939
940 Ok(())
941}
942
943fn print_status_json(binary: &str) -> Result<()> {
948 let mut output = serde_json::json!({
949 "binary": binary,
950 "tools": {}
951 });
952
953 let tools = output["tools"].as_object_mut().unwrap();
954
955 tools.insert(
957 "claude".to_string(),
958 claude_status_json().unwrap_or_else(
959 |e| serde_json::json!({"configured": false, "error": format!("{e:#}")}),
960 ),
961 );
962
963 tools.insert(
964 "codex".to_string(),
965 codex_status_json().unwrap_or_else(
966 |e| serde_json::json!({"configured": false, "error": format!("{e:#}")}),
967 ),
968 );
969
970 tools.insert(
971 "gemini".to_string(),
972 gemini_status_json().unwrap_or_else(
973 |e| serde_json::json!({"configured": false, "error": format!("{e:#}")}),
974 ),
975 );
976
977 if let Some(shim) = shim_path()
979 && shim.exists()
980 {
981 output["shim_detected"] = Value::String(shim.to_string_lossy().to_string());
982 }
983
984 println!("{}", serde_json::to_string_pretty(&output)?);
985 Ok(())
986}
987
988fn claude_status_json() -> Result<Value> {
989 let Some(config_path) = claude_config_path() else {
990 return Ok(serde_json::json!({"configured": false}));
991 };
992
993 if !config_path.exists() {
994 return Ok(serde_json::json!({
995 "config_path": config_path.to_string_lossy(),
996 "configured": false
997 }));
998 }
999
1000 let content = fs::read_to_string(&config_path)?;
1001 let config: Value = serde_json::from_str(&content)?;
1002
1003 let global = config
1004 .get("mcpServers")
1005 .and_then(|s| s.get("sqry"))
1006 .map_or_else(
1007 || serde_json::json!({"configured": false}),
1008 |entry| {
1009 serde_json::json!({
1010 "configured": true,
1011 "command": entry.get("command").and_then(Value::as_str).unwrap_or(""),
1012 "workspace_root": entry.get("env")
1013 .and_then(|e| e.get("SQRY_MCP_WORKSPACE_ROOT"))
1014 .and_then(Value::as_str)
1015 })
1016 },
1017 );
1018
1019 let mut projects = BTreeMap::new();
1020 if let Some(proj_map) = config.get("projects").and_then(Value::as_object) {
1021 for (path, project) in proj_map {
1022 if let Some(entry) = project.get("mcpServers").and_then(|s| s.get("sqry")) {
1023 projects.insert(
1024 path.clone(),
1025 serde_json::json!({
1026 "configured": true,
1027 "command": entry.get("command").and_then(Value::as_str).unwrap_or(""),
1028 "workspace_root": entry.get("env")
1029 .and_then(|e| e.get("SQRY_MCP_WORKSPACE_ROOT"))
1030 .and_then(Value::as_str)
1031 }),
1032 );
1033 }
1034 }
1035 }
1036
1037 Ok(serde_json::json!({
1038 "config_path": config_path.to_string_lossy(),
1039 "global": global,
1040 "projects": projects
1041 }))
1042}
1043
1044fn codex_status_json() -> Result<Value> {
1045 let Some(config_path) = codex_config_path() else {
1046 return Ok(serde_json::json!({"configured": false}));
1047 };
1048
1049 if !config_path.exists() {
1050 return Ok(serde_json::json!({
1051 "config_path": config_path.to_string_lossy(),
1052 "configured": false
1053 }));
1054 }
1055
1056 let content = fs::read_to_string(&config_path)?;
1057 let doc: toml_edit::DocumentMut = content.parse()?;
1058
1059 let configured = doc
1060 .get("mcp_servers")
1061 .and_then(|s| s.get("sqry"))
1062 .and_then(|t| t.get("command"))
1063 .and_then(|v| v.as_str())
1064 .is_some();
1065
1066 let command = doc
1067 .get("mcp_servers")
1068 .and_then(|s| s.get("sqry"))
1069 .and_then(|t| t.get("command"))
1070 .and_then(|v| v.as_str())
1071 .unwrap_or("");
1072
1073 let workspace_root = doc
1074 .get("mcp_servers")
1075 .and_then(|s| s.get("sqry"))
1076 .and_then(|t| t.get("env"))
1077 .and_then(|e| e.get("SQRY_MCP_WORKSPACE_ROOT"))
1078 .and_then(|v| v.as_str());
1079
1080 Ok(serde_json::json!({
1081 "config_path": config_path.to_string_lossy(),
1082 "configured": configured,
1083 "command": command,
1084 "workspace_root": workspace_root
1085 }))
1086}
1087
1088fn gemini_status_json() -> Result<Value> {
1089 let Some(config_path) = gemini_config_path() else {
1090 return Ok(serde_json::json!({"configured": false}));
1091 };
1092
1093 if !config_path.exists() {
1094 return Ok(serde_json::json!({
1095 "config_path": config_path.to_string_lossy(),
1096 "configured": false
1097 }));
1098 }
1099
1100 let content = fs::read_to_string(&config_path)?;
1101 let config: Value = serde_json::from_str(&content)?;
1102
1103 let configured = config
1104 .get("mcpServers")
1105 .and_then(|s| s.get("sqry"))
1106 .and_then(|e| e.get("command"))
1107 .and_then(Value::as_str)
1108 .is_some();
1109
1110 let command = config
1111 .get("mcpServers")
1112 .and_then(|s| s.get("sqry"))
1113 .and_then(|e| e.get("command"))
1114 .and_then(Value::as_str)
1115 .unwrap_or("");
1116
1117 let workspace_root = config
1118 .get("mcpServers")
1119 .and_then(|s| s.get("sqry"))
1120 .and_then(|e| e.get("env"))
1121 .and_then(|e| e.get("SQRY_MCP_WORKSPACE_ROOT"))
1122 .and_then(Value::as_str);
1123
1124 Ok(serde_json::json!({
1125 "config_path": config_path.to_string_lossy(),
1126 "configured": configured,
1127 "command": command,
1128 "workspace_root": workspace_root
1129 }))
1130}
1131
1132#[cfg(test)]
1137mod tests {
1138 use super::*;
1139 use tempfile::TempDir;
1140
1141 #[test]
1144 fn test_resolve_claude_scope_auto_with_root() {
1145 let tmp = TempDir::new().unwrap();
1146 let scope = resolve_claude_scope(&SetupScope::Auto, Some(tmp.path())).unwrap();
1147 assert!(matches!(scope, SetupScope::Project));
1148 }
1149
1150 #[test]
1151 fn test_resolve_claude_scope_auto_without_root() {
1152 let result = resolve_claude_scope(&SetupScope::Auto, None);
1153 assert!(result.is_err());
1154 let msg = result.unwrap_err().to_string();
1155 assert!(msg.contains("Not inside a project directory"));
1156 }
1157
1158 #[test]
1159 fn test_resolve_claude_scope_project_with_root() {
1160 let tmp = TempDir::new().unwrap();
1161 let scope = resolve_claude_scope(&SetupScope::Project, Some(tmp.path())).unwrap();
1162 assert!(matches!(scope, SetupScope::Project));
1163 }
1164
1165 #[test]
1166 fn test_resolve_claude_scope_project_without_root() {
1167 let result = resolve_claude_scope(&SetupScope::Project, None);
1168 assert!(result.is_err());
1169 let msg = result.unwrap_err().to_string();
1170 assert!(msg.contains("Project scope requires"));
1171 }
1172
1173 #[test]
1174 fn test_resolve_claude_scope_global_no_root_needed() {
1175 let scope = resolve_claude_scope(&SetupScope::Global, None).unwrap();
1176 assert!(matches!(scope, SetupScope::Global));
1177 }
1178
1179 #[test]
1182 fn test_atomic_write_creates_file() {
1183 let tmp = TempDir::new().unwrap();
1184 let path = tmp.path().join("test.json");
1185 atomic_write(&path, b"hello", false).unwrap();
1186 assert_eq!(fs::read_to_string(&path).unwrap(), "hello");
1187 }
1188
1189 #[test]
1190 fn test_atomic_write_creates_parent_dirs() {
1191 let tmp = TempDir::new().unwrap();
1192 let path = tmp.path().join("nested/dir/config.json");
1193 atomic_write(&path, b"{}", false).unwrap();
1194 assert_eq!(fs::read_to_string(&path).unwrap(), "{}");
1195 }
1196
1197 #[test]
1198 fn test_atomic_write_with_backup() {
1199 let tmp = TempDir::new().unwrap();
1200 let path = tmp.path().join("test.json");
1201 fs::write(&path, "original").unwrap();
1202
1203 atomic_write(&path, b"updated", true).unwrap();
1204
1205 assert_eq!(fs::read_to_string(&path).unwrap(), "updated");
1206 let bak = path.with_extension("bak");
1207 assert!(bak.exists());
1208 assert_eq!(fs::read_to_string(&bak).unwrap(), "original");
1209 }
1210
1211 #[test]
1212 fn test_atomic_write_without_backup() {
1213 let tmp = TempDir::new().unwrap();
1214 let path = tmp.path().join("test.json");
1215 fs::write(&path, "original").unwrap();
1216
1217 atomic_write(&path, b"updated", false).unwrap();
1218
1219 assert_eq!(fs::read_to_string(&path).unwrap(), "updated");
1220 let bak = path.with_extension("bak");
1221 assert!(!bak.exists());
1222 }
1223
1224 #[test]
1227 fn test_read_with_mtime_uses_same_handle() {
1228 let tmp = TempDir::new().unwrap();
1229 let path = tmp.path().join("test.txt");
1230 fs::write(&path, "content").unwrap();
1231
1232 let (content, mtime) = read_with_mtime(&path).unwrap();
1233 assert_eq!(content, "content");
1234
1235 let actual_mtime = fs::metadata(&path).unwrap().modified().unwrap();
1237 assert_eq!(mtime, actual_mtime);
1238 }
1239
1240 #[test]
1241 fn test_check_mtime_unchanged_passes() {
1242 let tmp = TempDir::new().unwrap();
1243 let path = tmp.path().join("test.txt");
1244 fs::write(&path, "content").unwrap();
1245
1246 let (_, mtime) = read_with_mtime(&path).unwrap();
1247 check_mtime(&path, mtime, false).unwrap();
1248 }
1249
1250 #[test]
1251 fn test_check_mtime_changed_fails() {
1252 let tmp = TempDir::new().unwrap();
1253 let path = tmp.path().join("test.txt");
1254 fs::write(&path, "content").unwrap();
1255
1256 let (_, mtime) = read_with_mtime(&path).unwrap();
1257
1258 std::thread::sleep(std::time::Duration::from_millis(50));
1260 fs::write(&path, "modified").unwrap();
1261
1262 let result = check_mtime(&path, mtime, false);
1263 assert!(result.is_err());
1264 assert!(
1265 result
1266 .unwrap_err()
1267 .to_string()
1268 .contains("modified by another process")
1269 );
1270 }
1271
1272 #[test]
1273 fn test_check_mtime_force_bypasses_conflict() {
1274 let tmp = TempDir::new().unwrap();
1275 let path = tmp.path().join("test.txt");
1276 fs::write(&path, "content").unwrap();
1277
1278 let (_, mtime) = read_with_mtime(&path).unwrap();
1279
1280 std::thread::sleep(std::time::Duration::from_millis(50));
1281 fs::write(&path, "modified").unwrap();
1282
1283 check_mtime(&path, mtime, true).unwrap();
1285 }
1286
1287 #[test]
1290 fn test_write_claude_project_entry_new_file() {
1291 let tmp = TempDir::new().unwrap();
1292 let config_path = tmp.path().join("claude.json");
1293
1294 let entry = serde_json::json!({
1295 "type": "stdio",
1296 "command": "/usr/bin/sqry-mcp",
1297 "args": [],
1298 "env": { "SQRY_MCP_WORKSPACE_ROOT": "/my/project" }
1299 });
1300
1301 write_claude_project_entry(&config_path, "/my/project", &entry, false, false).unwrap();
1302
1303 let content: Value =
1304 serde_json::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap();
1305 assert_eq!(
1306 content["projects"]["/my/project"]["mcpServers"]["sqry"]["command"],
1307 "/usr/bin/sqry-mcp"
1308 );
1309 assert_eq!(
1310 content["projects"]["/my/project"]["mcpServers"]["sqry"]["env"]["SQRY_MCP_WORKSPACE_ROOT"],
1311 "/my/project"
1312 );
1313 }
1314
1315 #[test]
1316 fn test_write_claude_project_entry_exists_no_force() {
1317 let tmp = TempDir::new().unwrap();
1318 let config_path = tmp.path().join("claude.json");
1319
1320 let existing = serde_json::json!({
1321 "projects": {
1322 "/my/project": {
1323 "mcpServers": {
1324 "sqry": { "command": "old" }
1325 }
1326 }
1327 }
1328 });
1329 fs::write(
1330 &config_path,
1331 serde_json::to_string_pretty(&existing).unwrap(),
1332 )
1333 .unwrap();
1334
1335 let entry = serde_json::json!({ "command": "new" });
1336 let result = write_claude_project_entry(&config_path, "/my/project", &entry, false, false);
1337 assert!(result.is_err());
1338 assert!(result.unwrap_err().to_string().contains("already exists"));
1339 }
1340
1341 #[test]
1342 fn test_write_claude_project_entry_exists_with_force() {
1343 let tmp = TempDir::new().unwrap();
1344 let config_path = tmp.path().join("claude.json");
1345
1346 let existing = serde_json::json!({
1347 "projects": {
1348 "/my/project": {
1349 "mcpServers": {
1350 "sqry": { "command": "old" }
1351 }
1352 }
1353 }
1354 });
1355 fs::write(
1356 &config_path,
1357 serde_json::to_string_pretty(&existing).unwrap(),
1358 )
1359 .unwrap();
1360
1361 let entry = serde_json::json!({ "command": "new" });
1362 write_claude_project_entry(&config_path, "/my/project", &entry, true, false).unwrap();
1363
1364 let content: Value =
1365 serde_json::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap();
1366 assert_eq!(
1367 content["projects"]["/my/project"]["mcpServers"]["sqry"]["command"],
1368 "new"
1369 );
1370 }
1371
1372 #[test]
1373 fn test_write_claude_global_entry_new_file() {
1374 let tmp = TempDir::new().unwrap();
1375 let config_path = tmp.path().join("claude.json");
1376
1377 let entry = serde_json::json!({
1378 "type": "stdio",
1379 "command": "/usr/bin/sqry-mcp",
1380 "args": []
1381 });
1382
1383 write_claude_global_entry(&config_path, &entry, false, false).unwrap();
1384
1385 let content: Value =
1386 serde_json::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap();
1387 assert_eq!(
1388 content["mcpServers"]["sqry"]["command"],
1389 "/usr/bin/sqry-mcp"
1390 );
1391 }
1392
1393 #[test]
1394 fn test_write_claude_global_entry_exists_no_force() {
1395 let tmp = TempDir::new().unwrap();
1396 let config_path = tmp.path().join("claude.json");
1397
1398 let existing = serde_json::json!({
1399 "mcpServers": {
1400 "sqry": { "command": "old" }
1401 }
1402 });
1403 fs::write(
1404 &config_path,
1405 serde_json::to_string_pretty(&existing).unwrap(),
1406 )
1407 .unwrap();
1408
1409 let entry = serde_json::json!({ "command": "new" });
1410 let result = write_claude_global_entry(&config_path, &entry, false, false);
1411 assert!(result.is_err());
1412 assert!(result.unwrap_err().to_string().contains("already exists"));
1413 }
1414
1415 #[test]
1416 fn test_write_claude_project_preserves_existing_global() {
1417 let tmp = TempDir::new().unwrap();
1418 let config_path = tmp.path().join("claude.json");
1419
1420 let existing = serde_json::json!({
1422 "mcpServers": {
1423 "sqry": { "command": "/usr/bin/sqry-mcp" }
1424 }
1425 });
1426 fs::write(
1427 &config_path,
1428 serde_json::to_string_pretty(&existing).unwrap(),
1429 )
1430 .unwrap();
1431
1432 let entry = serde_json::json!({
1434 "command": "/usr/bin/sqry-mcp",
1435 "env": { "SQRY_MCP_WORKSPACE_ROOT": "/my/project" }
1436 });
1437 write_claude_project_entry(&config_path, "/my/project", &entry, false, false).unwrap();
1438
1439 let content: Value =
1440 serde_json::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap();
1441 assert_eq!(
1443 content["mcpServers"]["sqry"]["command"],
1444 "/usr/bin/sqry-mcp"
1445 );
1446 assert_eq!(
1448 content["projects"]["/my/project"]["mcpServers"]["sqry"]["command"],
1449 "/usr/bin/sqry-mcp"
1450 );
1451 }
1452
1453 #[test]
1458 fn test_detect_tool_installed_unknown_tool() {
1459 assert!(!detect_tool_installed("unknown"));
1461 assert!(!detect_tool_installed(""));
1462 assert!(!detect_tool_installed("vscode"));
1463 }
1464
1465 #[test]
1468 fn test_claude_status_json_malformed_config() {
1469 let malformed = "not valid json {{{";
1473 let result: Result<Value, _> = serde_json::from_str(malformed);
1474 assert!(result.is_err());
1475
1476 let fallback = result.map_or_else(
1478 |e| serde_json::json!({"configured": false, "error": format!("{e:#}")}),
1479 |_| serde_json::json!({"configured": true}),
1480 );
1481 assert_eq!(fallback["configured"], false);
1482 assert!(fallback["error"].as_str().unwrap().contains("expected"));
1483 }
1484
1485 #[test]
1486 fn test_codex_toml_parse_malformed() {
1487 let malformed = "[invalid\nthis is not valid toml";
1490 let result: Result<toml_edit::DocumentMut, _> = malformed.parse();
1491 assert!(result.is_err());
1492 }
1493
1494 #[test]
1495 fn test_status_json_error_shape() {
1496 let error_json =
1498 serde_json::json!({"configured": false, "error": "Failed to parse config"});
1499 assert_eq!(error_json["configured"], false);
1500 assert!(error_json["error"].is_string());
1501 let serialized = serde_json::to_string(&error_json).unwrap();
1503 let _: Value = serde_json::from_str(&serialized).unwrap();
1504 }
1505
1506 #[test]
1509 fn test_workspace_root_rejected_for_codex() {
1510 let tmp = TempDir::new().unwrap();
1511 let root = tmp.path();
1512 fs::create_dir_all(root.join(".git")).unwrap();
1513
1514 let result = run_setup(
1515 &ToolTarget::Codex,
1516 &SetupScope::Auto,
1517 Some(root),
1518 false,
1519 true, true,
1521 );
1522 assert!(result.is_err());
1523 assert!(
1524 result
1525 .unwrap_err()
1526 .to_string()
1527 .contains("Codex/Gemini use global configs")
1528 );
1529 }
1530
1531 #[test]
1532 fn test_workspace_root_rejected_for_gemini() {
1533 let tmp = TempDir::new().unwrap();
1534 let root = tmp.path();
1535 fs::create_dir_all(root.join(".git")).unwrap();
1536
1537 let result = run_setup(
1538 &ToolTarget::Gemini,
1539 &SetupScope::Auto,
1540 Some(root),
1541 false,
1542 true, true,
1544 );
1545 assert!(result.is_err());
1546 assert!(
1547 result
1548 .unwrap_err()
1549 .to_string()
1550 .contains("Codex/Gemini use global configs")
1551 );
1552 }
1553}