1use std::path::{Path, PathBuf};
12
13use anyhow::{Context, Result};
14use clap::{Args, Subcommand};
15
16use crate::config::Config;
17
18const SERVER_NAME: &str = "asd";
20
21#[derive(Clone, Copy, PartialEq)]
29enum ConfigFormat {
30 Json,
31 Jsonc,
34 Toml,
35 Manual,
39}
40
41#[derive(Clone, Copy)]
44enum EntryStyle {
45 Standard,
48 ZedCustom,
50 VsCodeStdio,
52}
53
54struct Tool {
55 name: &'static str,
56 config_path_fn: fn() -> Option<PathBuf>,
57 format: ConfigFormat,
58 servers_key: &'static str,
60 entry_style: EntryStyle,
61}
62
63const TOOLS: &[Tool] = &[
64 Tool {
65 name: "claude-code",
66 config_path_fn: claude_code_config_path,
67 format: ConfigFormat::Json,
68 servers_key: "mcpServers",
69 entry_style: EntryStyle::Standard,
70 },
71 Tool {
72 name: "claude-desktop",
73 config_path_fn: claude_desktop_config_path,
74 format: ConfigFormat::Json,
75 servers_key: "mcpServers",
76 entry_style: EntryStyle::Standard,
77 },
78 Tool {
79 name: "cursor",
80 config_path_fn: cursor_config_path,
81 format: ConfigFormat::Json,
82 servers_key: "mcpServers",
83 entry_style: EntryStyle::Standard,
84 },
85 Tool {
86 name: "gemini-cli",
87 config_path_fn: gemini_cli_config_path,
88 format: ConfigFormat::Json,
89 servers_key: "mcpServers",
90 entry_style: EntryStyle::Standard,
91 },
92 Tool {
93 name: "windsurf",
94 config_path_fn: windsurf_config_path,
95 format: ConfigFormat::Json,
96 servers_key: "mcpServers",
97 entry_style: EntryStyle::Standard,
98 },
99 Tool {
100 name: "zed",
101 config_path_fn: zed_config_path,
102 format: ConfigFormat::Json,
103 servers_key: "context_servers",
104 entry_style: EntryStyle::ZedCustom,
105 },
106 Tool {
107 name: "vscode",
108 config_path_fn: vscode_config_path,
109 format: ConfigFormat::Json,
110 servers_key: "servers",
111 entry_style: EntryStyle::VsCodeStdio,
112 },
113 Tool {
114 name: "cline",
115 config_path_fn: cline_config_path,
116 format: ConfigFormat::Json,
117 servers_key: "mcpServers",
118 entry_style: EntryStyle::Standard,
119 },
120 Tool {
121 name: "codex-cli",
122 config_path_fn: codex_cli_config_path,
123 format: ConfigFormat::Toml,
124 servers_key: "mcp_servers",
125 entry_style: EntryStyle::Standard,
126 },
127 Tool {
128 name: "kilo-code",
129 config_path_fn: kilo_code_config_path,
130 format: ConfigFormat::Jsonc,
131 servers_key: "mcpServers",
132 entry_style: EntryStyle::Standard,
133 },
134 Tool {
135 name: "antigravity",
136 config_path_fn: antigravity_config_path,
137 format: ConfigFormat::Json,
138 servers_key: "mcpServers",
139 entry_style: EntryStyle::Standard,
140 },
141 Tool {
142 name: "aider",
143 config_path_fn: aider_config_path,
144 format: ConfigFormat::Manual,
145 servers_key: "mcp-servers",
146 entry_style: EntryStyle::Standard,
147 },
148];
149
150fn valid_tool_names() -> String {
153 TOOLS.iter().map(|t| t.name).collect::<Vec<_>>().join(", ")
154}
155
156fn home() -> Option<PathBuf> {
157 std::env::var("HOME").ok().map(PathBuf::from)
158}
159
160fn claude_code_config_path() -> Option<PathBuf> {
161 Some(home()?.join(".claude.json"))
162}
163
164fn claude_desktop_config_path() -> Option<PathBuf> {
165 #[cfg(target_os = "macos")]
166 return Some(home()?.join("Library/Application Support/Claude/claude_desktop_config.json"));
167 #[cfg(target_os = "linux")]
168 return Some(home()?.join(".config/Claude/claude_desktop_config.json"));
169 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
170 return None;
171}
172
173fn cursor_config_path() -> Option<PathBuf> {
174 Some(home()?.join(".cursor/mcp.json"))
175}
176
177fn gemini_cli_config_path() -> Option<PathBuf> {
178 Some(home()?.join(".gemini/settings.json"))
179}
180
181fn windsurf_config_path() -> Option<PathBuf> {
182 Some(home()?.join(".codeium/windsurf/mcp_config.json"))
183}
184
185fn zed_config_path() -> Option<PathBuf> {
186 Some(home()?.join(".config/zed/settings.json"))
187}
188
189fn vscode_config_path() -> Option<PathBuf> {
191 #[cfg(target_os = "macos")]
192 return Some(home()?.join("Library/Application Support/Code/User/mcp.json"));
193 #[cfg(target_os = "linux")]
194 return Some(home()?.join(".config/Code/User/mcp.json"));
195 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
196 return None;
197}
198
199fn cline_config_path() -> Option<PathBuf> {
201 let rel = "globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json";
202 #[cfg(target_os = "macos")]
203 return Some(
204 home()?
205 .join("Library/Application Support/Code/User")
206 .join(rel),
207 );
208 #[cfg(target_os = "linux")]
209 return Some(home()?.join(".config/Code/User").join(rel));
210 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
211 return None;
212}
213
214fn codex_cli_config_path() -> Option<PathBuf> {
215 Some(home()?.join(".codex/config.toml"))
216}
217
218fn kilo_code_config_path() -> Option<PathBuf> {
220 Some(home()?.join(".config/kilo/kilo.jsonc"))
221}
222
223fn antigravity_config_path() -> Option<PathBuf> {
226 Some(home()?.join(".gemini/config/mcp_config.json"))
227}
228
229fn aider_config_path() -> Option<PathBuf> {
231 Some(home()?.join(".aider.conf.yml"))
232}
233
234#[derive(Debug, Subcommand)]
239pub enum McpCmd {
240 Install(InstallArgs),
242
243 Uninstall(UninstallArgs),
245
246 Status(StatusArgs),
248
249 Instructions(InstructionsArgs),
252}
253
254#[derive(Debug, Args)]
255pub struct InstallArgs {
256 #[arg(long)]
259 pub db: Option<PathBuf>,
260
261 #[arg(long)]
264 pub tool: Option<String>,
265
266 #[arg(long)]
271 pub follow_active: bool,
272
273 #[arg(long)]
278 pub project: bool,
279}
280
281#[derive(Debug, Args)]
282pub struct UninstallArgs {
283 #[arg(long)]
285 pub tool: Option<String>,
286}
287
288#[derive(Debug, Args)]
289pub struct StatusArgs {}
290
291#[derive(Debug, Args)]
292pub struct InstructionsArgs {
293 #[arg(long)]
296 pub file: Vec<String>,
297
298 #[arg(long)]
300 pub remove: bool,
301
302 #[arg(long)]
306 pub hooks: bool,
307}
308
309pub fn run(_cfg: &Config, cmd: McpCmd) -> Result<()> {
314 match cmd {
315 McpCmd::Install(args) => {
316 let result = install(args);
317 if result.is_ok() {
321 match crate::commands::help::publish_help_index() {
322 Ok(path) => eprintln!(" help index: {}", path.display()),
323 Err(e) => eprintln!(" help index skipped: {e}"),
324 }
325 }
326 result
327 }
328 McpCmd::Uninstall(args) => uninstall(args),
329 McpCmd::Status(_) => status(),
330 McpCmd::Instructions(args) => instructions(args),
331 }
332}
333
334fn find_asd_mcp() -> Option<PathBuf> {
341 if let Ok(exe) = std::env::current_exe() {
343 let sibling = exe.with_file_name("asd-mcp");
344 if sibling.exists() {
345 return Some(sibling);
346 }
347 }
348 if let Some(home) = home() {
350 let p = home.join(".cargo/bin/asd-mcp");
351 if p.exists() {
352 return Some(p);
353 }
354 }
355 which_asd_mcp()
357}
358
359fn which_asd_mcp() -> Option<PathBuf> {
360 std::env::var_os("PATH")?
361 .to_string_lossy()
362 .split(':')
363 .find_map(|dir| {
364 let p = PathBuf::from(dir).join("asd-mcp");
365 p.exists().then_some(p)
366 })
367}
368
369fn resolve_db(override_db: Option<PathBuf>) -> Result<PathBuf> {
370 let p = match override_db {
371 Some(p) => p,
372 None => std::env::current_dir()
373 .context("cannot determine current directory")?
374 .join(".asd-state.db"),
375 };
376 if p.exists() {
378 p.canonicalize().context("canonicalize db path")
379 } else {
380 if p.is_absolute() {
381 Ok(p)
382 } else {
383 Ok(std::env::current_dir()?.join(p))
384 }
385 }
386}
387
388fn read_config(path: &Path, format: ConfigFormat) -> Result<serde_json::Value> {
391 if !path.exists() {
392 return Ok(serde_json::json!({}));
393 }
394 let raw = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
395 let raw = if format == ConfigFormat::Jsonc {
396 strip_jsonc_comments(&raw)
397 } else {
398 raw
399 };
400 serde_json::from_str(&raw).with_context(|| format!("parse JSON in {}", path.display()))
401}
402
403fn strip_jsonc_comments(s: &str) -> String {
406 let mut out = String::with_capacity(s.len());
407 let mut chars = s.chars().peekable();
408 let mut in_str = false;
409 while let Some(c) = chars.next() {
410 if in_str {
411 out.push(c);
412 if c == '\\' {
413 if let Some(n) = chars.next() {
414 out.push(n);
415 }
416 } else if c == '"' {
417 in_str = false;
418 }
419 } else if c == '"' {
420 in_str = true;
421 out.push('"');
422 } else if c == '/' && chars.peek() == Some(&'/') {
423 for n in chars.by_ref() {
424 if n == '\n' {
425 out.push('\n');
426 break;
427 }
428 }
429 } else if c == '/' && chars.peek() == Some(&'*') {
430 chars.next(); let mut prev = '\0';
432 for n in chars.by_ref() {
433 if prev == '*' && n == '/' {
434 break;
435 }
436 prev = n;
437 }
438 } else {
439 out.push(c);
440 }
441 }
442 out
443}
444
445fn write_config(path: &Path, value: &serde_json::Value) -> Result<()> {
447 if let Some(parent) = path.parent() {
448 std::fs::create_dir_all(parent)
449 .with_context(|| format!("create dirs for {}", path.display()))?;
450 }
451 let out = serde_json::to_string_pretty(value)?;
452 std::fs::write(path, out).with_context(|| format!("write {}", path.display()))
453}
454
455fn build_entry(style: EntryStyle, asd_mcp_bin: &Path, db_path: Option<&Path>) -> serde_json::Value {
459 let env = match db_path {
460 Some(p) => serde_json::json!({ "ASD_DB": p.to_string_lossy() }),
461 None => serde_json::json!({}),
462 };
463 let mut entry = serde_json::json!({
464 "command": asd_mcp_bin.to_string_lossy(),
465 "args": [],
466 "env": env
467 });
468 let obj = entry.as_object_mut().expect("entry is an object");
469 match style {
470 EntryStyle::Standard => {}
471 EntryStyle::ZedCustom => {
472 obj.insert("source".to_string(), serde_json::json!("custom"));
473 }
474 EntryStyle::VsCodeStdio => {
475 obj.insert("type".to_string(), serde_json::json!("stdio"));
476 }
477 }
478 entry
479}
480
481fn tools_to_process(tool_filter: Option<&str>) -> Vec<&'static Tool> {
482 TOOLS
483 .iter()
484 .filter(|t| tool_filter.map_or(true, |f| t.name == f))
485 .collect()
486}
487
488fn upsert_server(
491 cfg: &mut serde_json::Value,
492 servers_key: &str,
493 entry: &serde_json::Value,
494) -> Result<bool> {
495 let servers = cfg
496 .as_object_mut()
497 .context("config root is not an object")?
498 .entry(servers_key)
499 .or_insert_with(|| serde_json::json!({}));
500 let servers = servers
501 .as_object_mut()
502 .with_context(|| format!("{servers_key} is not an object"))?;
503 let already = servers.contains_key(SERVER_NAME);
504 servers.insert(SERVER_NAME.to_string(), entry.clone());
505 Ok(already)
506}
507
508fn remove_server(cfg: &mut serde_json::Value, servers_key: &str) -> bool {
511 cfg.get_mut(servers_key)
512 .and_then(|s| s.as_object_mut())
513 .map(|m| m.remove(SERVER_NAME).is_some())
514 .unwrap_or(false)
515}
516
517fn toml_upsert_server(
523 path: &Path,
524 servers_key: &str,
525 asd_mcp: &Path,
526 db_path: Option<&Path>,
527) -> Result<bool> {
528 use toml_edit::{Array, DocumentMut, Item, Table, value};
529
530 let mut doc = if path.exists() {
531 std::fs::read_to_string(path)
532 .with_context(|| format!("read {}", path.display()))?
533 .parse::<DocumentMut>()
534 .with_context(|| format!("parse TOML in {}", path.display()))?
535 } else {
536 DocumentMut::new()
537 };
538
539 let servers = doc
540 .as_table_mut()
541 .entry(servers_key)
542 .or_insert(Item::Table(Table::new()))
543 .as_table_mut()
544 .with_context(|| format!("{servers_key} is not a table"))?;
545 servers.set_implicit(true);
548
549 let already = servers.contains_key(SERVER_NAME);
550
551 let mut entry = Table::new();
552 entry["command"] = value(asd_mcp.to_string_lossy().into_owned());
553 entry["args"] = value(Array::new());
554 let mut env = Table::new();
555 if let Some(p) = db_path {
556 env["ASD_DB"] = value(p.to_string_lossy().into_owned());
557 }
558 entry["env"] = Item::Table(env);
559 servers[SERVER_NAME] = Item::Table(entry);
560
561 if let Some(parent) = path.parent() {
562 std::fs::create_dir_all(parent)
563 .with_context(|| format!("create dirs for {}", path.display()))?;
564 }
565 std::fs::write(path, doc.to_string()).with_context(|| format!("write {}", path.display()))?;
566 Ok(already)
567}
568
569fn toml_remove_server(path: &Path, servers_key: &str) -> Result<bool> {
572 use toml_edit::DocumentMut;
573
574 if !path.exists() {
575 return Ok(false);
576 }
577 let mut doc = std::fs::read_to_string(path)
578 .with_context(|| format!("read {}", path.display()))?
579 .parse::<DocumentMut>()
580 .with_context(|| format!("parse TOML in {}", path.display()))?;
581 let removed = doc
582 .get_mut(servers_key)
583 .and_then(|i| i.as_table_mut())
584 .map(|t| t.remove(SERVER_NAME).is_some())
585 .unwrap_or(false);
586 if removed {
587 std::fs::write(path, doc.to_string())
588 .with_context(|| format!("write {}", path.display()))?;
589 }
590 Ok(removed)
591}
592
593fn read_registered_entry(
596 path: &Path,
597 format: ConfigFormat,
598 servers_key: &str,
599) -> Option<(String, String)> {
600 match format {
601 ConfigFormat::Json | ConfigFormat::Jsonc => {
602 let cfg = read_config(path, format).ok()?;
603 let e = cfg.get(servers_key)?.get(SERVER_NAME)?;
604 let cmd = e.get("command")?.as_str()?.to_string();
605 let db = e.get("env")?.get("ASD_DB")?.as_str()?.to_string();
606 Some((cmd, db))
607 }
608 ConfigFormat::Toml => {
609 let doc = std::fs::read_to_string(path)
610 .ok()?
611 .parse::<toml_edit::DocumentMut>()
612 .ok()?;
613 let e = doc.get(servers_key)?.get(SERVER_NAME)?;
614 let cmd = e.get("command")?.as_str()?.to_string();
615 let db = e.get("env")?.get("ASD_DB")?.as_str()?.to_string();
616 Some((cmd, db))
617 }
618 ConfigFormat::Manual => None,
619 }
620}
621
622fn install(args: InstallArgs) -> Result<()> {
627 let asd_mcp = find_asd_mcp().ok_or_else(|| {
628 anyhow::anyhow!(
629 "asd-mcp binary not found. Install it with:\n \
630 cargo install --path crates/agentstatedeveloper-mcp"
631 )
632 })?;
633
634 if args.project {
637 let cfg_path = std::env::current_dir()
638 .context("cannot determine current directory")?
639 .join(".mcp.json");
640 let db_opt: Option<PathBuf> = if args.follow_active {
641 None
642 } else {
643 Some(resolve_db(args.db)?)
644 };
645 let entry = build_entry(EntryStyle::Standard, &asd_mcp, db_opt.as_deref());
646 let mut cfg = read_config(&cfg_path, ConfigFormat::Json)?;
647 let already = upsert_server(&mut cfg, "mcpServers", &entry)?;
648 write_config(&cfg_path, &cfg)?;
649
650 eprintln!(
651 " asd {} {}",
652 if already {
653 "updated in"
654 } else {
655 "installed to"
656 },
657 cfg_path.display()
658 );
659 eprintln!(
660 " ASD_DB: {}",
661 db_opt
662 .as_ref()
663 .map(|p| p.display().to_string())
664 .unwrap_or_else(
665 || "(unset — server resolves this project by directory)".to_string()
666 )
667 );
668 eprintln!();
669 eprintln!(
670 "Project-scoped MCP config written. Restart your agent in this project; \
671 it targets this repo's db regardless of any other open sessions."
672 );
673 return Ok(());
674 }
675
676 let db_opt: Option<PathBuf> = if args.follow_active {
679 None
680 } else {
681 Some(resolve_db(args.db)?)
682 };
683 let db_display = db_opt
684 .as_ref()
685 .map(|p| p.display().to_string())
686 .unwrap_or_else(|| "(unset — follows the active repo, `asd repo use`)".to_string());
687 let tools = tools_to_process(args.tool.as_deref());
688
689 if tools.is_empty() {
690 anyhow::bail!(
691 "unknown tool {:?}; valid: {}",
692 args.tool,
693 valid_tool_names()
694 );
695 }
696
697 let mut installed = 0usize;
698 let mut manual = 0usize;
699
700 for tool in &tools {
701 let Some(cfg_path) = (tool.config_path_fn)() else {
702 continue;
703 };
704
705 if tool.format == ConfigFormat::Manual {
707 manual += 1;
708 eprintln!(" {} — manual setup (not auto-edited):", tool.name);
709 eprintln!(" add an MCP server to {} with", cfg_path.display());
710 eprintln!(" command: {}", asd_mcp.display());
711 eprintln!(" env ASD_DB: {}", db_display);
712 eprintln!(
713 " (key `{}`; see the tool's MCP docs for the exact schema)",
714 tool.servers_key
715 );
716 continue;
717 }
718
719 let already = match tool.format {
720 ConfigFormat::Json | ConfigFormat::Jsonc => {
721 let entry = build_entry(tool.entry_style, &asd_mcp, db_opt.as_deref());
722 let mut cfg = read_config(&cfg_path, tool.format)?;
723 let already = upsert_server(&mut cfg, tool.servers_key, &entry)?;
724 write_config(&cfg_path, &cfg)?;
725 already
726 }
727 ConfigFormat::Toml => {
728 toml_upsert_server(&cfg_path, tool.servers_key, &asd_mcp, db_opt.as_deref())?
729 }
730 ConfigFormat::Manual => unreachable!("handled above"),
731 };
732
733 if already {
734 eprintln!(" {} updated {}", tool.name, cfg_path.display());
735 } else {
736 eprintln!(" {} installed {}", tool.name, cfg_path.display());
737 }
738 installed += 1;
739 }
740
741 if installed == 0 && manual == 0 {
742 eprintln!("No matching tool config files found.");
743 eprintln!("Checked:");
744 for tool in &tools {
745 if let Some(p) = (tool.config_path_fn)() {
746 eprintln!(" {} → {}", tool.name, p.display());
747 }
748 }
749 } else {
750 eprintln!();
751 eprintln!(" asd-mcp binary: {}", asd_mcp.display());
752 eprintln!(" ASD_DB: {}", db_display);
753 eprintln!();
754 eprintln!("Restart your agent tool(s) to activate the MCP server.");
755 }
756
757 Ok(())
758}
759
760fn uninstall(args: UninstallArgs) -> Result<()> {
765 let tools = tools_to_process(args.tool.as_deref());
766
767 if tools.is_empty() {
768 anyhow::bail!(
769 "unknown tool {:?}; valid: {}",
770 args.tool,
771 valid_tool_names()
772 );
773 }
774
775 let mut removed = 0usize;
776
777 for tool in &tools {
778 let Some(cfg_path) = (tool.config_path_fn)() else {
779 continue;
780 };
781 if tool.format == ConfigFormat::Manual {
782 eprintln!(
783 " {} — remove the asd MCP server manually from {}",
784 tool.name,
785 cfg_path.display()
786 );
787 continue;
788 }
789 if !cfg_path.exists() {
790 continue;
791 }
792
793 let was_present = match tool.format {
794 ConfigFormat::Json | ConfigFormat::Jsonc => {
795 let mut cfg = read_config(&cfg_path, tool.format)?;
796 let present = remove_server(&mut cfg, tool.servers_key);
797 if present {
798 write_config(&cfg_path, &cfg)?;
799 }
800 present
801 }
802 ConfigFormat::Toml => toml_remove_server(&cfg_path, tool.servers_key)?,
803 ConfigFormat::Manual => unreachable!("handled above"),
804 };
805
806 if was_present {
807 eprintln!(" {} removed {}", tool.name, cfg_path.display());
808 removed += 1;
809 } else {
810 eprintln!(" {} not registered (skipped)", tool.name);
811 }
812 }
813
814 if removed > 0 {
815 eprintln!();
816 eprintln!("Restart your agent tool(s) to deactivate the MCP server.");
817 }
818
819 Ok(())
820}
821
822fn status() -> Result<()> {
827 let bin = find_asd_mcp();
828
829 eprintln!(
830 "asd-mcp binary: {}",
831 match &bin {
832 Some(p) => p.to_string_lossy().into_owned(),
833 None => "NOT FOUND — install with: cargo install --path crates/agentstatedeveloper-mcp"
834 .to_string(),
835 }
836 );
837 eprintln!();
838
839 for tool in TOOLS {
840 let Some(cfg_path) = (tool.config_path_fn)() else {
841 eprintln!(" ✗ {} (unsupported platform)", tool.name);
842 continue;
843 };
844
845 if tool.format == ConfigFormat::Manual {
846 eprintln!(" ⚙ {} manual setup ({})", tool.name, cfg_path.display());
847 continue;
848 }
849
850 if !cfg_path.exists() {
851 eprintln!(
852 " ✗ {} config not found: {}",
853 tool.name,
854 cfg_path.display()
855 );
856 continue;
857 }
858
859 match read_registered_entry(&cfg_path, tool.format, tool.servers_key) {
860 Some((cmd, db)) => {
861 eprintln!(" ✓ {} command={} ASD_DB={}", tool.name, cmd, db);
862 }
863 None => {
864 eprintln!(
865 " ✗ {} not registered ({})",
866 tool.name,
867 cfg_path.display()
868 );
869 }
870 }
871 }
872
873 Ok(())
874}
875
876const BLOCK_BEGIN: &str = "<!-- asd:begin -->";
886const BLOCK_END: &str = "<!-- asd:end -->";
887
888fn instruction_body() -> String {
892 crate::skillgen::render_always_on(&crate::commands::skill::asd_skill_spec())
893}
894
895fn upsert_block(content: &str, block: &str, remove: bool) -> String {
898 if let (Some(start), Some(end_idx)) = (content.find(BLOCK_BEGIN), content.find(BLOCK_END)) {
899 let end = end_idx + BLOCK_END.len();
901 let before = content[..start].trim_end_matches('\n');
902 let after = content[end..].trim_start_matches('\n');
903 let mut out = String::new();
904 if !before.is_empty() {
905 out.push_str(before);
906 out.push_str("\n\n");
907 }
908 if !remove {
909 out.push_str(block);
910 }
911 if !after.is_empty() {
912 if !remove {
913 out.push('\n');
914 }
915 out.push_str(after);
916 out.push('\n');
917 }
918 out
919 } else if remove {
920 content.to_string()
921 } else if content.trim().is_empty() {
922 block.to_string()
923 } else {
924 format!("{}\n\n{block}", content.trim_end_matches('\n'))
925 }
926}
927
928fn instructions(args: InstructionsArgs) -> Result<()> {
929 let files = if args.file.is_empty() {
930 vec!["AGENTS.md".to_string(), "CLAUDE.md".to_string()]
931 } else {
932 args.file
933 };
934 let block = instruction_body();
935 let cwd = std::env::current_dir().context("cannot determine current directory")?;
936
937 for rel in &files {
938 let path = cwd.join(rel);
939 let existing = std::fs::read_to_string(&path).unwrap_or_default();
940 let had_block = existing.contains(BLOCK_BEGIN);
941 let updated = upsert_block(&existing, &block, args.remove);
942
943 if updated == existing {
944 eprintln!(" {rel} — no change");
945 continue;
946 }
947 if let Some(parent) = path.parent() {
948 std::fs::create_dir_all(parent)
949 .with_context(|| format!("create dirs for {}", path.display()))?;
950 }
951 std::fs::write(&path, &updated).with_context(|| format!("write {}", path.display()))?;
952 let verb = if args.remove {
953 "removed ASD block from"
954 } else if had_block {
955 "refreshed ASD block in"
956 } else {
957 "added ASD block to"
958 };
959 eprintln!(" {verb} {rel}");
960 }
961
962 if args.hooks {
963 let path = cwd.join(".claude/settings.json");
964 let mut settings = read_config(&path, ConfigFormat::Json)?;
965 if upsert_claude_hook(&mut settings, args.remove) {
966 write_config(&path, &settings)?;
967 let verb = if args.remove { "removed" } else { "added" };
968 eprintln!(" {verb} ASD PreToolUse hook in .claude/settings.json");
969 } else {
970 eprintln!(" .claude/settings.json — no change");
971 }
972 }
973 Ok(())
974}
975
976const HOOK_MARKER: &str = "asd search / asd context-for";
978
979fn asd_hook_entry() -> serde_json::Value {
980 serde_json::json!({
981 "matcher": "Grep|Glob",
982 "hooks": [{
983 "type": "command",
984 "command": "echo 'ASD indexes this repo — prefer asd search / asd context-for for \
985 structured answers, often cheaper than raw grep/glob.'"
986 }]
987 })
988}
989
990fn upsert_claude_hook(settings: &mut serde_json::Value, remove: bool) -> bool {
993 let Some(obj) = settings.as_object_mut() else {
994 return false;
995 };
996 let hooks = obj.entry("hooks").or_insert_with(|| serde_json::json!({}));
997 let Some(hooks) = hooks.as_object_mut() else {
998 return false;
999 };
1000 let pre = hooks
1001 .entry("PreToolUse")
1002 .or_insert_with(|| serde_json::json!([]));
1003 let Some(arr) = pre.as_array_mut() else {
1004 return false;
1005 };
1006 let existing = arr.iter().position(|e| e.to_string().contains(HOOK_MARKER));
1007 if remove {
1008 if let Some(i) = existing {
1009 arr.remove(i);
1010 return true;
1011 }
1012 return false;
1013 }
1014 if existing.is_some() {
1015 return false;
1016 }
1017 arr.push(asd_hook_entry());
1018 true
1019}
1020
1021#[cfg(test)]
1026mod tests {
1027 use super::*;
1028
1029 #[test]
1030 fn upsert_block_appends_to_existing_content() {
1031 let block = instruction_body();
1032 let out = upsert_block("# My project\n\nNotes here.\n", &block, false);
1033 assert!(out.starts_with("# My project"));
1034 assert!(out.contains(BLOCK_BEGIN) && out.contains(BLOCK_END));
1035 assert!(out.contains("asd context-for"));
1036 }
1037
1038 #[test]
1039 fn upsert_block_is_idempotent() {
1040 let block = instruction_body();
1041 let once = upsert_block("# P\n", &block, false);
1042 let twice = upsert_block(&once, &block, false);
1043 assert_eq!(once, twice, "re-running must not duplicate the block");
1044 assert_eq!(twice.matches(BLOCK_BEGIN).count(), 1);
1045 }
1046
1047 #[test]
1048 fn upsert_block_refreshes_in_place() {
1049 let stale = format!("# P\n\n{BLOCK_BEGIN}\nOLD\n{BLOCK_END}\n\n## After\n");
1051 let out = upsert_block(&stale, &instruction_body(), false);
1052 assert!(out.contains("# P") && out.contains("## After"));
1053 assert!(!out.contains("OLD"));
1054 assert!(out.contains("asd architecture"));
1055 assert_eq!(out.matches(BLOCK_BEGIN).count(), 1);
1056 }
1057
1058 #[test]
1059 fn upsert_block_remove_strips_it() {
1060 let with = upsert_block("# P\n\nbody\n", &instruction_body(), false);
1061 let without = upsert_block(&with, &instruction_body(), true);
1062 assert!(!without.contains(BLOCK_BEGIN));
1063 assert!(without.contains("# P") && without.contains("body"));
1064 }
1065
1066 #[test]
1067 fn claude_hook_add_idempotent_and_preserves_other_hooks() {
1068 let mut s = serde_json::json!({
1069 "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [] } ] },
1070 "model": "x"
1071 });
1072 assert!(upsert_claude_hook(&mut s, false), "first add changes");
1073 assert!(!upsert_claude_hook(&mut s, false), "second add is a no-op");
1074 let arr = s["hooks"]["PreToolUse"].as_array().unwrap();
1075 assert_eq!(arr.len(), 2, "ours added alongside the existing Bash hook");
1076 assert_eq!(s["model"], "x", "unrelated settings preserved");
1077 }
1078
1079 #[test]
1080 fn claude_hook_remove() {
1081 let mut s = serde_json::json!({});
1082 upsert_claude_hook(&mut s, false);
1083 assert!(upsert_claude_hook(&mut s, true), "remove changes");
1084 assert!(s["hooks"]["PreToolUse"].as_array().unwrap().is_empty());
1085 assert!(
1086 !upsert_claude_hook(&mut s, true),
1087 "second remove is a no-op"
1088 );
1089 }
1090
1091 #[test]
1092 fn tool_names_are_unique() {
1093 let mut names: Vec<&str> = TOOLS.iter().map(|t| t.name).collect();
1094 let count = names.len();
1095 names.sort_unstable();
1096 names.dedup();
1097 assert_eq!(names.len(), count, "duplicate tool name in TOOLS");
1098 }
1099
1100 #[test]
1101 fn registry_includes_all_tools() {
1102 let names: Vec<&str> = TOOLS.iter().map(|t| t.name).collect();
1103 for expected in [
1104 "claude-code",
1105 "claude-desktop",
1106 "cursor",
1107 "gemini-cli",
1108 "windsurf",
1109 "zed",
1110 "vscode",
1111 "cline",
1112 "codex-cli",
1113 "kilo-code",
1114 "antigravity",
1115 "aider",
1116 ] {
1117 assert!(names.contains(&expected), "TOOLS missing {expected}");
1118 }
1119 let listed = valid_tool_names();
1121 assert!(listed.contains("zed") && listed.contains("vscode") && listed.contains("cline"));
1122 assert!(listed.contains("codex-cli") && listed.contains("kilo-code"));
1123 assert!(listed.contains("antigravity") && listed.contains("aider"));
1124 }
1125
1126 #[test]
1127 fn jsonc_comments_stripped_but_strings_preserved() {
1128 let src =
1129 "{\n // line comment\n \"url\": \"http://x/a\", /* block */\n \"k\": \"a//b\"\n}";
1130 let parsed: serde_json::Value = serde_json::from_str(&strip_jsonc_comments(src)).unwrap();
1131 assert_eq!(parsed["url"], "http://x/a");
1133 assert_eq!(parsed["k"], "a//b");
1134 }
1135
1136 #[test]
1137 fn jsonc_round_trip_preserves_sibling_servers() {
1138 let dir = std::env::temp_dir().join(format!("asd_mcp_jsonc_{}", std::process::id()));
1140 std::fs::create_dir_all(&dir).unwrap();
1141 let path = dir.join("kilo.jsonc");
1142 std::fs::write(
1143 &path,
1144 "{\n // my config\n \"mcpServers\": { \"other\": { \"command\": \"x\" } }\n}",
1145 )
1146 .unwrap();
1147 let mut cfg = read_config(&path, ConfigFormat::Jsonc).unwrap();
1148 upsert_server(&mut cfg, "mcpServers", &std_entry()).unwrap();
1149 write_config(&path, &cfg).unwrap();
1150 let back = read_config(&path, ConfigFormat::Jsonc).unwrap();
1152 assert!(back["mcpServers"]["other"].is_object());
1153 assert!(back["mcpServers"]["asd"].is_object());
1154 let _ = std::fs::remove_dir_all(&dir);
1155 }
1156
1157 fn std_entry() -> serde_json::Value {
1158 build_entry(
1159 EntryStyle::Standard,
1160 Path::new("/bin/asd-mcp"),
1161 Some(Path::new("/db")),
1162 )
1163 }
1164
1165 #[test]
1166 fn standard_entry_has_expected_shape() {
1167 let entry = build_entry(
1168 EntryStyle::Standard,
1169 Path::new("/usr/local/bin/asd-mcp"),
1170 Some(Path::new("/repo/.asd-state.db")),
1171 );
1172 assert_eq!(entry["command"], "/usr/local/bin/asd-mcp");
1173 assert!(entry["args"].as_array().unwrap().is_empty());
1174 assert_eq!(entry["env"]["ASD_DB"], "/repo/.asd-state.db");
1175 assert!(entry.get("source").is_none() && entry.get("type").is_none());
1177 }
1178
1179 #[test]
1180 fn follow_active_entry_omits_asd_db() {
1181 let entry = build_entry(EntryStyle::Standard, Path::new("/bin/asd-mcp"), None);
1184 assert!(
1185 entry["env"].as_object().unwrap().is_empty(),
1186 "no ASD_DB pinned when following the active repo"
1187 );
1188 assert_eq!(entry["command"], "/bin/asd-mcp");
1189 }
1190
1191 #[test]
1192 fn divergent_entry_styles_add_their_marker() {
1193 let zed = build_entry(
1194 EntryStyle::ZedCustom,
1195 Path::new("/bin/asd-mcp"),
1196 Some(Path::new("/db")),
1197 );
1198 assert_eq!(zed["source"], "custom");
1199 assert_eq!(zed["command"], "/bin/asd-mcp"); let vscode = build_entry(
1202 EntryStyle::VsCodeStdio,
1203 Path::new("/bin/asd-mcp"),
1204 Some(Path::new("/db")),
1205 );
1206 assert_eq!(vscode["type"], "stdio");
1207 assert_eq!(vscode["env"]["ASD_DB"], "/db");
1208 }
1209
1210 #[test]
1211 fn upsert_uses_the_tools_servers_key() {
1212 let mut cfg = serde_json::json!({});
1214 let entry = build_entry(
1215 EntryStyle::ZedCustom,
1216 Path::new("/bin/asd-mcp"),
1217 Some(Path::new("/db")),
1218 );
1219 let already = upsert_server(&mut cfg, "context_servers", &entry).unwrap();
1220 assert!(!already);
1221 assert_eq!(cfg["context_servers"]["asd"]["source"], "custom");
1222 assert!(cfg.get("mcpServers").is_none(), "must not touch mcpServers");
1223 }
1224
1225 #[test]
1226 fn upsert_into_empty_config_creates_entry() {
1227 let mut cfg = serde_json::json!({});
1228 let already = upsert_server(&mut cfg, "mcpServers", &std_entry()).unwrap();
1229 assert!(!already, "fresh insert should not report already-present");
1230 assert_eq!(cfg["mcpServers"]["asd"]["command"], "/bin/asd-mcp");
1231 }
1232
1233 #[test]
1234 fn upsert_is_idempotent_and_reports_update() {
1235 let mut cfg = serde_json::json!({});
1236 assert!(!upsert_server(&mut cfg, "mcpServers", &std_entry()).unwrap());
1237 assert!(upsert_server(&mut cfg, "mcpServers", &std_entry()).unwrap());
1239 assert_eq!(cfg["mcpServers"].as_object().unwrap().len(), 1);
1240 }
1241
1242 #[test]
1243 fn upsert_preserves_sibling_servers() {
1244 let mut cfg = serde_json::json!({
1245 "servers": { "other": { "command": "x" } },
1246 "someOtherKey": 1
1247 });
1248 upsert_server(&mut cfg, "servers", &std_entry()).unwrap();
1249 assert_eq!(cfg["servers"]["other"]["command"], "x");
1250 assert_eq!(cfg["someOtherKey"], 1);
1251 assert!(cfg["servers"]["asd"].is_object());
1252 }
1253
1254 #[test]
1255 fn remove_returns_false_when_absent_true_when_present() {
1256 let mut cfg = serde_json::json!({});
1257 assert!(!remove_server(&mut cfg, "mcpServers"), "nothing to remove");
1258 upsert_server(&mut cfg, "mcpServers", &std_entry()).unwrap();
1259 assert!(
1260 remove_server(&mut cfg, "mcpServers"),
1261 "should remove the asd entry"
1262 );
1263 assert!(
1264 !remove_server(&mut cfg, "mcpServers"),
1265 "second remove is a no-op"
1266 );
1267 }
1268
1269 fn temp_toml(tag: &str) -> PathBuf {
1270 let dir = std::env::temp_dir().join(format!("asd_mcp_test_{}_{}", std::process::id(), tag));
1271 std::fs::create_dir_all(&dir).unwrap();
1272 dir.join("config.toml")
1273 }
1274
1275 #[test]
1276 fn toml_upsert_preserves_comments_and_siblings() {
1277 let path = temp_toml("preserve");
1278 std::fs::write(
1279 &path,
1280 "# hand-written codex config\nmodel = \"o3\"\n\n[mcp_servers.other]\ncommand = \"x\"\n",
1281 )
1282 .unwrap();
1283
1284 let already = toml_upsert_server(
1285 &path,
1286 "mcp_servers",
1287 Path::new("/bin/asd-mcp"),
1288 Some(Path::new("/db")),
1289 )
1290 .unwrap();
1291 assert!(!already, "fresh insert");
1292
1293 let txt = std::fs::read_to_string(&path).unwrap();
1294 assert!(txt.contains("# hand-written codex config"), "comment kept");
1295 assert!(txt.contains("model = \"o3\""), "sibling setting kept");
1296 assert!(txt.contains("[mcp_servers.other]"), "sibling server kept");
1297 assert!(txt.contains("[mcp_servers.asd]"), "asd table written");
1298 assert!(txt.contains("ASD_DB"), "env written");
1299
1300 let (cmd, db) = read_registered_entry(&path, ConfigFormat::Toml, "mcp_servers").unwrap();
1302 assert_eq!(cmd, "/bin/asd-mcp");
1303 assert_eq!(db, "/db");
1304
1305 assert!(
1307 toml_upsert_server(
1308 &path,
1309 "mcp_servers",
1310 Path::new("/bin/asd-mcp"),
1311 Some(Path::new("/db"))
1312 )
1313 .unwrap()
1314 );
1315
1316 assert!(toml_remove_server(&path, "mcp_servers").unwrap());
1318 let txt2 = std::fs::read_to_string(&path).unwrap();
1319 assert!(!txt2.contains("[mcp_servers.asd]"), "asd removed");
1320 assert!(
1321 txt2.contains("# hand-written codex config"),
1322 "comment survives removal"
1323 );
1324 assert!(
1325 txt2.contains("[mcp_servers.other]"),
1326 "sibling survives removal"
1327 );
1328 assert!(
1329 !toml_remove_server(&path, "mcp_servers").unwrap(),
1330 "second remove no-op"
1331 );
1332
1333 let _ = std::fs::remove_dir_all(path.parent().unwrap());
1334 }
1335
1336 #[test]
1337 fn toml_upsert_creates_file_when_absent() {
1338 let path = temp_toml("create");
1339 let _ = std::fs::remove_file(&path);
1340 toml_upsert_server(
1341 &path,
1342 "mcp_servers",
1343 Path::new("/bin/asd-mcp"),
1344 Some(Path::new("/db")),
1345 )
1346 .unwrap();
1347 let (cmd, _db) = read_registered_entry(&path, ConfigFormat::Toml, "mcp_servers").unwrap();
1348 assert_eq!(cmd, "/bin/asd-mcp");
1349 let _ = std::fs::remove_dir_all(path.parent().unwrap());
1350 }
1351}