1use std::io::{IsTerminal, Write as _};
26use std::path::{Path, PathBuf};
27
28use clap::{Args as ClapArgs, ValueEnum};
29use memstead_base::filesystem::config::{config_path, init_filesystem_mem, validate_mem_name};
30use memstead_base::vcs::Actor;
31use memstead_base::{CreateEntityArgs, Engine as BaseEngine};
32use serde_json::json;
33
34use crate::CliError;
35use crate::output::{ExitKind, print_json, print_markdown};
36use crate::setup::{CliContext, memstead_program, shell_quote};
37
38use super::init::find_ancestor_workspace;
39
40#[derive(ClapArgs, Debug)]
42pub struct Args {
43 #[arg(value_name = "PATH")]
45 pub path: Option<PathBuf>,
46
47 #[arg(long)]
51 pub name: Option<String>,
52
53 #[arg(long = "agent", value_enum)]
57 pub agents: Vec<AgentTarget>,
58}
59
60#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
66pub enum AgentTarget {
67 ClaudeCode,
69 Codex,
72 Cursor,
74 Gemini,
76}
77
78impl AgentTarget {
79 fn label(self) -> &'static str {
80 match self {
81 AgentTarget::ClaudeCode => "Claude Code",
82 AgentTarget::Codex => "Codex",
83 AgentTarget::Cursor => "Cursor",
84 AgentTarget::Gemini => "Gemini CLI",
85 }
86 }
87
88 fn config_file(self) -> Option<&'static str> {
91 match self {
92 AgentTarget::ClaudeCode => Some(".mcp.json"),
93 AgentTarget::Cursor => Some(".cursor/mcp.json"),
94 AgentTarget::Gemini => Some(".gemini/settings.json"),
95 AgentTarget::Codex => None,
96 }
97 }
98
99 const ALL: [AgentTarget; 4] = [
100 AgentTarget::ClaudeCode,
101 AgentTarget::Codex,
102 AgentTarget::Cursor,
103 AgentTarget::Gemini,
104 ];
105}
106
107struct WiringOutcome {
109 target: AgentTarget,
110 action: String,
112}
113
114pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
115 let target = args
116 .path
117 .clone()
118 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
119
120 if target.exists() && !target.is_dir() {
121 return Err(CliError::new(
122 ExitKind::Validation,
123 "INVALID_INPUT",
124 format!(
125 "target {} exists but is not a directory — point at a folder: \
126 memstead quickstart my-graph",
127 target.display(),
128 ),
129 )
130 .into());
131 }
132 if !target.exists() {
133 std::fs::create_dir_all(&target).map_err(|e| {
134 CliError::new(
135 ExitKind::Generic,
136 crate::INTERNAL_CODE,
137 format!(
138 "failed to create target directory {}: {e}",
139 target.display()
140 ),
141 )
142 })?;
143 }
144
145 check_no_local_memstead(&target)?;
147
148 if let Some(found_at) = find_ancestor_workspace(&target)? {
156 return Err(CliError::new(
157 ExitKind::Validation,
158 crate::WORKSPACE_ALREADY_EXISTS_ABOVE_CODE,
159 format!(
160 "{} is already inside the memstead workspace at {} — quickstart \
161 refuses to nest workspaces. Work in that workspace (memstead \
162 overview), or start a separate graph outside it: mkdir my-graph && \
163 cd my-graph && memstead quickstart",
164 target.display(),
165 found_at.display(),
166 ),
167 )
168 .with_details(json!({ "found_at": found_at.display().to_string() }))
169 .into());
170 }
171
172 let blocking = blocking_entries(&target)?;
179 if !blocking.is_empty() {
180 let md_note = if blocking.iter().any(|f| f.ends_with(".md`")) {
181 " (a filesystem mem owns every `.md` file in its folder, so quickstart \
182 would silently adopt them into the graph)"
183 } else {
184 ""
185 };
186 return Err(CliError::new(
187 ExitKind::Validation,
188 crate::TARGET_NOT_EMPTY_CODE,
189 format!(
190 "target {} has content quickstart won't touch: {}{md_note} — move it \
191 out, or start in a fresh folder: mkdir my-graph && cd my-graph && \
192 memstead quickstart",
193 target.display(),
194 blocking.join(", "),
195 ),
196 )
197 .with_details(json!({
198 "path": target.display().to_string(),
199 "found": blocking,
200 }))
201 .into());
202 }
203
204 let name = resolve_mem_name(&target, args.name.as_deref())?;
207
208 let (agents, agents_defaulted) = resolve_agents(&args.agents)?;
210
211 for agent in &agents {
217 if let Some(rel) = agent.config_file() {
218 read_agent_config(&target.join(rel))?;
219 }
220 }
221
222 let schema_pin = default_schema_pin()?;
225
226 init_filesystem_mem(&target, &name, &schema_pin).map_err(|e| {
229 CliError::new(
230 ExitKind::Generic,
231 crate::INTERNAL_CODE,
232 format!("initialise filesystem mem: {e}"),
233 )
234 })?;
235
236 let seed_id = seed_entity(&target, &name)?;
238
239 let mcp_bin = resolve_mcp_binary();
241 let mut wirings = Vec::with_capacity(agents.len());
242 for agent in &agents {
243 wirings.push(wire_agent(&target, *agent, &mcp_bin.command)?);
244 }
245
246 report(
247 ctx,
248 &target,
249 &name,
250 &schema_pin,
251 &seed_id,
252 &wirings,
253 agents_defaulted,
254 &mcp_bin,
255 )
256}
257
258fn check_no_local_memstead(target: &Path) -> anyhow::Result<()> {
263 let store = target.join(memstead_base::WORKSPACE_STORE_DIR);
264 if !store.exists() {
265 return Ok(());
266 }
267 if memstead_base::is_workspace_root(target) {
268 return Err(CliError::new(
269 ExitKind::Validation,
270 "WORKSPACE_ALREADY_INITIALISED",
271 format!(
272 "{} is already a Memstead workspace — nothing to bootstrap. \
273 Inspect it with: memstead overview",
274 target.display(),
275 ),
276 )
277 .with_details(json!({ "path": target.display().to_string() }))
278 .into());
279 }
280 Err(CliError::new(
281 ExitKind::Validation,
282 "FOREIGN_MEMSTEAD_DIR",
283 format!(
284 "{} contains a `.memstead/` directory that is not a workspace \
285 (no workspace.toml) — quickstart won't adopt or overwrite it. \
286 Move it aside, or start fresh: mkdir my-graph && cd my-graph && \
287 memstead quickstart",
288 target.display(),
289 ),
290 )
291 .with_details(json!({ "path": store.display().to_string() }))
292 .into())
293}
294
295fn blocking_entries(target: &Path) -> anyhow::Result<Vec<String>> {
303 let read_err = |e: std::io::Error| {
304 CliError::new(
305 ExitKind::Generic,
306 crate::INTERNAL_CODE,
307 format!("read target {}: {e}", target.display()),
308 )
309 };
310 let mut blocking = Vec::new();
311 for entry in std::fs::read_dir(target).map_err(read_err)? {
312 let entry = entry.map_err(read_err)?;
313 let name = entry.file_name().to_string_lossy().to_string();
314 if name.starts_with('.') {
315 continue;
316 }
317 let lower = name.to_lowercase();
318 let readme_grade = lower.starts_with("readme")
319 || lower.starts_with("license")
320 || lower.starts_with("licence");
321 if readme_grade && !lower.ends_with(".md") {
322 continue;
323 }
324 blocking.push(format!("`{name}`"));
325 }
326 blocking.sort();
327 Ok(blocking)
328}
329
330fn resolve_mem_name(target: &Path, flag: Option<&str>) -> anyhow::Result<String> {
334 if let Some(name) = flag {
335 validate_mem_name(name).map_err(|e| {
336 CliError::new(
337 ExitKind::Validation,
338 "INVALID_INPUT",
339 format!(
340 "invalid --name: {e}. Retry with a slug, e.g.: memstead quickstart \
341 --name {}",
342 derive_mem_name(name).unwrap_or_else(|| "my-graph".to_string()),
343 ),
344 )
345 })?;
346 return Ok(name.to_string());
347 }
348 let basename = std::fs::canonicalize(target)
349 .ok()
350 .and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string()))
351 .unwrap_or_default();
352 if let Some(derived) = derive_mem_name(&basename) {
353 return Ok(derived);
354 }
355 if std::io::stdin().is_terminal() {
356 let answer = prompt_line(&format!(
357 "Could not derive a mem name from `{basename}`. Mem name (lowercase letters, digits, hyphens): ",
358 ))?;
359 let answer = answer.trim();
360 validate_mem_name(answer).map_err(|e| {
361 CliError::new(
362 ExitKind::Validation,
363 "INVALID_INPUT",
364 format!("invalid mem name: {e}. Retry with: memstead quickstart --name my-graph"),
365 )
366 })?;
367 return Ok(answer.to_string());
368 }
369 Err(CliError::new(
370 ExitKind::Validation,
371 "INVALID_INPUT",
372 format!(
373 "could not derive a mem name from directory `{basename}` — \
374 pass one explicitly: memstead quickstart --name my-graph",
375 ),
376 )
377 .with_details(json!({ "directory": basename }))
378 .into())
379}
380
381fn derive_mem_name(basename: &str) -> Option<String> {
385 let mut out = String::with_capacity(basename.len());
386 for c in basename.to_lowercase().chars() {
387 if c.is_ascii_lowercase() || c.is_ascii_digit() {
388 out.push(c);
389 } else if !out.is_empty() && !out.ends_with('-') {
390 out.push('-');
391 }
392 }
393 let mut slug: String = out.trim_matches('-').chars().take(64).collect();
394 slug = slug.trim_matches('-').to_string();
395 validate_mem_name(&slug).ok().map(|()| slug)
396}
397
398fn resolve_agents(flag: &[AgentTarget]) -> anyhow::Result<(Vec<AgentTarget>, bool)> {
402 if !flag.is_empty() {
403 let mut seen = Vec::with_capacity(flag.len());
404 for a in flag {
405 if !seen.contains(a) {
406 seen.push(*a);
407 }
408 }
409 return Ok((seen, false));
410 }
411 if std::io::stdin().is_terminal() {
412 return Ok((prompt_agents()?, false));
413 }
414 Ok((vec![AgentTarget::ClaudeCode], true))
415}
416
417fn prompt_agents() -> anyhow::Result<Vec<AgentTarget>> {
420 let menu: Vec<String> = AgentTarget::ALL
421 .iter()
422 .enumerate()
423 .map(|(i, a)| format!(" {}) {}", i + 1, a.label()))
424 .collect();
425 let answer = prompt_line(&format!(
426 "Which agents should connect to this mem? (comma-separated, Enter = Claude Code)\n{}\n> ",
427 menu.join("\n"),
428 ))?;
429 let answer = answer.trim();
430 if answer.is_empty() {
431 return Ok(vec![AgentTarget::ClaudeCode]);
432 }
433 let mut selected = Vec::new();
434 for token in answer.split(',') {
435 let token = token.trim();
436 let picked = match token.parse::<usize>() {
437 Ok(n) if (1..=AgentTarget::ALL.len()).contains(&n) => AgentTarget::ALL[n - 1],
438 _ => {
439 return Err(CliError::new(
440 ExitKind::Validation,
441 "INVALID_INPUT",
442 format!(
443 "unrecognised selection `{token}` — expected numbers 1-{max} \
444 (comma-separated). Skip the prompt with: memstead quickstart \
445 --agent claude-code --agent cursor",
446 max = AgentTarget::ALL.len(),
447 ),
448 )
449 .into());
450 }
451 };
452 if !selected.contains(&picked) {
453 selected.push(picked);
454 }
455 }
456 Ok(selected)
457}
458
459fn prompt_line(msg: &str) -> anyhow::Result<String> {
462 let mut stderr = std::io::stderr();
463 stderr.write_all(msg.as_bytes()).ok();
464 stderr.flush().ok();
465 let mut line = String::new();
466 std::io::stdin().read_line(&mut line).map_err(|e| {
467 CliError::new(
468 ExitKind::Generic,
469 crate::INTERNAL_CODE,
470 format!("read answer from stdin: {e}"),
471 )
472 })?;
473 Ok(line)
474}
475
476fn default_schema_pin() -> anyhow::Result<memstead_schema::SchemaRef> {
481 let reg = memstead_schema::SchemaRegistry::builtin();
482 match reg.get("default", &semver::Version::new(1, 3, 0)) {
483 Some(schema) => {
484 let (name, version) = schema.id();
485 Ok(memstead_schema::SchemaRef::new(name, version))
486 }
487 _ => Err(CliError::new(
488 ExitKind::Generic,
489 crate::INTERNAL_CODE,
490 "builtin schema catalogue has no `default` schema — this binary is broken, please report",
491 )
492 .into()),
493 }
494}
495
496fn seed_entity(target: &Path, mem: &str) -> anyhow::Result<String> {
500 let mut engine = BaseEngine::from_workspace_root(target).map_err(|e| {
501 CliError::new(
502 ExitKind::Generic,
503 crate::INTERNAL_CODE,
504 format!("boot engine at {}: {e:#}", target.display()),
505 )
506 })?;
507 let mut sections = indexmap::IndexMap::new();
508 sections.insert(
509 "definition".to_string(),
510 "This mem is a typed knowledge graph: markdown entities validated against a schema, \
511 connected by typed relationships."
512 .to_string(),
513 );
514 sections.insert(
515 "explanation".to_string(),
516 "`memstead quickstart` seeded this entity so the graph starts non-empty. Read it back \
517 with `memstead entity <id>`, list types with `memstead type`, create your own with \
518 `memstead create`, and delete this one any time with `memstead delete <id>`."
519 .to_string(),
520 );
521 let outcome = engine
522 .create_entity(
523 CreateEntityArgs {
524 anchors: Vec::new(),
525 mem: mem.to_string(),
526 title: "Welcome to Memstead".to_string(),
527 entity_type: "concept".to_string(),
528 sections,
529 metadata: indexmap::IndexMap::new(),
530 relations: Vec::new(),
531 dry_run: false,
532 },
533 Actor::Cli,
534 None,
535 Some("seeded by memstead quickstart"),
536 )
537 .map_err(CliError::from_engine_op)?;
538 Ok(outcome.id.as_ref().to_string())
539}
540
541struct McpBinary {
545 command: String,
546 warning: Option<String>,
547}
548
549fn resolve_mcp_binary() -> McpBinary {
553 if let Ok(exe) = std::env::current_exe()
554 && let Some(dir) = exe.parent()
555 {
556 let sibling = dir.join("memstead-mcp");
557 if sibling.is_file() {
558 return McpBinary {
559 command: sibling.display().to_string(),
560 warning: None,
561 };
562 }
563 }
564 if let Some(paths) = std::env::var_os("PATH") {
565 for dir in std::env::split_paths(&paths) {
566 let candidate = dir.join("memstead-mcp");
567 if candidate.is_file() {
568 return McpBinary {
569 command: candidate.display().to_string(),
570 warning: None,
571 };
572 }
573 }
574 }
575 McpBinary {
576 command: "memstead-mcp".to_string(),
577 warning: Some(
578 "`memstead-mcp` was not found next to this binary or on PATH — the wiring uses the \
579 bare name and will work once it is installed (curl -sSf https://memstead.io/install.sh | sh)"
580 .to_string(),
581 ),
582 }
583}
584
585fn read_agent_config(path: &Path) -> anyhow::Result<serde_json::Value> {
591 if !path.is_file() {
592 return Ok(json!({}));
593 }
594 let fix_hint = "fix or remove the file, then re-run: memstead quickstart";
595 let bytes = std::fs::read(path).map_err(|e| {
596 CliError::new(
597 ExitKind::Generic,
598 crate::INTERNAL_CODE,
599 format!("read {}: {e}", path.display()),
600 )
601 })?;
602 let root: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| {
603 CliError::new(
604 ExitKind::Validation,
605 "INVALID_INPUT",
606 format!(
607 "{} exists but is not valid JSON ({e}) — {fix_hint}",
608 path.display()
609 ),
610 )
611 })?;
612 if !root.is_object() {
613 return Err(CliError::new(
614 ExitKind::Validation,
615 "INVALID_INPUT",
616 format!(
617 "{} exists but its top level is not a JSON object — {fix_hint}",
618 path.display(),
619 ),
620 )
621 .into());
622 }
623 let servers = &root["mcpServers"];
624 if !servers.is_null() && !servers.is_object() {
625 return Err(CliError::new(
626 ExitKind::Validation,
627 "INVALID_INPUT",
628 format!(
629 "{}'s `mcpServers` is not a JSON object — {fix_hint}",
630 path.display(),
631 ),
632 )
633 .into());
634 }
635 Ok(root)
636}
637
638fn wire_agent(
643 target: &Path,
644 agent: AgentTarget,
645 mcp_command: &str,
646) -> anyhow::Result<WiringOutcome> {
647 let Some(rel) = agent.config_file() else {
648 let add = ShellCmd::new("codex")
652 .arg("mcp")
653 .arg("add")
654 .arg("memstead")
655 .end_of_options()
656 .arg(mcp_command)
657 .render();
658 return Ok(WiringOutcome {
659 target: agent,
660 action: format!("run: `{add}`"),
661 });
662 };
663 let path = target.join(rel);
664 let mut root = read_agent_config(&path)?;
665
666 let servers = root
667 .as_object_mut()
668 .expect("read_agent_config only returns JSON objects")
669 .entry("mcpServers")
670 .or_insert_with(|| json!({}));
671 let servers = servers.as_object_mut().ok_or_else(|| {
672 CliError::new(
673 ExitKind::Validation,
674 "INVALID_INPUT",
675 format!(
676 "{}'s `mcpServers` is not a JSON object — fix or remove the file, then \
677 re-run: memstead quickstart",
678 path.display(),
679 ),
680 )
681 })?;
682
683 if servers.contains_key("memstead") {
684 return Ok(WiringOutcome {
685 target: agent,
686 action: format!("`{rel}` already has a `memstead` server entry — left untouched"),
687 });
688 }
689 servers.insert("memstead".to_string(), json!({ "command": mcp_command }));
690
691 if let Some(parent) = path.parent() {
692 std::fs::create_dir_all(parent).map_err(|e| {
693 CliError::new(
694 ExitKind::Generic,
695 crate::INTERNAL_CODE,
696 format!("create {}: {e}", parent.display()),
697 )
698 })?;
699 }
700 let rendered = format!(
701 "{}\n",
702 serde_json::to_string_pretty(&root).unwrap_or_default()
703 );
704 std::fs::write(&path, rendered).map_err(|e| {
705 CliError::new(
706 ExitKind::Generic,
707 crate::INTERNAL_CODE,
708 format!("write {}: {e}", path.display()),
709 )
710 })?;
711 Ok(WiringOutcome {
712 target: agent,
713 action: format!("wrote `{rel}` (server `memstead`)"),
714 })
715}
716
717enum Word {
730 Value(String),
731 Literal(&'static str),
732}
733
734struct ShellCmd {
735 cd: Option<String>,
737 program: String,
738 args: Vec<Word>,
739}
740
741impl ShellCmd {
742 fn new(program: impl Into<String>) -> Self {
743 ShellCmd {
744 cd: None,
745 program: program.into(),
746 args: Vec::new(),
747 }
748 }
749
750 fn arg(mut self, arg: impl Into<String>) -> Self {
751 self.args.push(Word::Value(arg.into()));
752 self
753 }
754
755 fn end_of_options(mut self) -> Self {
760 self.args.push(Word::Literal("--"));
761 self
762 }
763
764 fn in_dir(mut self, dir: &Path, already_there: bool) -> Self {
766 if !already_there {
767 self.cd = Some(dir.display().to_string());
768 }
769 self
770 }
771
772 fn render(&self) -> String {
775 let mut out = String::new();
776 if let Some(dir) = &self.cd {
777 out.push_str(&format!("cd -- {} && ", shell_quote(dir)));
778 }
779 out.push_str(&shell_quote(&self.program));
780 for arg in &self.args {
781 out.push(' ');
782 match arg {
783 Word::Value(v) => out.push_str(&shell_quote(v)),
784 Word::Literal(l) => out.push_str(l),
785 }
786 }
787 out
788 }
789}
790
791#[allow(clippy::too_many_arguments)]
793fn report(
794 ctx: &CliContext,
795 target: &Path,
796 name: &str,
797 schema_pin: &memstead_schema::SchemaRef,
798 seed_id: &str,
799 wirings: &[WiringOutcome],
800 agents_defaulted: bool,
801 mcp_bin: &McpBinary,
802) -> anyhow::Result<()> {
803 let restart_labels: Vec<&str> = wirings.iter().map(|w| w.target.label()).collect();
804
805 let absolute = target
815 .canonicalize()
816 .unwrap_or_else(|_| target.to_path_buf());
817 let in_cwd = std::env::current_dir()
818 .ok()
819 .is_some_and(|cwd| cwd == absolute);
820 let memstead = memstead_program();
821 let overview_cmd = ShellCmd::new(&memstead)
822 .arg("overview")
823 .in_dir(target, in_cwd)
824 .render();
825 let delete_cmd = ShellCmd::new(&memstead)
826 .arg("delete")
827 .arg(seed_id)
828 .in_dir(target, in_cwd)
829 .render();
830 let version_cmd = ShellCmd::new(&mcp_bin.command).arg("--version").render();
831
832 let codex_pending = wirings
836 .iter()
837 .any(|w| w.target == AgentTarget::Codex && w.action.starts_with("run:"));
838 let restart_clause = format!(
839 "Restart {} so the `memstead` MCP server registers its tools",
840 restart_labels.join(" / "),
841 );
842 let next_action = if codex_pending {
843 format!(
844 "Run the `codex mcp add` command above first — it is Codex's wiring, and a restart \
845 registers nothing without it. Then: {restart_clause} — then try: {overview_cmd}"
846 )
847 } else {
848 format!("{restart_clause} — then try: {overview_cmd}")
849 };
850 let mut verify_now: Vec<(&str, String)> = Vec::new();
857 if mcp_bin.warning.is_none() {
863 verify_now.push(("the wired binary answers", version_cmd));
864 }
865 verify_now.push(("the graph is already readable", overview_cmd.clone()));
866
867 if ctx.json {
868 return print_json(&json!({
869 "workspace_root": absolute.display().to_string(),
872 "config_path": config_path(&absolute).display().to_string(),
873 "seed_entity_delete_command": delete_cmd,
874 "name": name,
875 "schema": schema_pin.as_display(),
876 "seed_entity": seed_id,
877 "mcp_command": mcp_bin.command,
878 "agents": wirings
879 .iter()
880 .map(|w| json!({
881 "target": w.target.to_possible_value().map(|v| v.get_name().to_string()),
882 "action": w.action,
883 }))
884 .collect::<Vec<_>>(),
885 "agents_defaulted": agents_defaulted,
886 "workspace_shape": crate::setup::WorkspaceShape::Filesystem.label(),
887 "workspace_shape_disclosure":
892 crate::setup::shape_disclosure(crate::setup::WorkspaceShape::Filesystem).to_json(),
893 "next_action": next_action,
894 "verify_now": verify_now
895 .iter()
896 .map(|(what, command)| json!({ "what": what, "command": command }))
897 .collect::<Vec<_>>(),
898 "warnings": mcp_bin.warning.as_ref().map(|w| vec![w.clone()]).unwrap_or_default(),
899 }));
900 }
901
902 let mut lines = vec![
903 format!("# Quickstart complete — mem `{name}`"),
904 String::new(),
905 format!("- Workspace: `{}`", target.display()),
906 format!("- Schema pin: `{}`", schema_pin.as_display()),
907 format!("- Seed entity: `{seed_id}` (remove any time: `{delete_cmd}`)"),
908 ];
909 for w in wirings {
910 lines.push(format!("- {}: {}", w.target.label(), w.action));
911 }
912 if agents_defaulted {
913 lines.push(
914 "- No `--agent` given and no terminal to ask — defaulted to Claude Code \
915 (re-run with `--agent` for others)"
916 .to_string(),
917 );
918 }
919 if let Some(warning) = &mcp_bin.warning {
920 lines.push(String::new());
921 lines.push(format!("> warning: {warning}"));
922 }
923 lines.push(String::new());
928 lines.extend(crate::setup::shape_disclosure_lines(
929 crate::setup::WorkspaceShape::Filesystem,
930 ));
931 lines.push(String::new());
932 lines.push(format!("Next: {next_action}"));
933 lines.push(String::new());
934 lines.push("Verify from this session, no restart needed:".to_string());
935 lines.extend(
936 verify_now
937 .iter()
938 .map(|(what, command)| format!("- {what}: `{command}`")),
939 );
940 print_markdown(&lines.join("\n"));
941 Ok(())
942}
943
944#[cfg(test)]
945mod tests {
946 use super::*;
947
948 #[test]
949 fn derive_mem_name_handles_common_directory_names() {
950 assert_eq!(derive_mem_name("my-graph").as_deref(), Some("my-graph"));
951 assert_eq!(derive_mem_name("My Project").as_deref(), Some("my-project"));
952 assert_eq!(
953 derive_mem_name("Notes_2026 (v2)").as_deref(),
954 Some("notes-2026-v2")
955 );
956 assert_eq!(derive_mem_name("日本語"), None);
958 assert_eq!(derive_mem_name(""), None);
959 assert_eq!(derive_mem_name("a"), None);
961 }
962
963 #[test]
964 fn blocking_entries_tolerates_dotfiles_and_readme_grade() {
965 let tmp = tempfile::tempdir().unwrap();
966 for f in [".gitignore", ".mcp.json", "README", "LICENSE", "Readme.txt"] {
967 std::fs::write(tmp.path().join(f), b"x").unwrap();
968 }
969 std::fs::create_dir(tmp.path().join(".git")).unwrap();
970 assert!(blocking_entries(tmp.path()).unwrap().is_empty());
971
972 std::fs::write(tmp.path().join("README.md"), b"# hi").unwrap();
975 assert_eq!(blocking_entries(tmp.path()).unwrap(), vec!["`README.md`"]);
976 std::fs::remove_file(tmp.path().join("README.md")).unwrap();
977
978 std::fs::write(tmp.path().join("main.rs"), b"fn main() {}").unwrap();
979 assert_eq!(blocking_entries(tmp.path()).unwrap(), vec!["`main.rs`"]);
980 }
981
982 #[test]
983 fn wire_agent_merges_and_never_overwrites() {
984 let tmp = tempfile::tempdir().unwrap();
985 let outcome = wire_agent(tmp.path(), AgentTarget::ClaudeCode, "/bin/memstead-mcp").unwrap();
987 assert!(outcome.action.contains("wrote"), "got: {}", outcome.action);
988 let parsed: serde_json::Value =
989 serde_json::from_slice(&std::fs::read(tmp.path().join(".mcp.json")).unwrap()).unwrap();
990 assert_eq!(
991 parsed["mcpServers"]["memstead"]["command"],
992 "/bin/memstead-mcp"
993 );
994
995 std::fs::write(
998 tmp.path().join(".mcp.json"),
999 serde_json::to_vec_pretty(&serde_json::json!({
1000 "mcpServers": {
1001 "other": { "command": "/bin/other" },
1002 "memstead": { "command": "/custom/memstead-mcp" },
1003 }
1004 }))
1005 .unwrap(),
1006 )
1007 .unwrap();
1008 let outcome = wire_agent(tmp.path(), AgentTarget::ClaudeCode, "/bin/memstead-mcp").unwrap();
1009 assert!(
1010 outcome.action.contains("left untouched"),
1011 "got: {}",
1012 outcome.action
1013 );
1014 let parsed: serde_json::Value =
1015 serde_json::from_slice(&std::fs::read(tmp.path().join(".mcp.json")).unwrap()).unwrap();
1016 assert_eq!(
1017 parsed["mcpServers"]["memstead"]["command"],
1018 "/custom/memstead-mcp"
1019 );
1020 assert_eq!(parsed["mcpServers"]["other"]["command"], "/bin/other");
1021 }
1022
1023 #[test]
1024 fn shell_quote_leaves_ordinary_paths_alone_and_quotes_the_rest() {
1025 assert_eq!(
1026 shell_quote("/usr/local/bin/memstead-mcp"),
1027 "/usr/local/bin/memstead-mcp"
1028 );
1029 assert_eq!(shell_quote("my-graph"), "my-graph");
1030 assert_eq!(shell_quote("My Graph"), "'My Graph'");
1032 assert_eq!(
1033 shell_quote("/Users/a b/bin/memstead-mcp"),
1034 "'/Users/a b/bin/memstead-mcp'"
1035 );
1036 assert_eq!(shell_quote("a;rm -rf /"), "'a;rm -rf /'");
1038 assert_eq!(shell_quote("$(whoami)"), "'$(whoami)'");
1039 assert_eq!(shell_quote("it's"), r"'it'\''s'");
1041 assert_eq!(shell_quote(""), "''");
1042 }
1043
1044 #[test]
1045 fn wire_agent_codex_prints_command_writes_nothing() {
1046 let tmp = tempfile::tempdir().unwrap();
1047 let outcome = wire_agent(tmp.path(), AgentTarget::Codex, "/bin/memstead-mcp").unwrap();
1048 assert!(
1049 outcome
1050 .action
1051 .contains("codex mcp add memstead -- /bin/memstead-mcp"),
1052 "got: {}",
1053 outcome.action,
1054 );
1055 assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0);
1056 }
1057}