use std::io::{IsTerminal, Write as _};
use std::path::{Path, PathBuf};
use clap::{Args as ClapArgs, ValueEnum};
use memstead_base::binding::ScaffoldParams;
use memstead_base::filesystem::config::{config_path, init_filesystem_mem_at, validate_mem_name};
use memstead_base::pipeline_store::write_binding;
use memstead_base::vcs::Actor;
use memstead_base::{CreateEntityArgs, Engine as BaseEngine};
use serde_json::json;
use crate::CliError;
use crate::output::{ExitKind, print_json, print_markdown};
use crate::setup::{CliContext, memstead_program, shell_quote};
use super::init::find_ancestor_workspace;
#[derive(ClapArgs, Debug)]
pub struct Args {
#[arg(value_name = "PATH")]
pub path: Option<PathBuf>,
#[arg(long)]
pub name: Option<String>,
#[arg(long = "agent", value_enum)]
pub agents: Vec<AgentTarget>,
#[arg(long = "repo", value_name = "PATH")]
pub repo: Option<PathBuf>,
}
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
pub enum AgentTarget {
ClaudeCode,
Codex,
Cursor,
Gemini,
}
impl AgentTarget {
fn label(self) -> &'static str {
match self {
AgentTarget::ClaudeCode => "Claude Code",
AgentTarget::Codex => "Codex",
AgentTarget::Cursor => "Cursor",
AgentTarget::Gemini => "Gemini CLI",
}
}
fn config_file(self) -> Option<&'static str> {
match self {
AgentTarget::ClaudeCode => Some(".mcp.json"),
AgentTarget::Cursor => Some(".cursor/mcp.json"),
AgentTarget::Gemini => Some(".gemini/settings.json"),
AgentTarget::Codex => None,
}
}
const ALL: [AgentTarget; 4] = [
AgentTarget::ClaudeCode,
AgentTarget::Codex,
AgentTarget::Cursor,
AgentTarget::Gemini,
];
}
enum WiringAction {
Wrote,
LeftUntouched,
RunCommand(String),
}
impl WiringAction {
fn render(&self, target: AgentTarget, path: &dyn Fn(&str) -> String) -> String {
match self {
WiringAction::Wrote => match target.config_file() {
Some(rel) => format!("wrote `{}` (server `memstead`)", path(rel)),
None => "wrote its config".to_string(),
},
WiringAction::LeftUntouched => match target.config_file() {
Some(rel) => format!(
"`{}` already has a `memstead` server entry — left untouched",
path(rel)
),
None => "already wired — left untouched".to_string(),
},
WiringAction::RunCommand(cmd) => format!("run: `{cmd}`"),
}
}
}
struct WiringOutcome {
target: AgentTarget,
action: WiringAction,
existing_command: Option<String>,
preexisting: bool,
file_existed: bool,
}
pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
if let Some(repo) = &args.repo
&& !repo.is_dir()
{
return Err(CliError::new(
ExitKind::Validation,
"INVALID_INPUT",
format!(
"--repo {} is not an existing directory — point it at the repository \
you already have: memstead quickstart --repo .",
repo.display(),
),
)
.with_details(json!({ "repo": repo.display().to_string() }))
.into());
}
let target = args
.path
.clone()
.or_else(|| args.repo.clone())
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
if target.exists() && !target.is_dir() {
return Err(CliError::new(
ExitKind::Validation,
"INVALID_INPUT",
format!(
"target {} exists but is not a directory — point at a folder: \
memstead quickstart my-graph",
target.display(),
),
)
.into());
}
let target_created = !target.exists();
if target_created {
std::fs::create_dir_all(&target).map_err(|e| {
CliError::new(
ExitKind::Generic,
"INTERNAL_IO_ERROR",
format!(
"failed to create target directory {}: {e}",
target.display()
),
)
.with_details(serde_json::json!({ "path": target.display().to_string() }))
})?;
}
let mem_in_subfolder = args
.repo
.as_deref()
.is_some_and(|repo| workspace_overlaps_repo(&target, repo));
check_no_local_memstead(&target)?;
if let Some(found_at) = find_ancestor_workspace(&target)? {
return Err(CliError::new(
ExitKind::Validation,
crate::WORKSPACE_ALREADY_EXISTS_ABOVE_CODE,
format!(
"{} is already inside the memstead workspace at {} — quickstart \
refuses to nest workspaces. Work in that workspace (memstead \
overview), or start a separate graph outside it: mkdir my-graph && \
cd my-graph && memstead quickstart",
target.display(),
found_at.display(),
),
)
.with_details(json!({ "found_at": found_at.display().to_string() }))
.into());
}
let name = resolve_mem_name(&target, args.name.as_deref(), args.repo.as_deref())?;
let mem_dir = if mem_in_subfolder {
target.join(&name)
} else {
target.clone()
};
if mem_in_subfolder {
guard_guided_mem_folder(&target, &mem_dir, &name)?;
}
let blocking = blocking_entries(&mem_dir)?;
if !blocking.is_empty() {
let md_note = if blocking.iter().any(|f| f.ends_with(".md`")) {
" (a filesystem mem owns every `.md` file in its folder, so quickstart \
would silently adopt them into the graph)"
} else {
""
};
return Err(CliError::new(
ExitKind::Validation,
crate::TARGET_NOT_EMPTY_CODE,
format!(
"target {} has content quickstart won't touch: {}{md_note} — move it \
out, or start in a fresh folder: mkdir my-graph && cd my-graph && \
memstead quickstart",
mem_dir.display(),
blocking.join(", "),
),
)
.with_details(json!({
"path": mem_dir.display().to_string(),
"found": blocking,
}))
.into());
}
let (agents, agents_defaulted) = resolve_agents(&args.agents)?;
for agent in &agents {
if let Some(rel) = agent.config_file() {
read_agent_config(&target.join(rel))?;
}
}
let schema_pin = default_schema_pin()?;
let guided_plan = match &args.repo {
Some(repo) => Some(GuidedPlan::derive(&target, repo, &name)?),
None => None,
};
init_filesystem_mem_at(&target, &mem_dir, &name, &schema_pin).map_err(|e| {
CliError::new(
ExitKind::Generic,
"INTERNAL_IO_ERROR",
format!("initialise filesystem mem: {e}"),
)
})?;
let seed_id = seed_entity(&target, &name)?;
let guided = match guided_plan {
Some(plan) => Some(plan.write(&target)?),
None => None,
};
let mcp_bin = resolve_mcp_binary();
let mut wirings = Vec::with_capacity(agents.len());
for agent in &agents {
wirings.push(wire_agent(&target, *agent, &mcp_bin.command)?);
}
report(
ctx,
&target,
&mem_dir,
&name,
&schema_pin,
&seed_id,
&wirings,
agents_defaulted,
&mcp_bin,
guided.as_ref(),
target_created,
)
}
fn workspace_overlaps_repo(target: &Path, repo: &Path) -> bool {
let t = target
.canonicalize()
.unwrap_or_else(|_| target.to_path_buf());
let r = repo.canonicalize().unwrap_or_else(|_| repo.to_path_buf());
t.starts_with(&r) || r.starts_with(&t)
}
fn workspace_within_repo(target: &Path, repo: &Path) -> Option<String> {
let t = target
.canonicalize()
.unwrap_or_else(|_| target.to_path_buf());
let r = repo.canonicalize().unwrap_or_else(|_| repo.to_path_buf());
let rel = t.strip_prefix(&r).ok()?;
Some(rel.to_string_lossy().replace('\\', "/"))
}
fn guard_guided_mem_folder(repo: &Path, mem_dir: &Path, name: &str) -> anyhow::Result<()> {
if !mem_dir.exists() {
return Ok(());
}
let retry = ShellCmd::new(memstead_program())
.arg("quickstart")
.arg("--repo")
.arg(repo.display().to_string())
.arg("--name")
.arg(format!("{name}-mem"))
.render();
if !mem_dir.is_dir() {
return Err(CliError::new(
ExitKind::Validation,
crate::TARGET_NOT_EMPTY_CODE,
format!(
"the mem would take the folder {}, and that path already exists as a file \
— name the mem something else: {retry}",
mem_dir.display(),
),
)
.with_details(json!({ "path": mem_dir.display().to_string() }))
.into());
}
let occupied = std::fs::read_dir(mem_dir)
.map(|entries| entries.count() > 0)
.unwrap_or(true);
if occupied {
return Err(CliError::new(
ExitKind::Validation,
crate::TARGET_NOT_EMPTY_CODE,
format!(
"the mem would take the folder {}, and that folder already exists and is \
not empty — quickstart won't adopt a folder the repository already uses. \
Name the mem something else: {retry}",
mem_dir.display(),
),
)
.with_details(json!({ "path": mem_dir.display().to_string() }))
.into());
}
Ok(())
}
struct GuidedPlan {
pointer: String,
stem: String,
repo_display: String,
layout_warning: Option<String>,
workspace_in_repo: Option<String>,
is_git_repo: bool,
mem: String,
}
struct GuidedOutcome {
binding_id: String,
pointer: String,
repo_display: String,
record: String,
deny_paths: Vec<String>,
operations: Vec<String>,
warnings: Vec<String>,
workspace_in_repo: Option<String>,
is_git_repo: bool,
}
impl GuidedPlan {
fn derive(workspace_root: &Path, repo: &Path, mem: &str) -> anyhow::Result<Self> {
let workspace_abs = workspace_root
.canonicalize()
.unwrap_or_else(|_| workspace_root.to_path_buf());
let repo_abs = repo.canonicalize().unwrap_or_else(|_| repo.to_path_buf());
let pointer = if repo_abs == workspace_abs {
".".to_string()
} else {
let rel = memstead_base::ingest::cursor::relative_to(&workspace_abs, &repo_abs);
if rel.as_os_str().is_empty() {
".".to_string()
} else {
rel.to_string_lossy().replace('\\', "/")
}
};
let stem = repo_abs
.file_name()
.map(|n| n.to_string_lossy().to_string())
.and_then(|n| derive_mem_name(&n))
.unwrap_or_else(|| mem.to_string());
let layout_warning = memstead_base::ingest::cursor::out_of_root_layout_warning(
&pointer,
&workspace_abs,
memstead_base::MediumType::Codebase,
);
Ok(GuidedPlan {
pointer,
stem,
repo_display: repo_abs.display().to_string(),
layout_warning,
workspace_in_repo: workspace_within_repo(workspace_root, repo),
is_git_repo: repo_abs.join(".git").exists(),
mem: mem.to_string(),
})
}
fn write(self, workspace_root: &Path) -> anyhow::Result<GuidedOutcome> {
let GuidedPlan {
pointer,
stem,
repo_display,
layout_warning,
workspace_in_repo,
is_git_repo,
mem,
} = self;
let scaffolded = memstead_base::binding::scaffold_binding(ScaffoldParams {
destination_mem: &mem,
source_name: &stem,
pointer: &pointer,
medium_type: memstead_base::MediumType::Codebase,
intent: Some(format!(
"Model the `{stem}` codebase in the `{mem}` mem: what each part is for, \
how the parts fit together, and the decisions behind them."
)),
additional_deny_paths: Vec::new(),
});
write_binding(workspace_root, &mem, &stem, &scaffolded.binding).map_err(|e| {
CliError::new(
ExitKind::Generic,
"PROJECTION_INIT_FAILED",
format!("could not scaffold binding `{mem}/{stem}`: {e}"),
)
.with_details(json!({ "binding": format!("{mem}/{stem}"), "error": e.to_string() }))
})?;
let mut warnings: Vec<String> = scaffolded.warnings;
warnings.extend(layout_warning);
Ok(GuidedOutcome {
binding_id: format!("{mem}/{stem}"),
pointer,
repo_display,
record: format!(".memstead/projections/{mem}/{stem}.json"),
deny_paths: scaffolded.binding.deny_paths.clone(),
operations: scaffolded
.operations
.iter()
.map(|o| (*o).to_string())
.collect(),
warnings,
workspace_in_repo,
is_git_repo,
})
}
}
fn check_no_local_memstead(target: &Path) -> anyhow::Result<()> {
let store = target.join(memstead_base::WORKSPACE_STORE_DIR);
if !store.exists() {
return Ok(());
}
if memstead_base::is_workspace_root(target) {
return Err(CliError::new(
ExitKind::Validation,
"WORKSPACE_ALREADY_INITIALISED",
format!(
"{} is already a Memstead workspace — nothing to bootstrap. \
Inspect it with: memstead overview",
target.display(),
),
)
.with_details(json!({ "path": target.display().to_string() }))
.into());
}
Err(CliError::new(
ExitKind::Validation,
"FOREIGN_MEMSTEAD_DIR",
format!(
"{} contains a `.memstead/` directory that is not a workspace \
(no workspace.toml) — quickstart won't adopt or overwrite it. \
Move it aside, or start fresh: mkdir my-graph && cd my-graph && \
memstead quickstart",
target.display(),
),
)
.with_details(json!({ "path": store.display().to_string() }))
.into())
}
fn blocking_entries(target: &Path) -> anyhow::Result<Vec<String>> {
if !target.exists() {
return Ok(Vec::new());
}
let read_err = |e: std::io::Error| {
CliError::new(
ExitKind::Generic,
"INTERNAL_IO_ERROR",
format!("read target {}: {e}", target.display()),
)
};
let mut blocking = Vec::new();
for entry in std::fs::read_dir(target).map_err(read_err)? {
let entry = entry.map_err(read_err)?;
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
let lower = name.to_lowercase();
let readme_grade = lower.starts_with("readme")
|| lower.starts_with("license")
|| lower.starts_with("licence");
if readme_grade && !lower.ends_with(".md") {
continue;
}
blocking.push(format!("`{name}`"));
}
blocking.sort();
Ok(blocking)
}
fn resolve_mem_name(
target: &Path,
flag: Option<&str>,
repo: Option<&Path>,
) -> anyhow::Result<String> {
let retry = |name: &str| match repo {
Some(repo) => ShellCmd::new(memstead_program())
.arg("quickstart")
.arg("--repo")
.arg(repo.display().to_string())
.arg("--name")
.arg(name)
.render(),
None => format!("memstead quickstart --name {name}"),
};
if let Some(name) = flag {
validate_mem_name(name).map_err(|e| {
CliError::new(
ExitKind::Validation,
"INVALID_INPUT",
format!(
"invalid --name: {e}. Retry with a slug, e.g.: {}",
retry(&derive_mem_name(name).unwrap_or_else(|| "my-graph".to_string())),
),
)
})?;
return Ok(name.to_string());
}
let basename = std::fs::canonicalize(target)
.ok()
.and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string()))
.unwrap_or_default();
if let Some(derived) = derive_mem_name(&basename) {
return Ok(derived);
}
if std::io::stdin().is_terminal() {
let answer = prompt_line(&format!(
"Could not derive a mem name from `{basename}`. Mem name (lowercase letters, digits, hyphens): ",
))?;
let answer = answer.trim();
validate_mem_name(answer).map_err(|e| {
CliError::new(
ExitKind::Validation,
"INVALID_INPUT",
format!("invalid mem name: {e}. Retry with: {}", retry("my-graph")),
)
})?;
return Ok(answer.to_string());
}
Err(CliError::new(
ExitKind::Validation,
"INVALID_INPUT",
format!(
"could not derive a mem name from directory `{basename}` — \
pass one explicitly: {}",
retry("my-graph"),
),
)
.with_details(json!({ "directory": basename }))
.into())
}
fn derive_mem_name(basename: &str) -> Option<String> {
let mut out = String::with_capacity(basename.len());
for c in basename.to_lowercase().chars() {
if c.is_ascii_lowercase() || c.is_ascii_digit() {
out.push(c);
} else if !out.is_empty() && !out.ends_with('-') {
out.push('-');
}
}
let mut slug: String = out.trim_matches('-').chars().take(64).collect();
slug = slug.trim_matches('-').to_string();
validate_mem_name(&slug).ok().map(|()| slug)
}
fn resolve_agents(flag: &[AgentTarget]) -> anyhow::Result<(Vec<AgentTarget>, bool)> {
if !flag.is_empty() {
let mut seen = Vec::with_capacity(flag.len());
for a in flag {
if !seen.contains(a) {
seen.push(*a);
}
}
return Ok((seen, false));
}
if std::io::stdin().is_terminal() {
return Ok((prompt_agents()?, false));
}
Ok((vec![AgentTarget::ClaudeCode], true))
}
fn prompt_agents() -> anyhow::Result<Vec<AgentTarget>> {
let menu: Vec<String> = AgentTarget::ALL
.iter()
.enumerate()
.map(|(i, a)| format!(" {}) {}", i + 1, a.label()))
.collect();
let answer = prompt_line(&format!(
"Which agents should connect to this mem? (comma-separated, Enter = Claude Code)\n{}\n> ",
menu.join("\n"),
))?;
let answer = answer.trim();
if answer.is_empty() {
return Ok(vec![AgentTarget::ClaudeCode]);
}
let mut selected = Vec::new();
for token in answer.split(',') {
let token = token.trim();
let picked = match token.parse::<usize>() {
Ok(n) if (1..=AgentTarget::ALL.len()).contains(&n) => AgentTarget::ALL[n - 1],
_ => {
return Err(CliError::new(
ExitKind::Validation,
"INVALID_INPUT",
format!(
"unrecognised selection `{token}` — expected numbers 1-{max} \
(comma-separated). Skip the prompt with: memstead quickstart \
--agent claude-code --agent cursor",
max = AgentTarget::ALL.len(),
),
)
.into());
}
};
if !selected.contains(&picked) {
selected.push(picked);
}
}
Ok(selected)
}
fn prompt_line(msg: &str) -> anyhow::Result<String> {
let mut stderr = std::io::stderr();
stderr.write_all(msg.as_bytes()).ok();
stderr.flush().ok();
let mut line = String::new();
std::io::stdin().read_line(&mut line).map_err(|e| {
CliError::new(
ExitKind::Generic,
"INTERNAL_IO_ERROR",
format!("read answer from stdin: {e}"),
)
})?;
Ok(line)
}
fn default_schema_pin() -> anyhow::Result<memstead_schema::SchemaRef> {
let reg = memstead_schema::SchemaRegistry::builtin();
match reg.get("default", &semver::Version::new(1, 3, 0)) {
Some(schema) => {
let (name, version) = schema.id();
Ok(memstead_schema::SchemaRef::new(name, version))
}
_ => Err(CliError::new(
ExitKind::Generic,
crate::INTERNAL_CODE,
"builtin schema catalogue has no `default` schema — this binary is broken, please report",
)
.into()),
}
}
fn seed_entity(target: &Path, mem: &str) -> anyhow::Result<String> {
let mut engine = BaseEngine::from_workspace_root(target).map_err(|e| {
CliError::new(
ExitKind::Generic,
crate::INTERNAL_CODE,
format!("boot engine at {}: {e:#}", target.display()),
)
})?;
let mut sections = indexmap::IndexMap::new();
sections.insert(
"definition".to_string(),
"This mem is a typed knowledge graph: markdown entities validated against a schema, \
connected by typed relationships."
.to_string(),
);
sections.insert(
"explanation".to_string(),
"`memstead quickstart` seeded this entity so the graph starts non-empty. Read it back \
with `memstead entity <id>`, list types with `memstead type`, create your own with \
`memstead create`, and delete this one any time with `memstead delete <id>`."
.to_string(),
);
let outcome = engine
.create_entity(
CreateEntityArgs {
anchors: Vec::new(),
mem: mem.to_string(),
title: "Welcome to Memstead".to_string(),
entity_type: "concept".to_string(),
sections,
metadata: indexmap::IndexMap::new(),
relations: Vec::new(),
dry_run: false,
},
Actor::Cli,
None,
Some("seeded by memstead quickstart"),
)
.map_err(CliError::from_engine_op)?;
Ok(outcome.id.as_ref().to_string())
}
struct McpBinary {
command: String,
warning: Option<String>,
}
fn resolve_mcp_binary() -> McpBinary {
if let Ok(exe) = std::env::current_exe()
&& let Some(dir) = exe.parent()
{
let sibling = dir.join("memstead-mcp");
if sibling.is_file() {
return McpBinary {
command: sibling.display().to_string(),
warning: None,
};
}
}
if let Some(paths) = std::env::var_os("PATH") {
for dir in std::env::split_paths(&paths) {
let candidate = dir.join("memstead-mcp");
if candidate.is_file() {
return McpBinary {
command: candidate.display().to_string(),
warning: None,
};
}
}
}
McpBinary {
command: "memstead-mcp".to_string(),
warning: Some(
"`memstead-mcp` was not found next to this binary or on PATH — the wiring uses the \
bare name and will work once it is installed (curl -sSf https://memstead.io/install.sh | sh)"
.to_string(),
),
}
}
fn read_agent_config(path: &Path) -> anyhow::Result<serde_json::Value> {
if !path.is_file() {
return Ok(json!({}));
}
let fix_hint = "fix or remove the file, then re-run: memstead quickstart";
let bytes = std::fs::read(path).map_err(|e| {
CliError::new(
ExitKind::Generic,
"INTERNAL_IO_ERROR",
format!("read {}: {e}", path.display()),
)
})?;
let root: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| {
CliError::new(
ExitKind::Validation,
"INVALID_INPUT",
format!(
"{} exists but is not valid JSON ({e}) — {fix_hint}",
path.display()
),
)
})?;
if !root.is_object() {
return Err(CliError::new(
ExitKind::Validation,
"INVALID_INPUT",
format!(
"{} exists but its top level is not a JSON object — {fix_hint}",
path.display(),
),
)
.into());
}
let servers = &root["mcpServers"];
if !servers.is_null() && !servers.is_object() {
return Err(CliError::new(
ExitKind::Validation,
"INVALID_INPUT",
format!(
"{}'s `mcpServers` is not a JSON object — {fix_hint}",
path.display(),
),
)
.into());
}
Ok(root)
}
fn wire_agent(
target: &Path,
agent: AgentTarget,
mcp_command: &str,
) -> anyhow::Result<WiringOutcome> {
let Some(rel) = agent.config_file() else {
let add = ShellCmd::new("codex")
.arg("mcp")
.arg("add")
.arg("memstead")
.end_of_options()
.arg(mcp_command)
.render();
return Ok(WiringOutcome {
target: agent,
action: WiringAction::RunCommand(add),
existing_command: None,
preexisting: false,
file_existed: false,
});
};
let path = target.join(rel);
let file_existed = path.exists();
let mut root = read_agent_config(&path)?;
let servers = root
.as_object_mut()
.expect("read_agent_config only returns JSON objects")
.entry("mcpServers")
.or_insert_with(|| json!({}));
let servers = servers.as_object_mut().ok_or_else(|| {
CliError::new(
ExitKind::Validation,
"INVALID_INPUT",
format!(
"{}'s `mcpServers` is not a JSON object — fix or remove the file, then \
re-run: memstead quickstart",
path.display(),
),
)
})?;
if let Some(existing) = servers.get("memstead") {
let existing_command = existing
.get("command")
.and_then(|c| c.as_str())
.map(str::to_string);
return Ok(WiringOutcome {
target: agent,
action: WiringAction::LeftUntouched,
existing_command,
preexisting: true,
file_existed,
});
}
servers.insert("memstead".to_string(), json!({ "command": mcp_command }));
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
CliError::new(
ExitKind::Generic,
"INTERNAL_IO_ERROR",
format!("create {}: {e}", parent.display()),
)
})?;
}
let rendered = format!(
"{}\n",
serde_json::to_string_pretty(&root).unwrap_or_default()
);
std::fs::write(&path, rendered).map_err(|e| {
CliError::new(
ExitKind::Generic,
"INTERNAL_IO_ERROR",
format!("write {}: {e}", path.display()),
)
})?;
Ok(WiringOutcome {
target: agent,
action: WiringAction::Wrote,
existing_command: None,
preexisting: false,
file_existed,
})
}
enum Word {
Value(String),
Literal(&'static str),
}
struct ShellCmd {
cd: Option<String>,
program: String,
args: Vec<Word>,
}
impl ShellCmd {
fn new(program: impl Into<String>) -> Self {
ShellCmd {
cd: None,
program: program.into(),
args: Vec::new(),
}
}
fn arg(mut self, arg: impl Into<String>) -> Self {
self.args.push(Word::Value(arg.into()));
self
}
fn end_of_options(mut self) -> Self {
self.args.push(Word::Literal("--"));
self
}
fn in_dir(mut self, dir: &Path, already_there: bool) -> Self {
if !already_there {
self.cd = Some(dir.display().to_string());
}
self
}
fn render(&self) -> String {
let mut out = String::new();
if let Some(dir) = &self.cd {
out.push_str(&format!("cd -- {} && ", shell_quote(dir)));
}
out.push_str(&shell_quote(&self.program));
for arg in &self.args {
out.push(' ');
match arg {
Word::Value(v) => out.push_str(&shell_quote(v)),
Word::Literal(l) => out.push_str(l),
}
}
out
}
}
#[allow(clippy::too_many_arguments)]
fn report(
ctx: &CliContext,
target: &Path,
mem_dir: &Path,
name: &str,
schema_pin: &memstead_schema::SchemaRef,
seed_id: &str,
wirings: &[WiringOutcome],
agents_defaulted: bool,
mcp_bin: &McpBinary,
guided: Option<&GuidedOutcome>,
target_created: bool,
) -> anyhow::Result<()> {
let restart_labels: Vec<&str> = wirings.iter().map(|w| w.target.label()).collect();
let absolute = target
.canonicalize()
.unwrap_or_else(|_| target.to_path_buf());
let in_cwd = std::env::current_dir()
.ok()
.is_some_and(|cwd| cwd == absolute);
let memstead = memstead_program();
let overview_cmd = ShellCmd::new(&memstead)
.arg("overview")
.in_dir(target, in_cwd)
.render();
let delete_cmd = ShellCmd::new(&memstead)
.arg("delete")
.arg(seed_id)
.in_dir(target, in_cwd)
.render();
let version_cmd = ShellCmd::new(&mcp_bin.command).arg("--version").render();
let brief_cmd = guided.map(|g| {
ShellCmd::new(&memstead)
.arg("projection")
.arg("brief")
.arg(&g.binding_id)
.in_dir(target, in_cwd)
.render()
});
let mem_absolute = mem_dir.canonicalize().unwrap_or_else(|_| {
if mem_dir == target {
absolute.clone()
} else {
mem_dir.to_path_buf()
}
});
let cwd_canon = std::env::current_dir()
.ok()
.map(|c| c.canonicalize().unwrap_or(c));
let from_here = |workspace_relative: &str| -> String {
let joined = absolute.join(workspace_relative);
let resolved = joined.canonicalize().unwrap_or(joined);
let absolute_form = resolved.display().to_string();
let Some(cwd) = &cwd_canon else {
return absolute_form;
};
let rel = memstead_base::ingest::cursor::relative_to(cwd, &resolved)
.to_string_lossy()
.replace('\\', "/");
if rel.is_empty() {
return ".".to_string();
}
if rel.len() <= absolute_form.len() {
rel
} else {
absolute_form
}
};
let mem_folder_here: Option<String> = match mem_dir
.strip_prefix(target)
.ok()
.map(|r| r.to_string_lossy().to_string())
.filter(|r| !r.is_empty())
{
Some(rel) => Some(from_here(&rel)),
None if guided.is_some() && !in_cwd => Some(from_here(".")),
None => None,
};
let mem_folder_rel: Option<String> = mem_dir
.strip_prefix(target)
.ok()
.map(|r| r.to_string_lossy().to_string())
.filter(|r| !r.is_empty());
let codex_pending = wirings
.iter()
.any(|w| w.target == AgentTarget::Codex && matches!(w.action, WiringAction::RunCommand(_)));
let restart_clause = format!(
"Restart {} so the `memstead` MCP server registers its tools",
restart_labels.join(" / "),
);
let next_action = if codex_pending {
format!(
"Run the `codex mcp add` command above first — it is Codex's wiring, and a restart \
registers nothing without it. Then: {restart_clause} — then try: {overview_cmd}"
)
} else {
format!("{restart_clause} — then try: {overview_cmd}")
};
let mut verify_now: Vec<(&str, String)> = Vec::new();
let fresh_wiring = wirings.iter().any(|w| !w.preexisting);
if fresh_wiring && mcp_bin.warning.is_none() {
verify_now.push(("the wired binary answers", version_cmd));
}
let mut seen_existing: Vec<String> = Vec::new();
for w in wirings.iter().filter(|w| w.preexisting) {
match &w.existing_command {
Some(cmd) if !seen_existing.contains(cmd) => {
seen_existing.push(cmd.clone());
verify_now.push((
"the pre-existing `memstead` entry's binary answers",
ShellCmd::new(cmd).arg("--version").render(),
));
}
_ => {}
}
}
verify_now.push(("the graph is already readable", overview_cmd.clone()));
if let Some(cmd) = &brief_cmd {
verify_now.push(("the binding renders its ingest brief", cmd.clone()));
}
let build_brief = |path: &dyn Fn(&str) -> String| -> Vec<String> {
match (guided, &brief_cmd) {
(Some(g), Some(brief)) => {
let mut b = vec![
"## What this mem holds".to_string(),
String::new(),
format!(
"- Now: one seed entity (`{seed_id}`). Nothing else — scaffolding a \
binding reads no source file and creates no entity from one."
),
format!(
"- Not yet: anything from `{}`. Its {} are the binding's subject, \
not its content.",
g.repo_display,
if g.is_git_repo {
"code, docs and history"
} else {
"files"
},
),
format!(
"- Growth: the ingest loop against binding `{}` — one batch at a \
time, each entity written through the same validated path as the \
seed. Start with: `{brief}`, or follow the walkthrough at \
https://memstead.com/dev/guides/grow-a-mem-from-a-source/",
g.binding_id,
),
format!(
"- Scope: everything under `{}`, minus what the record denies ({}) \
and minus {}, which the engine excludes unconditionally. The deny \
list is yours to edit: `{}`",
path(&g.pointer),
g.deny_paths
.iter()
.map(|d| format!("`{d}`"))
.collect::<Vec<_>>()
.join(", "),
match &mem_folder_rel {
Some(rel) => {
format!("engine state and the mem's own folder `{}/`", path(rel))
}
None => "engine state (`.memstead/`)".to_string(),
},
path(&g.record),
),
format!(
"- Operations the binding declares: {}",
g.operations.join(", ")
),
];
if let Some(ws_rel) = &g.workspace_in_repo {
let in_repo = |p: &str| {
if ws_rel.is_empty() {
format!("`{p}`")
} else {
format!("`{ws_rel}/{p}`")
}
};
let mut written = Vec::new();
if target_created && !ws_rel.is_empty() {
written.push(format!(
"`{ws_rel}/` (the workspace: its state, the binding record, \
the mem, and the agent wiring — plus the engine's cache, \
which appears inside it once the binding is first measured)"
));
} else {
written.push(format!(
"{} (workspace state and the binding record; a sibling \
`.memstead.cache/` appears once the binding is first measured)",
in_repo(".memstead/")
));
if let Some(rel) = &mem_folder_rel {
written.push(format!("{} (the mem)", in_repo(&format!("{rel}/"))));
}
for w in wirings.iter().filter(|w| !w.preexisting) {
if let Some(f) = w.target.config_file() {
let verb = if w.file_existed {
"agent wiring added to it"
} else {
"agent wiring"
};
written.push(format!("{} ({verb})", in_repo(f)));
}
}
}
b.push(format!(
"- Written into your {}: {}. Nothing else in the tree was touched.",
if g.is_git_repo {
"repository"
} else {
"source directory"
},
written.join(", "),
));
}
b
}
_ => Vec::new(),
}
};
let brief_lines = build_brief(&from_here);
let brief_lines_machine = build_brief(&|rel: &str| rel.to_string());
if ctx.json {
let mut payload = json!({
"workspace_root": absolute.display().to_string(),
"config_path": config_path(&mem_absolute).display().to_string(),
"seed_entity_delete_command": delete_cmd,
"name": name,
"schema": schema_pin.as_display(),
"seed_entity": seed_id,
"mcp_command": mcp_bin.command,
"agents": wirings
.iter()
.map(|w| json!({
"target": w.target.to_possible_value().map(|v| v.get_name().to_string()),
"action": w.action.render(w.target, &|rel: &str| rel.to_string()),
}))
.collect::<Vec<_>>(),
"agents_defaulted": agents_defaulted,
"workspace_shape": crate::setup::WorkspaceShape::Filesystem.label(),
"workspace_shape_disclosure":
crate::setup::shape_disclosure_in(
crate::setup::WorkspaceShape::Filesystem,
mem_folder_rel.as_deref(),
).to_json(),
"next_action": next_action,
"verify_now": verify_now
.iter()
.map(|(what, command)| json!({ "what": what, "command": command }))
.collect::<Vec<_>>(),
"warnings": mcp_bin.warning.as_ref().map(|w| vec![w.clone()]).unwrap_or_default(),
});
if let Some(g) = guided {
payload["mem_folder"] =
json!(mem_folder_rel.clone().unwrap_or_else(|| ".".to_string()));
payload["repo"] = json!(g.repo_display);
payload["binding"] = json!({
"id": g.binding_id,
"pointer": g.pointer,
"record": g.record,
"deny_paths": g.deny_paths,
"operations": g.operations,
});
payload["brief"] = json!(
brief_lines_machine
.iter()
.filter(|l| l.starts_with("- "))
.map(|l| l.trim_start_matches("- ").to_string())
.collect::<Vec<_>>()
);
if !g.warnings.is_empty() {
let mut w: Vec<String> = payload["warnings"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
w.extend(g.warnings.iter().cloned());
payload["warnings"] = json!(w);
}
}
return print_json(&payload);
}
let mut lines = vec![
format!("# Quickstart complete — mem `{name}`"),
String::new(),
format!(
"- Workspace: `{}`",
if guided.is_some() {
absolute.display().to_string()
} else {
target.display().to_string()
}
),
];
if let Some(rel) = &mem_folder_rel {
lines.push(format!(
"- Mem folder: `{}/` (the graph owns this folder and nothing else)",
from_here(rel),
));
}
lines.push(format!("- Schema pin: `{}`", schema_pin.as_display()));
lines.push(format!(
"- Seed entity: `{seed_id}` (remove any time: `{delete_cmd}`)"
));
if let Some(g) = guided {
lines.push(format!(
"- Binding: `{}` over `{}` (record: `{}`)",
g.binding_id,
from_here(&g.pointer),
from_here(&g.record),
));
}
for w in wirings {
lines.push(format!(
"- {}: {}",
w.target.label(),
w.action.render(w.target, &from_here),
));
}
if agents_defaulted {
lines.push(
"- No `--agent` given and no terminal to ask — defaulted to Claude Code \
(re-run with `--agent` for others)"
.to_string(),
);
}
let mut warnings: Vec<String> = mcp_bin.warning.iter().cloned().collect();
warnings.extend(guided.iter().flat_map(|g| g.warnings.iter().cloned()));
if !warnings.is_empty() {
lines.push(String::new());
for warning in &warnings {
lines.push(format!("> warning: {warning}"));
}
}
if !brief_lines.is_empty() {
lines.push(String::new());
lines.extend(brief_lines.iter().cloned());
}
lines.push(String::new());
lines.extend(crate::setup::shape_disclosure_lines_in(
crate::setup::WorkspaceShape::Filesystem,
mem_folder_here.as_deref(),
));
lines.push(String::new());
lines.push(format!("Next: {next_action}"));
lines.push(String::new());
lines.push("Verify from this session, no restart needed:".to_string());
lines.extend(
verify_now
.iter()
.map(|(what, command)| format!("- {what}: `{command}`")),
);
print_markdown(&lines.join("\n"));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn derive_mem_name_handles_common_directory_names() {
assert_eq!(derive_mem_name("my-graph").as_deref(), Some("my-graph"));
assert_eq!(derive_mem_name("My Project").as_deref(), Some("my-project"));
assert_eq!(
derive_mem_name("Notes_2026 (v2)").as_deref(),
Some("notes-2026-v2")
);
assert_eq!(derive_mem_name("日本語"), None);
assert_eq!(derive_mem_name(""), None);
assert_eq!(derive_mem_name("a"), None);
}
#[test]
fn blocking_entries_tolerates_dotfiles_and_readme_grade() {
let tmp = tempfile::tempdir().unwrap();
for f in [".gitignore", ".mcp.json", "README", "LICENSE", "Readme.txt"] {
std::fs::write(tmp.path().join(f), b"x").unwrap();
}
std::fs::create_dir(tmp.path().join(".git")).unwrap();
assert!(blocking_entries(tmp.path()).unwrap().is_empty());
std::fs::write(tmp.path().join("README.md"), b"# hi").unwrap();
assert_eq!(blocking_entries(tmp.path()).unwrap(), vec!["`README.md`"]);
std::fs::remove_file(tmp.path().join("README.md")).unwrap();
std::fs::write(tmp.path().join("main.rs"), b"fn main() {}").unwrap();
assert_eq!(blocking_entries(tmp.path()).unwrap(), vec!["`main.rs`"]);
}
#[test]
fn wire_agent_merges_and_never_overwrites() {
let tmp = tempfile::tempdir().unwrap();
let outcome = wire_agent(tmp.path(), AgentTarget::ClaudeCode, "/bin/memstead-mcp").unwrap();
let rendered = outcome
.action
.render(outcome.target, &|rel: &str| rel.to_string());
assert!(rendered.contains("wrote"), "got: {rendered}");
let parsed: serde_json::Value =
serde_json::from_slice(&std::fs::read(tmp.path().join(".mcp.json")).unwrap()).unwrap();
assert_eq!(
parsed["mcpServers"]["memstead"]["command"],
"/bin/memstead-mcp"
);
std::fs::write(
tmp.path().join(".mcp.json"),
serde_json::to_vec_pretty(&serde_json::json!({
"mcpServers": {
"other": { "command": "/bin/other" },
"memstead": { "command": "/custom/memstead-mcp" },
}
}))
.unwrap(),
)
.unwrap();
let outcome = wire_agent(tmp.path(), AgentTarget::ClaudeCode, "/bin/memstead-mcp").unwrap();
let rendered = outcome
.action
.render(outcome.target, &|rel: &str| rel.to_string());
assert!(rendered.contains("left untouched"), "got: {rendered}");
let parsed: serde_json::Value =
serde_json::from_slice(&std::fs::read(tmp.path().join(".mcp.json")).unwrap()).unwrap();
assert_eq!(
parsed["mcpServers"]["memstead"]["command"],
"/custom/memstead-mcp"
);
assert_eq!(parsed["mcpServers"]["other"]["command"], "/bin/other");
}
#[test]
fn shell_quote_leaves_ordinary_paths_alone_and_quotes_the_rest() {
assert_eq!(
shell_quote("/usr/local/bin/memstead-mcp"),
"/usr/local/bin/memstead-mcp"
);
assert_eq!(shell_quote("my-graph"), "my-graph");
assert_eq!(shell_quote("My Graph"), "'My Graph'");
assert_eq!(
shell_quote("/Users/a b/bin/memstead-mcp"),
"'/Users/a b/bin/memstead-mcp'"
);
assert_eq!(shell_quote("a;rm -rf /"), "'a;rm -rf /'");
assert_eq!(shell_quote("$(whoami)"), "'$(whoami)'");
assert_eq!(shell_quote("it's"), r"'it'\''s'");
assert_eq!(shell_quote(""), "''");
}
#[test]
fn wire_agent_codex_prints_command_writes_nothing() {
let tmp = tempfile::tempdir().unwrap();
let outcome = wire_agent(tmp.path(), AgentTarget::Codex, "/bin/memstead-mcp").unwrap();
let rendered = outcome
.action
.render(outcome.target, &|rel: &str| rel.to_string());
assert!(
rendered.contains("codex mcp add memstead -- /bin/memstead-mcp"),
"got: {rendered}",
);
assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0);
}
}