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;
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> {
478 let reg = memstead_schema::SchemaRegistry::builtin();
479 match reg.resolve_by_name("default") {
480 Ok(Some(schema)) => {
481 let (name, version) = schema.id();
482 Ok(memstead_schema::SchemaRef::new(name, version))
483 }
484 _ => Err(CliError::new(
485 ExitKind::Generic,
486 crate::INTERNAL_CODE,
487 "builtin schema catalogue has no `default` schema — this binary is broken, please report",
488 )
489 .into()),
490 }
491}
492
493fn seed_entity(target: &Path, mem: &str) -> anyhow::Result<String> {
497 let mut engine = BaseEngine::from_workspace_root(target).map_err(|e| {
498 CliError::new(
499 ExitKind::Generic,
500 crate::INTERNAL_CODE,
501 format!("boot engine at {}: {e:#}", target.display()),
502 )
503 })?;
504 let mut sections = indexmap::IndexMap::new();
505 sections.insert(
506 "definition".to_string(),
507 "This mem is a typed knowledge graph: markdown entities validated against a schema, \
508 connected by typed relationships."
509 .to_string(),
510 );
511 sections.insert(
512 "explanation".to_string(),
513 "`memstead quickstart` seeded this entity so the graph starts non-empty. Read it back \
514 with `memstead entity <id>`, list types with `memstead type`, create your own with \
515 `memstead create`, and delete this one any time with `memstead delete <id>`."
516 .to_string(),
517 );
518 let outcome = engine
519 .create_entity(
520 CreateEntityArgs {
521 anchors: Vec::new(),
522 mem: mem.to_string(),
523 title: "Welcome to Memstead".to_string(),
524 entity_type: "concept".to_string(),
525 sections,
526 metadata: indexmap::IndexMap::new(),
527 relations: Vec::new(),
528 dry_run: false,
529 },
530 Actor::Cli,
531 None,
532 Some("seeded by memstead quickstart"),
533 )
534 .map_err(CliError::from_engine_op)?;
535 Ok(outcome.id.as_ref().to_string())
536}
537
538struct McpBinary {
542 command: String,
543 warning: Option<String>,
544}
545
546fn resolve_mcp_binary() -> McpBinary {
550 if let Ok(exe) = std::env::current_exe()
551 && let Some(dir) = exe.parent()
552 {
553 let sibling = dir.join("memstead-mcp");
554 if sibling.is_file() {
555 return McpBinary {
556 command: sibling.display().to_string(),
557 warning: None,
558 };
559 }
560 }
561 if let Some(paths) = std::env::var_os("PATH") {
562 for dir in std::env::split_paths(&paths) {
563 let candidate = dir.join("memstead-mcp");
564 if candidate.is_file() {
565 return McpBinary {
566 command: candidate.display().to_string(),
567 warning: None,
568 };
569 }
570 }
571 }
572 McpBinary {
573 command: "memstead-mcp".to_string(),
574 warning: Some(
575 "`memstead-mcp` was not found next to this binary or on PATH — the wiring uses the \
576 bare name and will work once it is installed (curl -sSf https://memstead.io/install.sh | sh)"
577 .to_string(),
578 ),
579 }
580}
581
582fn read_agent_config(path: &Path) -> anyhow::Result<serde_json::Value> {
588 if !path.is_file() {
589 return Ok(json!({}));
590 }
591 let fix_hint = "fix or remove the file, then re-run: memstead quickstart";
592 let bytes = std::fs::read(path).map_err(|e| {
593 CliError::new(
594 ExitKind::Generic,
595 crate::INTERNAL_CODE,
596 format!("read {}: {e}", path.display()),
597 )
598 })?;
599 let root: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| {
600 CliError::new(
601 ExitKind::Validation,
602 "INVALID_INPUT",
603 format!(
604 "{} exists but is not valid JSON ({e}) — {fix_hint}",
605 path.display()
606 ),
607 )
608 })?;
609 if !root.is_object() {
610 return Err(CliError::new(
611 ExitKind::Validation,
612 "INVALID_INPUT",
613 format!(
614 "{} exists but its top level is not a JSON object — {fix_hint}",
615 path.display(),
616 ),
617 )
618 .into());
619 }
620 let servers = &root["mcpServers"];
621 if !servers.is_null() && !servers.is_object() {
622 return Err(CliError::new(
623 ExitKind::Validation,
624 "INVALID_INPUT",
625 format!(
626 "{}'s `mcpServers` is not a JSON object — {fix_hint}",
627 path.display(),
628 ),
629 )
630 .into());
631 }
632 Ok(root)
633}
634
635fn wire_agent(
640 target: &Path,
641 agent: AgentTarget,
642 mcp_command: &str,
643) -> anyhow::Result<WiringOutcome> {
644 let Some(rel) = agent.config_file() else {
645 return Ok(WiringOutcome {
646 target: agent,
647 action: format!("run: `codex mcp add memstead -- {mcp_command}`"),
648 });
649 };
650 let path = target.join(rel);
651 let mut root = read_agent_config(&path)?;
652
653 let servers = root
654 .as_object_mut()
655 .expect("read_agent_config only returns JSON objects")
656 .entry("mcpServers")
657 .or_insert_with(|| json!({}));
658 let servers = servers.as_object_mut().ok_or_else(|| {
659 CliError::new(
660 ExitKind::Validation,
661 "INVALID_INPUT",
662 format!(
663 "{}'s `mcpServers` is not a JSON object — fix or remove the file, then \
664 re-run: memstead quickstart",
665 path.display(),
666 ),
667 )
668 })?;
669
670 if servers.contains_key("memstead") {
671 return Ok(WiringOutcome {
672 target: agent,
673 action: format!("`{rel}` already has a `memstead` server entry — left untouched"),
674 });
675 }
676 servers.insert("memstead".to_string(), json!({ "command": mcp_command }));
677
678 if let Some(parent) = path.parent() {
679 std::fs::create_dir_all(parent).map_err(|e| {
680 CliError::new(
681 ExitKind::Generic,
682 crate::INTERNAL_CODE,
683 format!("create {}: {e}", parent.display()),
684 )
685 })?;
686 }
687 let rendered = format!(
688 "{}\n",
689 serde_json::to_string_pretty(&root).unwrap_or_default()
690 );
691 std::fs::write(&path, rendered).map_err(|e| {
692 CliError::new(
693 ExitKind::Generic,
694 crate::INTERNAL_CODE,
695 format!("write {}: {e}", path.display()),
696 )
697 })?;
698 Ok(WiringOutcome {
699 target: agent,
700 action: format!("wrote `{rel}` (server `memstead`)"),
701 })
702}
703
704#[allow(clippy::too_many_arguments)]
706fn report(
707 ctx: &CliContext,
708 target: &Path,
709 name: &str,
710 schema_pin: &memstead_schema::SchemaRef,
711 seed_id: &str,
712 wirings: &[WiringOutcome],
713 agents_defaulted: bool,
714 mcp_bin: &McpBinary,
715) -> anyhow::Result<()> {
716 let restart_labels: Vec<&str> = wirings.iter().map(|w| w.target.label()).collect();
717 let next_action = format!(
718 "Restart {} so the `memstead` MCP server registers — then try: memstead overview",
719 restart_labels.join(" / "),
720 );
721
722 if ctx.json {
723 return print_json(&json!({
724 "workspace_root": target.display().to_string(),
725 "config_path": config_path(target).display().to_string(),
726 "name": name,
727 "schema": schema_pin.as_display(),
728 "seed_entity": seed_id,
729 "mcp_command": mcp_bin.command,
730 "agents": wirings
731 .iter()
732 .map(|w| json!({
733 "target": w.target.to_possible_value().map(|v| v.get_name().to_string()),
734 "action": w.action,
735 }))
736 .collect::<Vec<_>>(),
737 "agents_defaulted": agents_defaulted,
738 "next_action": next_action,
739 "warnings": mcp_bin.warning.as_ref().map(|w| vec![w.clone()]).unwrap_or_default(),
740 }));
741 }
742
743 let mut lines = vec![
744 format!("# Quickstart complete — mem `{name}`"),
745 String::new(),
746 format!("- Workspace: `{}`", target.display()),
747 format!("- Schema pin: `{}`", schema_pin.as_display()),
748 format!("- Seed entity: `{seed_id}` (remove any time: `memstead delete {seed_id}`)"),
749 ];
750 for w in wirings {
751 lines.push(format!("- {}: {}", w.target.label(), w.action));
752 }
753 if agents_defaulted {
754 lines.push(
755 "- No `--agent` given and no terminal to ask — defaulted to Claude Code \
756 (re-run with `--agent` for others)"
757 .to_string(),
758 );
759 }
760 if let Some(warning) = &mcp_bin.warning {
761 lines.push(String::new());
762 lines.push(format!("> warning: {warning}"));
763 }
764 lines.push(String::new());
765 lines.push(format!("Next: {next_action}"));
766 print_markdown(&lines.join("\n"));
767 Ok(())
768}
769
770#[cfg(test)]
771mod tests {
772 use super::*;
773
774 #[test]
775 fn derive_mem_name_handles_common_directory_names() {
776 assert_eq!(derive_mem_name("my-graph").as_deref(), Some("my-graph"));
777 assert_eq!(derive_mem_name("My Project").as_deref(), Some("my-project"));
778 assert_eq!(
779 derive_mem_name("Notes_2026 (v2)").as_deref(),
780 Some("notes-2026-v2")
781 );
782 assert_eq!(derive_mem_name("日本語"), None);
784 assert_eq!(derive_mem_name(""), None);
785 assert_eq!(derive_mem_name("a"), None);
787 }
788
789 #[test]
790 fn blocking_entries_tolerates_dotfiles_and_readme_grade() {
791 let tmp = tempfile::tempdir().unwrap();
792 for f in [".gitignore", ".mcp.json", "README", "LICENSE", "Readme.txt"] {
793 std::fs::write(tmp.path().join(f), b"x").unwrap();
794 }
795 std::fs::create_dir(tmp.path().join(".git")).unwrap();
796 assert!(blocking_entries(tmp.path()).unwrap().is_empty());
797
798 std::fs::write(tmp.path().join("README.md"), b"# hi").unwrap();
801 assert_eq!(blocking_entries(tmp.path()).unwrap(), vec!["`README.md`"]);
802 std::fs::remove_file(tmp.path().join("README.md")).unwrap();
803
804 std::fs::write(tmp.path().join("main.rs"), b"fn main() {}").unwrap();
805 assert_eq!(blocking_entries(tmp.path()).unwrap(), vec!["`main.rs`"]);
806 }
807
808 #[test]
809 fn wire_agent_merges_and_never_overwrites() {
810 let tmp = tempfile::tempdir().unwrap();
811 let outcome = wire_agent(tmp.path(), AgentTarget::ClaudeCode, "/bin/memstead-mcp").unwrap();
813 assert!(outcome.action.contains("wrote"), "got: {}", outcome.action);
814 let parsed: serde_json::Value =
815 serde_json::from_slice(&std::fs::read(tmp.path().join(".mcp.json")).unwrap()).unwrap();
816 assert_eq!(
817 parsed["mcpServers"]["memstead"]["command"],
818 "/bin/memstead-mcp"
819 );
820
821 std::fs::write(
824 tmp.path().join(".mcp.json"),
825 serde_json::to_vec_pretty(&serde_json::json!({
826 "mcpServers": {
827 "other": { "command": "/bin/other" },
828 "memstead": { "command": "/custom/memstead-mcp" },
829 }
830 }))
831 .unwrap(),
832 )
833 .unwrap();
834 let outcome = wire_agent(tmp.path(), AgentTarget::ClaudeCode, "/bin/memstead-mcp").unwrap();
835 assert!(
836 outcome.action.contains("left untouched"),
837 "got: {}",
838 outcome.action
839 );
840 let parsed: serde_json::Value =
841 serde_json::from_slice(&std::fs::read(tmp.path().join(".mcp.json")).unwrap()).unwrap();
842 assert_eq!(
843 parsed["mcpServers"]["memstead"]["command"],
844 "/custom/memstead-mcp"
845 );
846 assert_eq!(parsed["mcpServers"]["other"]["command"], "/bin/other");
847 }
848
849 #[test]
850 fn wire_agent_codex_prints_command_writes_nothing() {
851 let tmp = tempfile::tempdir().unwrap();
852 let outcome = wire_agent(tmp.path(), AgentTarget::Codex, "/bin/memstead-mcp").unwrap();
853 assert!(
854 outcome
855 .action
856 .contains("codex mcp add memstead -- /bin/memstead-mcp"),
857 "got: {}",
858 outcome.action,
859 );
860 assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0);
861 }
862}