use std::io::{IsTerminal, Write as _};
use std::path::{Path, PathBuf};
use clap::{Args as ClapArgs, ValueEnum};
use memstead_base::filesystem::config::{config_path, init_filesystem_mem, validate_mem_name};
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;
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>,
}
#[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,
];
}
struct WiringOutcome {
target: AgentTarget,
action: String,
}
pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
let target = args
.path
.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());
}
if !target.exists() {
std::fs::create_dir_all(&target).map_err(|e| {
CliError::new(
ExitKind::Generic,
crate::INTERNAL_CODE,
format!(
"failed to create target directory {}: {e}",
target.display()
),
)
})?;
}
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 blocking = blocking_entries(&target)?;
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",
target.display(),
blocking.join(", "),
),
)
.with_details(json!({
"path": target.display().to_string(),
"found": blocking,
}))
.into());
}
let name = resolve_mem_name(&target, args.name.as_deref())?;
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()?;
init_filesystem_mem(&target, &name, &schema_pin).map_err(|e| {
CliError::new(
ExitKind::Generic,
crate::INTERNAL_CODE,
format!("initialise filesystem mem: {e}"),
)
})?;
let seed_id = seed_entity(&target, &name)?;
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,
&name,
&schema_pin,
&seed_id,
&wirings,
agents_defaulted,
&mcp_bin,
)
}
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>> {
let read_err = |e: std::io::Error| {
CliError::new(
ExitKind::Generic,
crate::INTERNAL_CODE,
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>) -> anyhow::Result<String> {
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.: memstead quickstart \
--name {}",
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: memstead quickstart --name 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: memstead quickstart --name 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,
crate::INTERNAL_CODE,
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,
crate::INTERNAL_CODE,
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 {
return Ok(WiringOutcome {
target: agent,
action: format!("run: `codex mcp add memstead -- {mcp_command}`"),
});
};
let path = target.join(rel);
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 servers.contains_key("memstead") {
return Ok(WiringOutcome {
target: agent,
action: format!("`{rel}` already has a `memstead` server entry — left untouched"),
});
}
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,
crate::INTERNAL_CODE,
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,
crate::INTERNAL_CODE,
format!("write {}: {e}", path.display()),
)
})?;
Ok(WiringOutcome {
target: agent,
action: format!("wrote `{rel}` (server `memstead`)"),
})
}
#[allow(clippy::too_many_arguments)]
fn report(
ctx: &CliContext,
target: &Path,
name: &str,
schema_pin: &memstead_schema::SchemaRef,
seed_id: &str,
wirings: &[WiringOutcome],
agents_defaulted: bool,
mcp_bin: &McpBinary,
) -> anyhow::Result<()> {
let restart_labels: Vec<&str> = wirings.iter().map(|w| w.target.label()).collect();
let next_action = format!(
"Restart {} so the `memstead` MCP server registers — then try: memstead overview",
restart_labels.join(" / "),
);
if ctx.json {
return print_json(&json!({
"workspace_root": target.display().to_string(),
"config_path": config_path(target).display().to_string(),
"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,
}))
.collect::<Vec<_>>(),
"agents_defaulted": agents_defaulted,
"next_action": next_action,
"warnings": mcp_bin.warning.as_ref().map(|w| vec![w.clone()]).unwrap_or_default(),
}));
}
let mut lines = vec![
format!("# Quickstart complete — mem `{name}`"),
String::new(),
format!("- Workspace: `{}`", target.display()),
format!("- Schema pin: `{}`", schema_pin.as_display()),
format!("- Seed entity: `{seed_id}` (remove any time: `memstead delete {seed_id}`)"),
];
for w in wirings {
lines.push(format!("- {}: {}", w.target.label(), w.action));
}
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(),
);
}
if let Some(warning) = &mcp_bin.warning {
lines.push(String::new());
lines.push(format!("> warning: {warning}"));
}
lines.push(String::new());
lines.push(format!("Next: {next_action}"));
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();
assert!(outcome.action.contains("wrote"), "got: {}", outcome.action);
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();
assert!(
outcome.action.contains("left untouched"),
"got: {}",
outcome.action
);
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 wire_agent_codex_prints_command_writes_nothing() {
let tmp = tempfile::tempdir().unwrap();
let outcome = wire_agent(tmp.path(), AgentTarget::Codex, "/bin/memstead-mcp").unwrap();
assert!(
outcome
.action
.contains("codex mcp add memstead -- /bin/memstead-mcp"),
"got: {}",
outcome.action,
);
assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0);
}
}