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