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> {
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 return Ok(WiringOutcome {
649 target: agent,
650 action: format!("run: `codex mcp add memstead -- {mcp_command}`"),
651 });
652 };
653 let path = target.join(rel);
654 let mut root = read_agent_config(&path)?;
655
656 let servers = root
657 .as_object_mut()
658 .expect("read_agent_config only returns JSON objects")
659 .entry("mcpServers")
660 .or_insert_with(|| json!({}));
661 let servers = servers.as_object_mut().ok_or_else(|| {
662 CliError::new(
663 ExitKind::Validation,
664 "INVALID_INPUT",
665 format!(
666 "{}'s `mcpServers` is not a JSON object — fix or remove the file, then \
667 re-run: memstead quickstart",
668 path.display(),
669 ),
670 )
671 })?;
672
673 if servers.contains_key("memstead") {
674 return Ok(WiringOutcome {
675 target: agent,
676 action: format!("`{rel}` already has a `memstead` server entry — left untouched"),
677 });
678 }
679 servers.insert("memstead".to_string(), json!({ "command": mcp_command }));
680
681 if let Some(parent) = path.parent() {
682 std::fs::create_dir_all(parent).map_err(|e| {
683 CliError::new(
684 ExitKind::Generic,
685 crate::INTERNAL_CODE,
686 format!("create {}: {e}", parent.display()),
687 )
688 })?;
689 }
690 let rendered = format!(
691 "{}\n",
692 serde_json::to_string_pretty(&root).unwrap_or_default()
693 );
694 std::fs::write(&path, rendered).map_err(|e| {
695 CliError::new(
696 ExitKind::Generic,
697 crate::INTERNAL_CODE,
698 format!("write {}: {e}", path.display()),
699 )
700 })?;
701 Ok(WiringOutcome {
702 target: agent,
703 action: format!("wrote `{rel}` (server `memstead`)"),
704 })
705}
706
707#[allow(clippy::too_many_arguments)]
709fn report(
710 ctx: &CliContext,
711 target: &Path,
712 name: &str,
713 schema_pin: &memstead_schema::SchemaRef,
714 seed_id: &str,
715 wirings: &[WiringOutcome],
716 agents_defaulted: bool,
717 mcp_bin: &McpBinary,
718) -> anyhow::Result<()> {
719 let restart_labels: Vec<&str> = wirings.iter().map(|w| w.target.label()).collect();
720 let next_action = format!(
721 "Restart {} so the `memstead` MCP server registers — then try: memstead overview",
722 restart_labels.join(" / "),
723 );
724
725 if ctx.json {
726 return print_json(&json!({
727 "workspace_root": target.display().to_string(),
728 "config_path": config_path(target).display().to_string(),
729 "name": name,
730 "schema": schema_pin.as_display(),
731 "seed_entity": seed_id,
732 "mcp_command": mcp_bin.command,
733 "agents": wirings
734 .iter()
735 .map(|w| json!({
736 "target": w.target.to_possible_value().map(|v| v.get_name().to_string()),
737 "action": w.action,
738 }))
739 .collect::<Vec<_>>(),
740 "agents_defaulted": agents_defaulted,
741 "next_action": next_action,
742 "warnings": mcp_bin.warning.as_ref().map(|w| vec![w.clone()]).unwrap_or_default(),
743 }));
744 }
745
746 let mut lines = vec![
747 format!("# Quickstart complete — mem `{name}`"),
748 String::new(),
749 format!("- Workspace: `{}`", target.display()),
750 format!("- Schema pin: `{}`", schema_pin.as_display()),
751 format!("- Seed entity: `{seed_id}` (remove any time: `memstead delete {seed_id}`)"),
752 ];
753 for w in wirings {
754 lines.push(format!("- {}: {}", w.target.label(), w.action));
755 }
756 if agents_defaulted {
757 lines.push(
758 "- No `--agent` given and no terminal to ask — defaulted to Claude Code \
759 (re-run with `--agent` for others)"
760 .to_string(),
761 );
762 }
763 if let Some(warning) = &mcp_bin.warning {
764 lines.push(String::new());
765 lines.push(format!("> warning: {warning}"));
766 }
767 lines.push(String::new());
768 lines.push(format!("Next: {next_action}"));
769 print_markdown(&lines.join("\n"));
770 Ok(())
771}
772
773#[cfg(test)]
774mod tests {
775 use super::*;
776
777 #[test]
778 fn derive_mem_name_handles_common_directory_names() {
779 assert_eq!(derive_mem_name("my-graph").as_deref(), Some("my-graph"));
780 assert_eq!(derive_mem_name("My Project").as_deref(), Some("my-project"));
781 assert_eq!(
782 derive_mem_name("Notes_2026 (v2)").as_deref(),
783 Some("notes-2026-v2")
784 );
785 assert_eq!(derive_mem_name("日本語"), None);
787 assert_eq!(derive_mem_name(""), None);
788 assert_eq!(derive_mem_name("a"), None);
790 }
791
792 #[test]
793 fn blocking_entries_tolerates_dotfiles_and_readme_grade() {
794 let tmp = tempfile::tempdir().unwrap();
795 for f in [".gitignore", ".mcp.json", "README", "LICENSE", "Readme.txt"] {
796 std::fs::write(tmp.path().join(f), b"x").unwrap();
797 }
798 std::fs::create_dir(tmp.path().join(".git")).unwrap();
799 assert!(blocking_entries(tmp.path()).unwrap().is_empty());
800
801 std::fs::write(tmp.path().join("README.md"), b"# hi").unwrap();
804 assert_eq!(blocking_entries(tmp.path()).unwrap(), vec!["`README.md`"]);
805 std::fs::remove_file(tmp.path().join("README.md")).unwrap();
806
807 std::fs::write(tmp.path().join("main.rs"), b"fn main() {}").unwrap();
808 assert_eq!(blocking_entries(tmp.path()).unwrap(), vec!["`main.rs`"]);
809 }
810
811 #[test]
812 fn wire_agent_merges_and_never_overwrites() {
813 let tmp = tempfile::tempdir().unwrap();
814 let outcome = wire_agent(tmp.path(), AgentTarget::ClaudeCode, "/bin/memstead-mcp").unwrap();
816 assert!(outcome.action.contains("wrote"), "got: {}", outcome.action);
817 let parsed: serde_json::Value =
818 serde_json::from_slice(&std::fs::read(tmp.path().join(".mcp.json")).unwrap()).unwrap();
819 assert_eq!(
820 parsed["mcpServers"]["memstead"]["command"],
821 "/bin/memstead-mcp"
822 );
823
824 std::fs::write(
827 tmp.path().join(".mcp.json"),
828 serde_json::to_vec_pretty(&serde_json::json!({
829 "mcpServers": {
830 "other": { "command": "/bin/other" },
831 "memstead": { "command": "/custom/memstead-mcp" },
832 }
833 }))
834 .unwrap(),
835 )
836 .unwrap();
837 let outcome = wire_agent(tmp.path(), AgentTarget::ClaudeCode, "/bin/memstead-mcp").unwrap();
838 assert!(
839 outcome.action.contains("left untouched"),
840 "got: {}",
841 outcome.action
842 );
843 let parsed: serde_json::Value =
844 serde_json::from_slice(&std::fs::read(tmp.path().join(".mcp.json")).unwrap()).unwrap();
845 assert_eq!(
846 parsed["mcpServers"]["memstead"]["command"],
847 "/custom/memstead-mcp"
848 );
849 assert_eq!(parsed["mcpServers"]["other"]["command"], "/bin/other");
850 }
851
852 #[test]
853 fn wire_agent_codex_prints_command_writes_nothing() {
854 let tmp = tempfile::tempdir().unwrap();
855 let outcome = wire_agent(tmp.path(), AgentTarget::Codex, "/bin/memstead-mcp").unwrap();
856 assert!(
857 outcome
858 .action
859 .contains("codex mcp add memstead -- /bin/memstead-mcp"),
860 "got: {}",
861 outcome.action,
862 );
863 assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0);
864 }
865}