1use std::io::{IsTerminal, Write as _};
26use std::path::{Path, PathBuf};
27
28use clap::{Args as ClapArgs, ValueEnum};
29use memstead_base::binding::ScaffoldParams;
30use memstead_base::filesystem::config::{config_path, init_filesystem_mem_at, validate_mem_name};
31use memstead_base::pipeline_store::write_binding;
32use memstead_base::vcs::Actor;
33use memstead_base::{CreateEntityArgs, Engine as BaseEngine};
34use serde_json::json;
35
36use crate::CliError;
37use crate::output::{ExitKind, print_json, print_markdown};
38use crate::setup::{CliContext, memstead_program, shell_quote};
39
40use super::init::find_ancestor_workspace;
41
42#[derive(ClapArgs, Debug)]
44pub struct Args {
45 #[arg(value_name = "PATH")]
47 pub path: Option<PathBuf>,
48
49 #[arg(long)]
53 pub name: Option<String>,
54
55 #[arg(long = "agent", value_enum)]
59 pub agents: Vec<AgentTarget>,
60
61 #[arg(long = "repo", value_name = "PATH")]
76 pub repo: Option<PathBuf>,
77}
78
79#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
85pub enum AgentTarget {
86 ClaudeCode,
88 Codex,
91 Cursor,
93 Gemini,
95}
96
97impl AgentTarget {
98 fn label(self) -> &'static str {
99 match self {
100 AgentTarget::ClaudeCode => "Claude Code",
101 AgentTarget::Codex => "Codex",
102 AgentTarget::Cursor => "Cursor",
103 AgentTarget::Gemini => "Gemini CLI",
104 }
105 }
106
107 fn config_file(self) -> Option<&'static str> {
110 match self {
111 AgentTarget::ClaudeCode => Some(".mcp.json"),
112 AgentTarget::Cursor => Some(".cursor/mcp.json"),
113 AgentTarget::Gemini => Some(".gemini/settings.json"),
114 AgentTarget::Codex => None,
115 }
116 }
117
118 const ALL: [AgentTarget; 4] = [
119 AgentTarget::ClaudeCode,
120 AgentTarget::Codex,
121 AgentTarget::Cursor,
122 AgentTarget::Gemini,
123 ];
124}
125
126enum WiringAction {
131 Wrote,
133 LeftUntouched,
135 RunCommand(String),
138}
139
140impl WiringAction {
141 fn render(&self, target: AgentTarget, path: &dyn Fn(&str) -> String) -> String {
143 match self {
144 WiringAction::Wrote => match target.config_file() {
145 Some(rel) => format!("wrote `{}` (server `memstead`)", path(rel)),
146 None => "wrote its config".to_string(),
147 },
148 WiringAction::LeftUntouched => match target.config_file() {
149 Some(rel) => format!(
150 "`{}` already has a `memstead` server entry — left untouched",
151 path(rel)
152 ),
153 None => "already wired — left untouched".to_string(),
154 },
155 WiringAction::RunCommand(cmd) => format!("run: `{cmd}`"),
156 }
157 }
158}
159
160struct WiringOutcome {
162 target: AgentTarget,
163 action: WiringAction,
165 existing_command: Option<String>,
172 preexisting: bool,
175 file_existed: bool,
181}
182
183pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
184 if let Some(repo) = &args.repo
188 && !repo.is_dir()
189 {
190 return Err(CliError::new(
191 ExitKind::Validation,
192 "INVALID_INPUT",
193 format!(
194 "--repo {} is not an existing directory — point it at the repository \
195 you already have: memstead quickstart --repo .",
196 repo.display(),
197 ),
198 )
199 .with_details(json!({ "repo": repo.display().to_string() }))
200 .into());
201 }
202
203 let target = args
204 .path
205 .clone()
206 .or_else(|| args.repo.clone())
207 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
208
209 if target.exists() && !target.is_dir() {
210 return Err(CliError::new(
211 ExitKind::Validation,
212 "INVALID_INPUT",
213 format!(
214 "target {} exists but is not a directory — point at a folder: \
215 memstead quickstart my-graph",
216 target.display(),
217 ),
218 )
219 .into());
220 }
221 let target_created = !target.exists();
222 if target_created {
223 std::fs::create_dir_all(&target).map_err(|e| {
228 CliError::new(
229 ExitKind::Generic,
230 "INTERNAL_IO_ERROR",
231 format!(
232 "failed to create target directory {}: {e}",
233 target.display()
234 ),
235 )
236 .with_details(serde_json::json!({ "path": target.display().to_string() }))
237 })?;
238 }
239
240 let mem_in_subfolder = args
261 .repo
262 .as_deref()
263 .is_some_and(|repo| workspace_overlaps_repo(&target, repo));
264
265 check_no_local_memstead(&target)?;
267
268 if let Some(found_at) = find_ancestor_workspace(&target)? {
276 return Err(CliError::new(
277 ExitKind::Validation,
278 crate::WORKSPACE_ALREADY_EXISTS_ABOVE_CODE,
279 format!(
280 "{} is already inside the memstead workspace at {} — quickstart \
281 refuses to nest workspaces. Work in that workspace (memstead \
282 overview), or start a separate graph outside it: mkdir my-graph && \
283 cd my-graph && memstead quickstart",
284 target.display(),
285 found_at.display(),
286 ),
287 )
288 .with_details(json!({ "found_at": found_at.display().to_string() }))
289 .into());
290 }
291
292 let name = resolve_mem_name(&target, args.name.as_deref(), args.repo.as_deref())?;
296
297 let mem_dir = if mem_in_subfolder {
300 target.join(&name)
301 } else {
302 target.clone()
303 };
304
305 if mem_in_subfolder {
311 guard_guided_mem_folder(&target, &mem_dir, &name)?;
312 }
313 let blocking = blocking_entries(&mem_dir)?;
314 if !blocking.is_empty() {
315 let md_note = if blocking.iter().any(|f| f.ends_with(".md`")) {
316 " (a filesystem mem owns every `.md` file in its folder, so quickstart \
317 would silently adopt them into the graph)"
318 } else {
319 ""
320 };
321 return Err(CliError::new(
322 ExitKind::Validation,
323 crate::TARGET_NOT_EMPTY_CODE,
324 format!(
325 "target {} has content quickstart won't touch: {}{md_note} — move it \
326 out, or start in a fresh folder: mkdir my-graph && cd my-graph && \
327 memstead quickstart",
328 mem_dir.display(),
329 blocking.join(", "),
330 ),
331 )
332 .with_details(json!({
333 "path": mem_dir.display().to_string(),
334 "found": blocking,
335 }))
336 .into());
337 }
338
339 let (agents, agents_defaulted) = resolve_agents(&args.agents)?;
341
342 for agent in &agents {
348 if let Some(rel) = agent.config_file() {
349 read_agent_config(&target.join(rel))?;
350 }
351 }
352
353 let schema_pin = default_schema_pin()?;
356
357 let guided_plan = match &args.repo {
361 Some(repo) => Some(GuidedPlan::derive(&target, repo, &name)?),
362 None => None,
363 };
364
365 init_filesystem_mem_at(&target, &mem_dir, &name, &schema_pin).map_err(|e| {
368 CliError::new(
369 ExitKind::Generic,
370 "INTERNAL_IO_ERROR",
371 format!("initialise filesystem mem: {e}"),
372 )
373 })?;
374
375 let seed_id = seed_entity(&target, &name)?;
377
378 let guided = match guided_plan {
383 Some(plan) => Some(plan.write(&target)?),
384 None => None,
385 };
386
387 let mcp_bin = resolve_mcp_binary();
389 let mut wirings = Vec::with_capacity(agents.len());
390 for agent in &agents {
391 wirings.push(wire_agent(&target, *agent, &mcp_bin.command)?);
392 }
393
394 report(
395 ctx,
396 &target,
397 &mem_dir,
398 &name,
399 &schema_pin,
400 &seed_id,
401 &wirings,
402 agents_defaulted,
403 &mcp_bin,
404 guided.as_ref(),
405 target_created,
406 )
407}
408
409fn workspace_overlaps_repo(target: &Path, repo: &Path) -> bool {
415 let t = target
416 .canonicalize()
417 .unwrap_or_else(|_| target.to_path_buf());
418 let r = repo.canonicalize().unwrap_or_else(|_| repo.to_path_buf());
419 t.starts_with(&r) || r.starts_with(&t)
420}
421
422fn workspace_within_repo(target: &Path, repo: &Path) -> Option<String> {
429 let t = target
430 .canonicalize()
431 .unwrap_or_else(|_| target.to_path_buf());
432 let r = repo.canonicalize().unwrap_or_else(|_| repo.to_path_buf());
433 let rel = t.strip_prefix(&r).ok()?;
434 Some(rel.to_string_lossy().replace('\\', "/"))
435}
436
437fn guard_guided_mem_folder(repo: &Path, mem_dir: &Path, name: &str) -> anyhow::Result<()> {
443 if !mem_dir.exists() {
444 return Ok(());
445 }
446 let retry = ShellCmd::new(memstead_program())
447 .arg("quickstart")
448 .arg("--repo")
449 .arg(repo.display().to_string())
450 .arg("--name")
451 .arg(format!("{name}-mem"))
452 .render();
453 if !mem_dir.is_dir() {
454 return Err(CliError::new(
455 ExitKind::Validation,
456 crate::TARGET_NOT_EMPTY_CODE,
457 format!(
458 "the mem would take the folder {}, and that path already exists as a file \
459 — name the mem something else: {retry}",
460 mem_dir.display(),
461 ),
462 )
463 .with_details(json!({ "path": mem_dir.display().to_string() }))
464 .into());
465 }
466 let occupied = std::fs::read_dir(mem_dir)
467 .map(|entries| entries.count() > 0)
468 .unwrap_or(true);
469 if occupied {
470 return Err(CliError::new(
471 ExitKind::Validation,
472 crate::TARGET_NOT_EMPTY_CODE,
473 format!(
474 "the mem would take the folder {}, and that folder already exists and is \
475 not empty — quickstart won't adopt a folder the repository already uses. \
476 Name the mem something else: {retry}",
477 mem_dir.display(),
478 ),
479 )
480 .with_details(json!({ "path": mem_dir.display().to_string() }))
481 .into());
482 }
483 Ok(())
484}
485
486struct GuidedPlan {
490 pointer: String,
492 stem: String,
494 repo_display: String,
496 layout_warning: Option<String>,
498 workspace_in_repo: Option<String>,
502 is_git_repo: bool,
504 mem: String,
505}
506
507struct GuidedOutcome {
509 binding_id: String,
510 pointer: String,
511 repo_display: String,
512 record: String,
514 deny_paths: Vec<String>,
518 operations: Vec<String>,
520 warnings: Vec<String>,
522 workspace_in_repo: Option<String>,
525 is_git_repo: bool,
529}
530
531impl GuidedPlan {
532 fn derive(workspace_root: &Path, repo: &Path, mem: &str) -> anyhow::Result<Self> {
533 let workspace_abs = workspace_root
534 .canonicalize()
535 .unwrap_or_else(|_| workspace_root.to_path_buf());
536 let repo_abs = repo.canonicalize().unwrap_or_else(|_| repo.to_path_buf());
537 let pointer = if repo_abs == workspace_abs {
538 ".".to_string()
539 } else {
540 let rel = memstead_base::ingest::cursor::relative_to(&workspace_abs, &repo_abs);
541 if rel.as_os_str().is_empty() {
542 ".".to_string()
543 } else {
544 rel.to_string_lossy().replace('\\', "/")
545 }
546 };
547 let stem = repo_abs
552 .file_name()
553 .map(|n| n.to_string_lossy().to_string())
554 .and_then(|n| derive_mem_name(&n))
555 .unwrap_or_else(|| mem.to_string());
556 let layout_warning = memstead_base::ingest::cursor::out_of_root_layout_warning(
557 &pointer,
558 &workspace_abs,
559 memstead_base::MediumType::Codebase,
560 );
561 Ok(GuidedPlan {
562 pointer,
563 stem,
564 repo_display: repo_abs.display().to_string(),
565 layout_warning,
566 workspace_in_repo: workspace_within_repo(workspace_root, repo),
567 is_git_repo: repo_abs.join(".git").exists(),
568 mem: mem.to_string(),
569 })
570 }
571
572 fn write(self, workspace_root: &Path) -> anyhow::Result<GuidedOutcome> {
573 let GuidedPlan {
574 pointer,
575 stem,
576 repo_display,
577 layout_warning,
578 workspace_in_repo,
579 is_git_repo,
580 mem,
581 } = self;
582 let scaffolded = memstead_base::binding::scaffold_binding(ScaffoldParams {
583 destination_mem: &mem,
584 source_name: &stem,
585 pointer: &pointer,
586 medium_type: memstead_base::MediumType::Codebase,
587 intent: Some(format!(
588 "Model the `{stem}` codebase in the `{mem}` mem: what each part is for, \
589 how the parts fit together, and the decisions behind them."
590 )),
591 additional_deny_paths: Vec::new(),
592 });
593 write_binding(workspace_root, &mem, &stem, &scaffolded.binding).map_err(|e| {
594 CliError::new(
595 ExitKind::Generic,
596 "PROJECTION_INIT_FAILED",
597 format!("could not scaffold binding `{mem}/{stem}`: {e}"),
598 )
599 .with_details(json!({ "binding": format!("{mem}/{stem}"), "error": e.to_string() }))
600 })?;
601 let mut warnings: Vec<String> = scaffolded.warnings;
602 warnings.extend(layout_warning);
603 Ok(GuidedOutcome {
604 binding_id: format!("{mem}/{stem}"),
605 pointer,
606 repo_display,
607 record: format!(".memstead/projections/{mem}/{stem}.json"),
608 deny_paths: scaffolded.binding.deny_paths.clone(),
609 operations: scaffolded
610 .operations
611 .iter()
612 .map(|o| (*o).to_string())
613 .collect(),
614 warnings,
615 workspace_in_repo,
616 is_git_repo,
617 })
618 }
619}
620
621fn check_no_local_memstead(target: &Path) -> anyhow::Result<()> {
626 let store = target.join(memstead_base::WORKSPACE_STORE_DIR);
627 if !store.exists() {
628 return Ok(());
629 }
630 if memstead_base::is_workspace_root(target) {
631 return Err(CliError::new(
632 ExitKind::Validation,
633 "WORKSPACE_ALREADY_INITIALISED",
634 format!(
635 "{} is already a Memstead workspace — nothing to bootstrap. \
636 Inspect it with: memstead overview",
637 target.display(),
638 ),
639 )
640 .with_details(json!({ "path": target.display().to_string() }))
641 .into());
642 }
643 Err(CliError::new(
644 ExitKind::Validation,
645 "FOREIGN_MEMSTEAD_DIR",
646 format!(
647 "{} contains a `.memstead/` directory that is not a workspace \
648 (no workspace.toml) — quickstart won't adopt or overwrite it. \
649 Move it aside, or start fresh: mkdir my-graph && cd my-graph && \
650 memstead quickstart",
651 target.display(),
652 ),
653 )
654 .with_details(json!({ "path": store.display().to_string() }))
655 .into())
656}
657
658fn blocking_entries(target: &Path) -> anyhow::Result<Vec<String>> {
666 if !target.exists() {
669 return Ok(Vec::new());
670 }
671 let read_err = |e: std::io::Error| {
672 CliError::new(
673 ExitKind::Generic,
674 "INTERNAL_IO_ERROR",
675 format!("read target {}: {e}", target.display()),
676 )
677 };
678 let mut blocking = Vec::new();
679 for entry in std::fs::read_dir(target).map_err(read_err)? {
680 let entry = entry.map_err(read_err)?;
681 let name = entry.file_name().to_string_lossy().to_string();
682 if name.starts_with('.') {
683 continue;
684 }
685 let lower = name.to_lowercase();
686 let readme_grade = lower.starts_with("readme")
687 || lower.starts_with("license")
688 || lower.starts_with("licence");
689 if readme_grade && !lower.ends_with(".md") {
690 continue;
691 }
692 blocking.push(format!("`{name}`"));
693 }
694 blocking.sort();
695 Ok(blocking)
696}
697
698fn resolve_mem_name(
702 target: &Path,
703 flag: Option<&str>,
704 repo: Option<&Path>,
705) -> anyhow::Result<String> {
706 let retry = |name: &str| match repo {
713 Some(repo) => ShellCmd::new(memstead_program())
714 .arg("quickstart")
715 .arg("--repo")
716 .arg(repo.display().to_string())
717 .arg("--name")
718 .arg(name)
719 .render(),
720 None => format!("memstead quickstart --name {name}"),
721 };
722 if let Some(name) = flag {
723 validate_mem_name(name).map_err(|e| {
724 CliError::new(
725 ExitKind::Validation,
726 "INVALID_INPUT",
727 format!(
728 "invalid --name: {e}. Retry with a slug, e.g.: {}",
729 retry(&derive_mem_name(name).unwrap_or_else(|| "my-graph".to_string())),
730 ),
731 )
732 })?;
733 return Ok(name.to_string());
734 }
735 let basename = std::fs::canonicalize(target)
736 .ok()
737 .and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string()))
738 .unwrap_or_default();
739 if let Some(derived) = derive_mem_name(&basename) {
740 return Ok(derived);
741 }
742 if std::io::stdin().is_terminal() {
743 let answer = prompt_line(&format!(
744 "Could not derive a mem name from `{basename}`. Mem name (lowercase letters, digits, hyphens): ",
745 ))?;
746 let answer = answer.trim();
747 validate_mem_name(answer).map_err(|e| {
748 CliError::new(
749 ExitKind::Validation,
750 "INVALID_INPUT",
751 format!("invalid mem name: {e}. Retry with: {}", retry("my-graph")),
752 )
753 })?;
754 return Ok(answer.to_string());
755 }
756 Err(CliError::new(
757 ExitKind::Validation,
758 "INVALID_INPUT",
759 format!(
760 "could not derive a mem name from directory `{basename}` — \
761 pass one explicitly: {}",
762 retry("my-graph"),
763 ),
764 )
765 .with_details(json!({ "directory": basename }))
766 .into())
767}
768
769fn derive_mem_name(basename: &str) -> Option<String> {
773 let mut out = String::with_capacity(basename.len());
774 for c in basename.to_lowercase().chars() {
775 if c.is_ascii_lowercase() || c.is_ascii_digit() {
776 out.push(c);
777 } else if !out.is_empty() && !out.ends_with('-') {
778 out.push('-');
779 }
780 }
781 let mut slug: String = out.trim_matches('-').chars().take(64).collect();
782 slug = slug.trim_matches('-').to_string();
783 validate_mem_name(&slug).ok().map(|()| slug)
784}
785
786fn resolve_agents(flag: &[AgentTarget]) -> anyhow::Result<(Vec<AgentTarget>, bool)> {
790 if !flag.is_empty() {
791 let mut seen = Vec::with_capacity(flag.len());
792 for a in flag {
793 if !seen.contains(a) {
794 seen.push(*a);
795 }
796 }
797 return Ok((seen, false));
798 }
799 if std::io::stdin().is_terminal() {
800 return Ok((prompt_agents()?, false));
801 }
802 Ok((vec![AgentTarget::ClaudeCode], true))
803}
804
805fn prompt_agents() -> anyhow::Result<Vec<AgentTarget>> {
808 let menu: Vec<String> = AgentTarget::ALL
809 .iter()
810 .enumerate()
811 .map(|(i, a)| format!(" {}) {}", i + 1, a.label()))
812 .collect();
813 let answer = prompt_line(&format!(
814 "Which agents should connect to this mem? (comma-separated, Enter = Claude Code)\n{}\n> ",
815 menu.join("\n"),
816 ))?;
817 let answer = answer.trim();
818 if answer.is_empty() {
819 return Ok(vec![AgentTarget::ClaudeCode]);
820 }
821 let mut selected = Vec::new();
822 for token in answer.split(',') {
823 let token = token.trim();
824 let picked = match token.parse::<usize>() {
825 Ok(n) if (1..=AgentTarget::ALL.len()).contains(&n) => AgentTarget::ALL[n - 1],
826 _ => {
827 return Err(CliError::new(
828 ExitKind::Validation,
829 "INVALID_INPUT",
830 format!(
831 "unrecognised selection `{token}` — expected numbers 1-{max} \
832 (comma-separated). Skip the prompt with: memstead quickstart \
833 --agent claude-code --agent cursor",
834 max = AgentTarget::ALL.len(),
835 ),
836 )
837 .into());
838 }
839 };
840 if !selected.contains(&picked) {
841 selected.push(picked);
842 }
843 }
844 Ok(selected)
845}
846
847fn prompt_line(msg: &str) -> anyhow::Result<String> {
850 let mut stderr = std::io::stderr();
851 stderr.write_all(msg.as_bytes()).ok();
852 stderr.flush().ok();
853 let mut line = String::new();
854 std::io::stdin().read_line(&mut line).map_err(|e| {
855 CliError::new(
856 ExitKind::Generic,
857 "INTERNAL_IO_ERROR",
858 format!("read answer from stdin: {e}"),
859 )
860 })?;
861 Ok(line)
862}
863
864fn default_schema_pin() -> anyhow::Result<memstead_schema::SchemaRef> {
869 let reg = memstead_schema::SchemaRegistry::builtin();
870 match reg.get("default", &semver::Version::new(1, 3, 0)) {
871 Some(schema) => {
872 let (name, version) = schema.id();
873 Ok(memstead_schema::SchemaRef::new(name, version))
874 }
875 _ => Err(CliError::new(
876 ExitKind::Generic,
877 crate::INTERNAL_CODE,
878 "builtin schema catalogue has no `default` schema — this binary is broken, please report",
879 )
880 .into()),
881 }
882}
883
884fn seed_entity(target: &Path, mem: &str) -> anyhow::Result<String> {
888 let mut engine = BaseEngine::from_workspace_root(target).map_err(|e| {
889 CliError::new(
890 ExitKind::Generic,
891 crate::INTERNAL_CODE,
892 format!("boot engine at {}: {e:#}", target.display()),
893 )
894 })?;
895 let mut sections = indexmap::IndexMap::new();
896 sections.insert(
897 "definition".to_string(),
898 "This mem is a typed knowledge graph: markdown entities validated against a schema, \
899 connected by typed relationships."
900 .to_string(),
901 );
902 sections.insert(
903 "explanation".to_string(),
904 "`memstead quickstart` seeded this entity so the graph starts non-empty. Read it back \
905 with `memstead entity <id>`, list types with `memstead type`, create your own with \
906 `memstead create`, and delete this one any time with `memstead delete <id>`."
907 .to_string(),
908 );
909 let outcome = engine
910 .create_entity(
911 CreateEntityArgs {
912 anchors: Vec::new(),
913 mem: mem.to_string(),
914 title: "Welcome to Memstead".to_string(),
915 entity_type: "concept".to_string(),
916 sections,
917 metadata: indexmap::IndexMap::new(),
918 relations: Vec::new(),
919 dry_run: false,
920 },
921 Actor::Cli,
922 None,
923 Some("seeded by memstead quickstart"),
924 )
925 .map_err(CliError::from_engine_op)?;
926 Ok(outcome.id.as_ref().to_string())
927}
928
929struct McpBinary {
933 command: String,
934 warning: Option<String>,
935}
936
937fn resolve_mcp_binary() -> McpBinary {
941 if let Ok(exe) = std::env::current_exe()
942 && let Some(dir) = exe.parent()
943 {
944 let sibling = dir.join("memstead-mcp");
945 if sibling.is_file() {
946 return McpBinary {
947 command: sibling.display().to_string(),
948 warning: None,
949 };
950 }
951 }
952 if let Some(paths) = std::env::var_os("PATH") {
953 for dir in std::env::split_paths(&paths) {
954 let candidate = dir.join("memstead-mcp");
955 if candidate.is_file() {
956 return McpBinary {
957 command: candidate.display().to_string(),
958 warning: None,
959 };
960 }
961 }
962 }
963 McpBinary {
964 command: "memstead-mcp".to_string(),
965 warning: Some(
966 "`memstead-mcp` was not found next to this binary or on PATH — the wiring uses the \
967 bare name and will work once it is installed (curl -sSf https://memstead.io/install.sh | sh)"
968 .to_string(),
969 ),
970 }
971}
972
973fn read_agent_config(path: &Path) -> anyhow::Result<serde_json::Value> {
979 if !path.is_file() {
980 return Ok(json!({}));
981 }
982 let fix_hint = "fix or remove the file, then re-run: memstead quickstart";
983 let bytes = std::fs::read(path).map_err(|e| {
984 CliError::new(
985 ExitKind::Generic,
986 "INTERNAL_IO_ERROR",
987 format!("read {}: {e}", path.display()),
988 )
989 })?;
990 let root: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| {
991 CliError::new(
992 ExitKind::Validation,
993 "INVALID_INPUT",
994 format!(
995 "{} exists but is not valid JSON ({e}) — {fix_hint}",
996 path.display()
997 ),
998 )
999 })?;
1000 if !root.is_object() {
1001 return Err(CliError::new(
1002 ExitKind::Validation,
1003 "INVALID_INPUT",
1004 format!(
1005 "{} exists but its top level is not a JSON object — {fix_hint}",
1006 path.display(),
1007 ),
1008 )
1009 .into());
1010 }
1011 let servers = &root["mcpServers"];
1012 if !servers.is_null() && !servers.is_object() {
1013 return Err(CliError::new(
1014 ExitKind::Validation,
1015 "INVALID_INPUT",
1016 format!(
1017 "{}'s `mcpServers` is not a JSON object — {fix_hint}",
1018 path.display(),
1019 ),
1020 )
1021 .into());
1022 }
1023 Ok(root)
1024}
1025
1026fn wire_agent(
1031 target: &Path,
1032 agent: AgentTarget,
1033 mcp_command: &str,
1034) -> anyhow::Result<WiringOutcome> {
1035 let Some(rel) = agent.config_file() else {
1036 let add = ShellCmd::new("codex")
1040 .arg("mcp")
1041 .arg("add")
1042 .arg("memstead")
1043 .end_of_options()
1044 .arg(mcp_command)
1045 .render();
1046 return Ok(WiringOutcome {
1047 target: agent,
1048 action: WiringAction::RunCommand(add),
1049 existing_command: None,
1050 preexisting: false,
1051 file_existed: false,
1052 });
1053 };
1054 let path = target.join(rel);
1055 let file_existed = path.exists();
1056 let mut root = read_agent_config(&path)?;
1057
1058 let servers = root
1059 .as_object_mut()
1060 .expect("read_agent_config only returns JSON objects")
1061 .entry("mcpServers")
1062 .or_insert_with(|| json!({}));
1063 let servers = servers.as_object_mut().ok_or_else(|| {
1064 CliError::new(
1065 ExitKind::Validation,
1066 "INVALID_INPUT",
1067 format!(
1068 "{}'s `mcpServers` is not a JSON object — fix or remove the file, then \
1069 re-run: memstead quickstart",
1070 path.display(),
1071 ),
1072 )
1073 })?;
1074
1075 if let Some(existing) = servers.get("memstead") {
1076 let existing_command = existing
1079 .get("command")
1080 .and_then(|c| c.as_str())
1081 .map(str::to_string);
1082 return Ok(WiringOutcome {
1083 target: agent,
1084 action: WiringAction::LeftUntouched,
1085 existing_command,
1086 preexisting: true,
1087 file_existed,
1088 });
1089 }
1090 servers.insert("memstead".to_string(), json!({ "command": mcp_command }));
1091
1092 if let Some(parent) = path.parent() {
1093 std::fs::create_dir_all(parent).map_err(|e| {
1094 CliError::new(
1095 ExitKind::Generic,
1096 "INTERNAL_IO_ERROR",
1097 format!("create {}: {e}", parent.display()),
1098 )
1099 })?;
1100 }
1101 let rendered = format!(
1102 "{}\n",
1103 serde_json::to_string_pretty(&root).unwrap_or_default()
1104 );
1105 std::fs::write(&path, rendered).map_err(|e| {
1106 CliError::new(
1107 ExitKind::Generic,
1108 "INTERNAL_IO_ERROR",
1109 format!("write {}: {e}", path.display()),
1110 )
1111 })?;
1112 Ok(WiringOutcome {
1113 target: agent,
1114 action: WiringAction::Wrote,
1115 existing_command: None,
1116 preexisting: false,
1117 file_existed,
1118 })
1119}
1120
1121enum Word {
1134 Value(String),
1135 Literal(&'static str),
1136}
1137
1138struct ShellCmd {
1139 cd: Option<String>,
1141 program: String,
1142 args: Vec<Word>,
1143}
1144
1145impl ShellCmd {
1146 fn new(program: impl Into<String>) -> Self {
1147 ShellCmd {
1148 cd: None,
1149 program: program.into(),
1150 args: Vec::new(),
1151 }
1152 }
1153
1154 fn arg(mut self, arg: impl Into<String>) -> Self {
1155 self.args.push(Word::Value(arg.into()));
1156 self
1157 }
1158
1159 fn end_of_options(mut self) -> Self {
1164 self.args.push(Word::Literal("--"));
1165 self
1166 }
1167
1168 fn in_dir(mut self, dir: &Path, already_there: bool) -> Self {
1170 if !already_there {
1171 self.cd = Some(dir.display().to_string());
1172 }
1173 self
1174 }
1175
1176 fn render(&self) -> String {
1179 let mut out = String::new();
1180 if let Some(dir) = &self.cd {
1181 out.push_str(&format!("cd -- {} && ", shell_quote(dir)));
1182 }
1183 out.push_str(&shell_quote(&self.program));
1184 for arg in &self.args {
1185 out.push(' ');
1186 match arg {
1187 Word::Value(v) => out.push_str(&shell_quote(v)),
1188 Word::Literal(l) => out.push_str(l),
1189 }
1190 }
1191 out
1192 }
1193}
1194
1195#[allow(clippy::too_many_arguments)]
1197fn report(
1198 ctx: &CliContext,
1199 target: &Path,
1200 mem_dir: &Path,
1201 name: &str,
1202 schema_pin: &memstead_schema::SchemaRef,
1203 seed_id: &str,
1204 wirings: &[WiringOutcome],
1205 agents_defaulted: bool,
1206 mcp_bin: &McpBinary,
1207 guided: Option<&GuidedOutcome>,
1208 target_created: bool,
1212) -> anyhow::Result<()> {
1213 let restart_labels: Vec<&str> = wirings.iter().map(|w| w.target.label()).collect();
1214
1215 let absolute = target
1225 .canonicalize()
1226 .unwrap_or_else(|_| target.to_path_buf());
1227 let in_cwd = std::env::current_dir()
1228 .ok()
1229 .is_some_and(|cwd| cwd == absolute);
1230 let memstead = memstead_program();
1231 let overview_cmd = ShellCmd::new(&memstead)
1232 .arg("overview")
1233 .in_dir(target, in_cwd)
1234 .render();
1235 let delete_cmd = ShellCmd::new(&memstead)
1236 .arg("delete")
1237 .arg(seed_id)
1238 .in_dir(target, in_cwd)
1239 .render();
1240 let version_cmd = ShellCmd::new(&mcp_bin.command).arg("--version").render();
1241 let brief_cmd = guided.map(|g| {
1243 ShellCmd::new(&memstead)
1244 .arg("projection")
1245 .arg("brief")
1246 .arg(&g.binding_id)
1247 .in_dir(target, in_cwd)
1248 .render()
1249 });
1250 let mem_absolute = mem_dir.canonicalize().unwrap_or_else(|_| {
1253 if mem_dir == target {
1254 absolute.clone()
1255 } else {
1256 mem_dir.to_path_buf()
1257 }
1258 });
1259 let cwd_canon = std::env::current_dir()
1272 .ok()
1273 .map(|c| c.canonicalize().unwrap_or(c));
1274 let from_here = |workspace_relative: &str| -> String {
1275 let joined = absolute.join(workspace_relative);
1276 let resolved = joined.canonicalize().unwrap_or(joined);
1277 let absolute_form = resolved.display().to_string();
1278 let Some(cwd) = &cwd_canon else {
1279 return absolute_form;
1280 };
1281 let rel = memstead_base::ingest::cursor::relative_to(cwd, &resolved)
1282 .to_string_lossy()
1283 .replace('\\', "/");
1284 if rel.is_empty() {
1285 return ".".to_string();
1286 }
1287 if rel.len() <= absolute_form.len() {
1291 rel
1292 } else {
1293 absolute_form
1294 }
1295 };
1296 let mem_folder_here: Option<String> = match mem_dir
1302 .strip_prefix(target)
1303 .ok()
1304 .map(|r| r.to_string_lossy().to_string())
1305 .filter(|r| !r.is_empty())
1306 {
1307 Some(rel) => Some(from_here(&rel)),
1308 None if guided.is_some() && !in_cwd => Some(from_here(".")),
1314 None => None,
1315 };
1316 let mem_folder_rel: Option<String> = mem_dir
1318 .strip_prefix(target)
1319 .ok()
1320 .map(|r| r.to_string_lossy().to_string())
1321 .filter(|r| !r.is_empty());
1322
1323 let codex_pending = wirings
1327 .iter()
1328 .any(|w| w.target == AgentTarget::Codex && matches!(w.action, WiringAction::RunCommand(_)));
1329 let restart_clause = format!(
1330 "Restart {} so the `memstead` MCP server registers its tools",
1331 restart_labels.join(" / "),
1332 );
1333 let next_action = if codex_pending {
1334 format!(
1335 "Run the `codex mcp add` command above first — it is Codex's wiring, and a restart \
1336 registers nothing without it. Then: {restart_clause} — then try: {overview_cmd}"
1337 )
1338 } else {
1339 format!("{restart_clause} — then try: {overview_cmd}")
1340 };
1341 let mut verify_now: Vec<(&str, String)> = Vec::new();
1348 let fresh_wiring = wirings.iter().any(|w| !w.preexisting);
1361 if fresh_wiring && mcp_bin.warning.is_none() {
1362 verify_now.push(("the wired binary answers", version_cmd));
1363 }
1364 let mut seen_existing: Vec<String> = Vec::new();
1365 for w in wirings.iter().filter(|w| w.preexisting) {
1366 match &w.existing_command {
1367 Some(cmd) if !seen_existing.contains(cmd) => {
1368 seen_existing.push(cmd.clone());
1369 verify_now.push((
1370 "the pre-existing `memstead` entry's binary answers",
1371 ShellCmd::new(cmd).arg("--version").render(),
1372 ));
1373 }
1374 _ => {}
1375 }
1376 }
1377 verify_now.push(("the graph is already readable", overview_cmd.clone()));
1378 if let Some(cmd) = &brief_cmd {
1379 verify_now.push(("the binding renders its ingest brief", cmd.clone()));
1380 }
1381
1382 let build_brief = |path: &dyn Fn(&str) -> String| -> Vec<String> {
1392 match (guided, &brief_cmd) {
1393 (Some(g), Some(brief)) => {
1394 let mut b = vec![
1395 "## What this mem holds".to_string(),
1396 String::new(),
1397 format!(
1398 "- Now: one seed entity (`{seed_id}`). Nothing else — scaffolding a \
1399 binding reads no source file and creates no entity from one."
1400 ),
1401 format!(
1402 "- Not yet: anything from `{}`. Its {} are the binding's subject, \
1403 not its content.",
1404 g.repo_display,
1405 if g.is_git_repo {
1406 "code, docs and history"
1407 } else {
1408 "files"
1409 },
1410 ),
1411 format!(
1412 "- Growth: the ingest loop against binding `{}` — one batch at a \
1413 time, each entity written through the same validated path as the \
1414 seed. Start with: `{brief}`, or follow the walkthrough at \
1415 https://memstead.com/dev/guides/grow-a-mem-from-a-source/",
1416 g.binding_id,
1417 ),
1418 format!(
1419 "- Scope: everything under `{}`, minus what the record denies ({}) \
1420 and minus {}, which the engine excludes unconditionally. The deny \
1421 list is yours to edit: `{}`",
1422 path(&g.pointer),
1423 g.deny_paths
1424 .iter()
1425 .map(|d| format!("`{d}`"))
1426 .collect::<Vec<_>>()
1427 .join(", "),
1428 match &mem_folder_rel {
1435 Some(rel) => {
1436 format!("engine state and the mem's own folder `{}/`", path(rel))
1437 }
1438 None => "engine state (`.memstead/`)".to_string(),
1439 },
1440 path(&g.record),
1441 ),
1442 format!(
1443 "- Operations the binding declares: {}",
1444 g.operations.join(", ")
1445 ),
1446 ];
1447 if let Some(ws_rel) = &g.workspace_in_repo {
1455 let in_repo = |p: &str| {
1456 if ws_rel.is_empty() {
1457 format!("`{p}`")
1458 } else {
1459 format!("`{ws_rel}/{p}`")
1460 }
1461 };
1462 let mut written = Vec::new();
1463 if target_created && !ws_rel.is_empty() {
1464 written.push(format!(
1467 "`{ws_rel}/` (the workspace: its state, the binding record, \
1468 the mem, and the agent wiring — plus the engine's cache, \
1469 which appears inside it once the binding is first measured)"
1470 ));
1471 } else {
1472 written.push(format!(
1473 "{} (workspace state and the binding record; a sibling \
1474 `.memstead.cache/` appears once the binding is first measured)",
1475 in_repo(".memstead/")
1476 ));
1477 if let Some(rel) = &mem_folder_rel {
1478 written.push(format!("{} (the mem)", in_repo(&format!("{rel}/"))));
1479 }
1480 for w in wirings.iter().filter(|w| !w.preexisting) {
1484 if let Some(f) = w.target.config_file() {
1485 let verb = if w.file_existed {
1486 "agent wiring added to it"
1487 } else {
1488 "agent wiring"
1489 };
1490 written.push(format!("{} ({verb})", in_repo(f)));
1491 }
1492 }
1493 }
1494 b.push(format!(
1495 "- Written into your {}: {}. Nothing else in the tree was touched.",
1496 if g.is_git_repo {
1497 "repository"
1498 } else {
1499 "source directory"
1500 },
1501 written.join(", "),
1502 ));
1503 }
1504 b
1505 }
1506 _ => Vec::new(),
1507 }
1508 };
1509 let brief_lines = build_brief(&from_here);
1512 let brief_lines_machine = build_brief(&|rel: &str| rel.to_string());
1513
1514 if ctx.json {
1515 let mut payload = json!({
1516 "workspace_root": absolute.display().to_string(),
1519 "config_path": config_path(&mem_absolute).display().to_string(),
1524 "seed_entity_delete_command": delete_cmd,
1525 "name": name,
1526 "schema": schema_pin.as_display(),
1527 "seed_entity": seed_id,
1528 "mcp_command": mcp_bin.command,
1529 "agents": wirings
1530 .iter()
1531 .map(|w| json!({
1532 "target": w.target.to_possible_value().map(|v| v.get_name().to_string()),
1533 "action": w.action.render(w.target, &|rel: &str| rel.to_string()),
1534 }))
1535 .collect::<Vec<_>>(),
1536 "agents_defaulted": agents_defaulted,
1537 "workspace_shape": crate::setup::WorkspaceShape::Filesystem.label(),
1538 "workspace_shape_disclosure":
1543 crate::setup::shape_disclosure_in(
1544 crate::setup::WorkspaceShape::Filesystem,
1545 mem_folder_rel.as_deref(),
1546 ).to_json(),
1547 "next_action": next_action,
1548 "verify_now": verify_now
1549 .iter()
1550 .map(|(what, command)| json!({ "what": what, "command": command }))
1551 .collect::<Vec<_>>(),
1552 "warnings": mcp_bin.warning.as_ref().map(|w| vec![w.clone()]).unwrap_or_default(),
1553 });
1554 if let Some(g) = guided {
1557 payload["mem_folder"] =
1558 json!(mem_folder_rel.clone().unwrap_or_else(|| ".".to_string()));
1559 payload["repo"] = json!(g.repo_display);
1560 payload["binding"] = json!({
1561 "id": g.binding_id,
1562 "pointer": g.pointer,
1563 "record": g.record,
1564 "deny_paths": g.deny_paths,
1565 "operations": g.operations,
1566 });
1567 payload["brief"] = json!(
1568 brief_lines_machine
1569 .iter()
1570 .filter(|l| l.starts_with("- "))
1571 .map(|l| l.trim_start_matches("- ").to_string())
1572 .collect::<Vec<_>>()
1573 );
1574 if !g.warnings.is_empty() {
1575 let mut w: Vec<String> = payload["warnings"]
1576 .as_array()
1577 .map(|a| {
1578 a.iter()
1579 .filter_map(|v| v.as_str().map(str::to_string))
1580 .collect()
1581 })
1582 .unwrap_or_default();
1583 w.extend(g.warnings.iter().cloned());
1584 payload["warnings"] = json!(w);
1585 }
1586 }
1587 return print_json(&payload);
1588 }
1589
1590 let mut lines = vec![
1591 format!("# Quickstart complete — mem `{name}`"),
1592 String::new(),
1593 format!(
1597 "- Workspace: `{}`",
1598 if guided.is_some() {
1599 absolute.display().to_string()
1600 } else {
1601 target.display().to_string()
1602 }
1603 ),
1604 ];
1605 if let Some(rel) = &mem_folder_rel {
1606 lines.push(format!(
1607 "- Mem folder: `{}/` (the graph owns this folder and nothing else)",
1608 from_here(rel),
1609 ));
1610 }
1611 lines.push(format!("- Schema pin: `{}`", schema_pin.as_display()));
1612 lines.push(format!(
1613 "- Seed entity: `{seed_id}` (remove any time: `{delete_cmd}`)"
1614 ));
1615 if let Some(g) = guided {
1616 lines.push(format!(
1617 "- Binding: `{}` over `{}` (record: `{}`)",
1618 g.binding_id,
1619 from_here(&g.pointer),
1620 from_here(&g.record),
1621 ));
1622 }
1623 for w in wirings {
1624 lines.push(format!(
1625 "- {}: {}",
1626 w.target.label(),
1627 w.action.render(w.target, &from_here),
1628 ));
1629 }
1630 if agents_defaulted {
1631 lines.push(
1632 "- No `--agent` given and no terminal to ask — defaulted to Claude Code \
1633 (re-run with `--agent` for others)"
1634 .to_string(),
1635 );
1636 }
1637 let mut warnings: Vec<String> = mcp_bin.warning.iter().cloned().collect();
1638 warnings.extend(guided.iter().flat_map(|g| g.warnings.iter().cloned()));
1639 if !warnings.is_empty() {
1640 lines.push(String::new());
1641 for warning in &warnings {
1642 lines.push(format!("> warning: {warning}"));
1643 }
1644 }
1645 if !brief_lines.is_empty() {
1646 lines.push(String::new());
1647 lines.extend(brief_lines.iter().cloned());
1648 }
1649 lines.push(String::new());
1654 lines.extend(crate::setup::shape_disclosure_lines_in(
1655 crate::setup::WorkspaceShape::Filesystem,
1656 mem_folder_here.as_deref(),
1657 ));
1658 lines.push(String::new());
1659 lines.push(format!("Next: {next_action}"));
1660 lines.push(String::new());
1661 lines.push("Verify from this session, no restart needed:".to_string());
1662 lines.extend(
1663 verify_now
1664 .iter()
1665 .map(|(what, command)| format!("- {what}: `{command}`")),
1666 );
1667 print_markdown(&lines.join("\n"));
1668 Ok(())
1669}
1670
1671#[cfg(test)]
1672mod tests {
1673 use super::*;
1674
1675 #[test]
1676 fn derive_mem_name_handles_common_directory_names() {
1677 assert_eq!(derive_mem_name("my-graph").as_deref(), Some("my-graph"));
1678 assert_eq!(derive_mem_name("My Project").as_deref(), Some("my-project"));
1679 assert_eq!(
1680 derive_mem_name("Notes_2026 (v2)").as_deref(),
1681 Some("notes-2026-v2")
1682 );
1683 assert_eq!(derive_mem_name("日本語"), None);
1685 assert_eq!(derive_mem_name(""), None);
1686 assert_eq!(derive_mem_name("a"), None);
1688 }
1689
1690 #[test]
1691 fn blocking_entries_tolerates_dotfiles_and_readme_grade() {
1692 let tmp = tempfile::tempdir().unwrap();
1693 for f in [".gitignore", ".mcp.json", "README", "LICENSE", "Readme.txt"] {
1694 std::fs::write(tmp.path().join(f), b"x").unwrap();
1695 }
1696 std::fs::create_dir(tmp.path().join(".git")).unwrap();
1697 assert!(blocking_entries(tmp.path()).unwrap().is_empty());
1698
1699 std::fs::write(tmp.path().join("README.md"), b"# hi").unwrap();
1702 assert_eq!(blocking_entries(tmp.path()).unwrap(), vec!["`README.md`"]);
1703 std::fs::remove_file(tmp.path().join("README.md")).unwrap();
1704
1705 std::fs::write(tmp.path().join("main.rs"), b"fn main() {}").unwrap();
1706 assert_eq!(blocking_entries(tmp.path()).unwrap(), vec!["`main.rs`"]);
1707 }
1708
1709 #[test]
1710 fn wire_agent_merges_and_never_overwrites() {
1711 let tmp = tempfile::tempdir().unwrap();
1712 let outcome = wire_agent(tmp.path(), AgentTarget::ClaudeCode, "/bin/memstead-mcp").unwrap();
1714 let rendered = outcome
1715 .action
1716 .render(outcome.target, &|rel: &str| rel.to_string());
1717 assert!(rendered.contains("wrote"), "got: {rendered}");
1718 let parsed: serde_json::Value =
1719 serde_json::from_slice(&std::fs::read(tmp.path().join(".mcp.json")).unwrap()).unwrap();
1720 assert_eq!(
1721 parsed["mcpServers"]["memstead"]["command"],
1722 "/bin/memstead-mcp"
1723 );
1724
1725 std::fs::write(
1728 tmp.path().join(".mcp.json"),
1729 serde_json::to_vec_pretty(&serde_json::json!({
1730 "mcpServers": {
1731 "other": { "command": "/bin/other" },
1732 "memstead": { "command": "/custom/memstead-mcp" },
1733 }
1734 }))
1735 .unwrap(),
1736 )
1737 .unwrap();
1738 let outcome = wire_agent(tmp.path(), AgentTarget::ClaudeCode, "/bin/memstead-mcp").unwrap();
1739 let rendered = outcome
1740 .action
1741 .render(outcome.target, &|rel: &str| rel.to_string());
1742 assert!(rendered.contains("left untouched"), "got: {rendered}");
1743 let parsed: serde_json::Value =
1744 serde_json::from_slice(&std::fs::read(tmp.path().join(".mcp.json")).unwrap()).unwrap();
1745 assert_eq!(
1746 parsed["mcpServers"]["memstead"]["command"],
1747 "/custom/memstead-mcp"
1748 );
1749 assert_eq!(parsed["mcpServers"]["other"]["command"], "/bin/other");
1750 }
1751
1752 #[test]
1753 fn shell_quote_leaves_ordinary_paths_alone_and_quotes_the_rest() {
1754 assert_eq!(
1755 shell_quote("/usr/local/bin/memstead-mcp"),
1756 "/usr/local/bin/memstead-mcp"
1757 );
1758 assert_eq!(shell_quote("my-graph"), "my-graph");
1759 assert_eq!(shell_quote("My Graph"), "'My Graph'");
1761 assert_eq!(
1762 shell_quote("/Users/a b/bin/memstead-mcp"),
1763 "'/Users/a b/bin/memstead-mcp'"
1764 );
1765 assert_eq!(shell_quote("a;rm -rf /"), "'a;rm -rf /'");
1767 assert_eq!(shell_quote("$(whoami)"), "'$(whoami)'");
1768 assert_eq!(shell_quote("it's"), r"'it'\''s'");
1770 assert_eq!(shell_quote(""), "''");
1771 }
1772
1773 #[test]
1774 fn wire_agent_codex_prints_command_writes_nothing() {
1775 let tmp = tempfile::tempdir().unwrap();
1776 let outcome = wire_agent(tmp.path(), AgentTarget::Codex, "/bin/memstead-mcp").unwrap();
1777 let rendered = outcome
1778 .action
1779 .render(outcome.target, &|rel: &str| rel.to_string());
1780 assert!(
1781 rendered.contains("codex mcp add memstead -- /bin/memstead-mcp"),
1782 "got: {rendered}",
1783 );
1784 assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0);
1785 }
1786}