Skip to main content

memstead_cli/commands/
quickstart.rs

1//! `memstead quickstart` — the batteries-included cold start.
2//!
3//! One run in a fresh (or trivially-dirty) directory leaves: a bootable
4//! filesystem-mem workspace pinned to the default schema, one seed
5//! entity so the graph is non-empty, and the MCP wiring for the
6//! selected agent targets. Output names each artifact plus the single
7//! next action.
8//!
9//! Contract split against `memstead init`: `init` is the deliberate,
10//! script-safe verb — exact pins, strict emptiness, no side effects
11//! beyond `.memstead/`. `quickstart` is the newcomer verb — it derives
12//! the mem name from the directory, tolerates dotfiles and
13//! README-grade files, and writes agent config. It composes the same
14//! engine primitives (`init_filesystem_mem`, `Engine::create_entity`)
15//! rather than forking a second init path; the write-validation
16//! strictness downstream of the doorway is untouched.
17//!
18//! Interactivity ceiling: two prompts, both TTY-only, both with a flag
19//! alternative — the agent-target selection (`--agent` bypasses) and
20//! the mem name when derivation from the directory fails (`--name`
21//! bypasses). Non-interactive runs never block: no `--agent` defaults
22//! to Claude Code (and says so), an underivable name refuses with the
23//! exact command to run instead.
24
25use std::io::{IsTerminal, Write as _};
26use std::path::{Path, PathBuf};
27
28use clap::{Args as ClapArgs, ValueEnum};
29use memstead_base::filesystem::config::{config_path, init_filesystem_mem, validate_mem_name};
30use memstead_base::vcs::Actor;
31use memstead_base::{CreateEntityArgs, Engine as BaseEngine};
32use serde_json::json;
33
34use crate::CliError;
35use crate::output::{ExitKind, print_json, print_markdown};
36use crate::setup::{CliContext, memstead_program, shell_quote};
37
38use super::init::find_ancestor_workspace;
39
40/// `memstead quickstart` arguments.
41#[derive(ClapArgs, Debug)]
42pub struct Args {
43    /// Target folder. Defaults to the current working directory.
44    #[arg(value_name = "PATH")]
45    pub path: Option<PathBuf>,
46
47    /// Mem name. Normally derived from the directory name; pass this
48    /// when the derivation fails (or to override it). Slug-shaped:
49    /// `^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`.
50    #[arg(long)]
51    pub name: Option<String>,
52
53    /// Agent target(s) to write MCP wiring for. Repeatable. Skips the
54    /// interactive selection prompt. Without a TTY and without this
55    /// flag, quickstart defaults to `claude-code`.
56    #[arg(long = "agent", value_enum)]
57    pub agents: Vec<AgentTarget>,
58}
59
60/// The supported agent targets and the wiring each one gets. The three
61/// file-writing targets take project-scoped MCP config; Codex reads
62/// MCP servers only from its global `~/.codex/config.toml`, so its
63/// wiring is the exact `codex mcp add` command printed as the next
64/// action — quickstart never writes outside the target directory.
65#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
66pub enum AgentTarget {
67    /// Claude Code — project `.mcp.json`.
68    ClaudeCode,
69    /// OpenAI Codex — prints the `codex mcp add` one-liner (Codex has
70    /// no project-scoped MCP config file).
71    Codex,
72    /// Cursor — project `.cursor/mcp.json`.
73    Cursor,
74    /// Gemini CLI — project `.gemini/settings.json`.
75    Gemini,
76}
77
78impl AgentTarget {
79    fn label(self) -> &'static str {
80        match self {
81            AgentTarget::ClaudeCode => "Claude Code",
82            AgentTarget::Codex => "Codex",
83            AgentTarget::Cursor => "Cursor",
84            AgentTarget::Gemini => "Gemini CLI",
85        }
86    }
87
88    /// Project-relative MCP config file, or `None` for the
89    /// print-a-command target (Codex).
90    fn config_file(self) -> Option<&'static str> {
91        match self {
92            AgentTarget::ClaudeCode => Some(".mcp.json"),
93            AgentTarget::Cursor => Some(".cursor/mcp.json"),
94            AgentTarget::Gemini => Some(".gemini/settings.json"),
95            AgentTarget::Codex => None,
96        }
97    }
98
99    const ALL: [AgentTarget; 4] = [
100        AgentTarget::ClaudeCode,
101        AgentTarget::Codex,
102        AgentTarget::Cursor,
103        AgentTarget::Gemini,
104    ];
105}
106
107/// One wiring outcome per selected target, for the report.
108struct WiringOutcome {
109    target: AgentTarget,
110    /// What happened, as a report line fragment.
111    action: String,
112}
113
114pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
115    let target = args
116        .path
117        .clone()
118        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
119
120    if target.exists() && !target.is_dir() {
121        return Err(CliError::new(
122            ExitKind::Validation,
123            "INVALID_INPUT",
124            format!(
125                "target {} exists but is not a directory — point at a folder: \
126                 memstead quickstart my-graph",
127                target.display(),
128            ),
129        )
130        .into());
131    }
132    if !target.exists() {
133        std::fs::create_dir_all(&target).map_err(|e| {
134            CliError::new(
135                ExitKind::Generic,
136                crate::INTERNAL_CODE,
137                format!(
138                    "failed to create target directory {}: {e}",
139                    target.display()
140                ),
141            )
142        })?;
143    }
144
145    // Conflict gate 1: the target itself already carries `.memstead/`.
146    check_no_local_memstead(&target)?;
147
148    // Conflict gate 2: never nest inside an existing workspace — same
149    // rule and walker as `memstead init`. The alternatives named here
150    // must be viable in the workspaces quickstart itself creates
151    // (filesystem-shaped, no mem-lifecycle allowlist), so the message
152    // points at working in the existing workspace or starting a
153    // separate one — never at `memstead mem init`, which refuses on
154    // both counts there.
155    if let Some(found_at) = find_ancestor_workspace(&target)? {
156        return Err(CliError::new(
157            ExitKind::Validation,
158            crate::WORKSPACE_ALREADY_EXISTS_ABOVE_CODE,
159            format!(
160                "{} is already inside the memstead workspace at {} — quickstart \
161                 refuses to nest workspaces. Work in that workspace (memstead \
162                 overview), or start a separate graph outside it: mkdir my-graph && \
163                 cd my-graph && memstead quickstart",
164                target.display(),
165                found_at.display(),
166            ),
167        )
168        .with_details(json!({ "found_at": found_at.display().to_string() }))
169        .into());
170    }
171
172    // Conflict gate 3: tolerant emptiness. Dotfiles and non-`.md`
173    // README-grade files are fine — the folder backend only reads `.md`
174    // files, so they can never leak into the graph. Anything else is a
175    // genuine conflict named in full; `.md` files especially, because a
176    // filesystem mem owns every `.md` file in its folder and quickstart
177    // must never silently adopt user content into the graph.
178    let blocking = blocking_entries(&target)?;
179    if !blocking.is_empty() {
180        let md_note = if blocking.iter().any(|f| f.ends_with(".md`")) {
181            " (a filesystem mem owns every `.md` file in its folder, so quickstart \
182             would silently adopt them into the graph)"
183        } else {
184            ""
185        };
186        return Err(CliError::new(
187            ExitKind::Validation,
188            crate::TARGET_NOT_EMPTY_CODE,
189            format!(
190                "target {} has content quickstart won't touch: {}{md_note} — move it \
191                 out, or start in a fresh folder: mkdir my-graph && cd my-graph && \
192                 memstead quickstart",
193                target.display(),
194                blocking.join(", "),
195            ),
196        )
197        .with_details(json!({
198            "path": target.display().to_string(),
199            "found": blocking,
200        }))
201        .into());
202    }
203
204    // Mem name: flag > derivation from the directory > TTY prompt >
205    // refusal carrying the exact command.
206    let name = resolve_mem_name(&target, args.name.as_deref())?;
207
208    // Agent targets: flag > TTY prompt > default (Claude Code, stated).
209    let (agents, agents_defaulted) = resolve_agents(&args.agents)?;
210
211    // Preflight every selected agent's existing config file BEFORE any
212    // write lands: a malformed `.mcp.json` must refuse while "re-run
213    // memstead quickstart" is still true — discovering it after the
214    // workspace exists would leave a half-bootstrapped directory and a
215    // printed retry command that can no longer succeed.
216    for agent in &agents {
217        if let Some(rel) = agent.config_file() {
218            read_agent_config(&target.join(rel))?;
219        }
220    }
221
222    // Schema pin: the current default builtin, resolved by name so the
223    // printed pin tracks the catalogue instead of a hardcoded version.
224    let schema_pin = default_schema_pin()?;
225
226    // Workspace + config through the same shared initialiser `memstead
227    // init` uses — one code path, byte-identical output.
228    init_filesystem_mem(&target, &name, &schema_pin).map_err(|e| {
229        CliError::new(
230            ExitKind::Generic,
231            crate::INTERNAL_CODE,
232            format!("initialise filesystem mem: {e}"),
233        )
234    })?;
235
236    // Seed entity, through the engine's validated create path.
237    let seed_id = seed_entity(&target, &name)?;
238
239    // MCP wiring per selected target.
240    let mcp_bin = resolve_mcp_binary();
241    let mut wirings = Vec::with_capacity(agents.len());
242    for agent in &agents {
243        wirings.push(wire_agent(&target, *agent, &mcp_bin.command)?);
244    }
245
246    report(
247        ctx,
248        &target,
249        &name,
250        &schema_pin,
251        &seed_id,
252        &wirings,
253        agents_defaulted,
254        &mcp_bin,
255    )
256}
257
258/// Refuse when the target already carries `.memstead/` — either a
259/// finished workspace (point at the next command, don't re-initialise)
260/// or a foreign/partial `.memstead/` directory quickstart must not
261/// adopt or overwrite.
262fn check_no_local_memstead(target: &Path) -> anyhow::Result<()> {
263    let store = target.join(memstead_base::WORKSPACE_STORE_DIR);
264    if !store.exists() {
265        return Ok(());
266    }
267    if memstead_base::is_workspace_root(target) {
268        return Err(CliError::new(
269            ExitKind::Validation,
270            "WORKSPACE_ALREADY_INITIALISED",
271            format!(
272                "{} is already a Memstead workspace — nothing to bootstrap. \
273                 Inspect it with: memstead overview",
274                target.display(),
275            ),
276        )
277        .with_details(json!({ "path": target.display().to_string() }))
278        .into());
279    }
280    Err(CliError::new(
281        ExitKind::Validation,
282        "FOREIGN_MEMSTEAD_DIR",
283        format!(
284            "{} contains a `.memstead/` directory that is not a workspace \
285             (no workspace.toml) — quickstart won't adopt or overwrite it. \
286             Move it aside, or start fresh: mkdir my-graph && cd my-graph && \
287             memstead quickstart",
288            target.display(),
289        ),
290    )
291    .with_details(json!({ "path": store.display().to_string() }))
292    .into())
293}
294
295/// Directory entries that block quickstart. Tolerated: dotfiles
296/// (`.git`, `.gitignore`, `.mcp.json`, editor config, …) and non-`.md`
297/// README-grade files (README, LICENSE.txt, …). Every `.md` file blocks
298/// — including `README.md` — because the folder backend treats each
299/// `.md` in the mem folder as an entity, and silently adopting user
300/// content into the graph is the one thing quickstart must never do.
301/// `.memstead` is handled earlier by [`check_no_local_memstead`].
302fn blocking_entries(target: &Path) -> anyhow::Result<Vec<String>> {
303    let read_err = |e: std::io::Error| {
304        CliError::new(
305            ExitKind::Generic,
306            crate::INTERNAL_CODE,
307            format!("read target {}: {e}", target.display()),
308        )
309    };
310    let mut blocking = Vec::new();
311    for entry in std::fs::read_dir(target).map_err(read_err)? {
312        let entry = entry.map_err(read_err)?;
313        let name = entry.file_name().to_string_lossy().to_string();
314        if name.starts_with('.') {
315            continue;
316        }
317        let lower = name.to_lowercase();
318        let readme_grade = lower.starts_with("readme")
319            || lower.starts_with("license")
320            || lower.starts_with("licence");
321        if readme_grade && !lower.ends_with(".md") {
322            continue;
323        }
324        blocking.push(format!("`{name}`"));
325    }
326    blocking.sort();
327    Ok(blocking)
328}
329
330/// Resolve the mem name: `--name` wins, then slug derivation from the
331/// directory basename, then (TTY only) one prompt, else a refusal
332/// carrying the exact retry command.
333fn resolve_mem_name(target: &Path, flag: Option<&str>) -> anyhow::Result<String> {
334    if let Some(name) = flag {
335        validate_mem_name(name).map_err(|e| {
336            CliError::new(
337                ExitKind::Validation,
338                "INVALID_INPUT",
339                format!(
340                    "invalid --name: {e}. Retry with a slug, e.g.: memstead quickstart \
341                     --name {}",
342                    derive_mem_name(name).unwrap_or_else(|| "my-graph".to_string()),
343                ),
344            )
345        })?;
346        return Ok(name.to_string());
347    }
348    let basename = std::fs::canonicalize(target)
349        .ok()
350        .and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string()))
351        .unwrap_or_default();
352    if let Some(derived) = derive_mem_name(&basename) {
353        return Ok(derived);
354    }
355    if std::io::stdin().is_terminal() {
356        let answer = prompt_line(&format!(
357            "Could not derive a mem name from `{basename}`. Mem name (lowercase letters, digits, hyphens): ",
358        ))?;
359        let answer = answer.trim();
360        validate_mem_name(answer).map_err(|e| {
361            CliError::new(
362                ExitKind::Validation,
363                "INVALID_INPUT",
364                format!("invalid mem name: {e}. Retry with: memstead quickstart --name my-graph"),
365            )
366        })?;
367        return Ok(answer.to_string());
368    }
369    Err(CliError::new(
370        ExitKind::Validation,
371        "INVALID_INPUT",
372        format!(
373            "could not derive a mem name from directory `{basename}` — \
374             pass one explicitly: memstead quickstart --name my-graph",
375        ),
376    )
377    .with_details(json!({ "directory": basename }))
378    .into())
379}
380
381/// Slug-derive a mem name from a directory basename: lowercase,
382/// non-alphanumerics to hyphens, runs collapsed, edges trimmed, capped
383/// at the 64-char rule. `None` when nothing valid survives.
384fn derive_mem_name(basename: &str) -> Option<String> {
385    let mut out = String::with_capacity(basename.len());
386    for c in basename.to_lowercase().chars() {
387        if c.is_ascii_lowercase() || c.is_ascii_digit() {
388            out.push(c);
389        } else if !out.is_empty() && !out.ends_with('-') {
390            out.push('-');
391        }
392    }
393    let mut slug: String = out.trim_matches('-').chars().take(64).collect();
394    slug = slug.trim_matches('-').to_string();
395    validate_mem_name(&slug).ok().map(|()| slug)
396}
397
398/// Resolve the agent-target list. Returns the targets plus whether the
399/// non-interactive Claude Code default was applied (the report states
400/// it, so a scripted run knows the choice was made for it).
401fn resolve_agents(flag: &[AgentTarget]) -> anyhow::Result<(Vec<AgentTarget>, bool)> {
402    if !flag.is_empty() {
403        let mut seen = Vec::with_capacity(flag.len());
404        for a in flag {
405            if !seen.contains(a) {
406                seen.push(*a);
407            }
408        }
409        return Ok((seen, false));
410    }
411    if std::io::stdin().is_terminal() {
412        return Ok((prompt_agents()?, false));
413    }
414    Ok((vec![AgentTarget::ClaudeCode], true))
415}
416
417/// The one interactive agent-target prompt. Empty answer means Claude
418/// Code; otherwise comma-separated numbers from the printed list.
419fn prompt_agents() -> anyhow::Result<Vec<AgentTarget>> {
420    let menu: Vec<String> = AgentTarget::ALL
421        .iter()
422        .enumerate()
423        .map(|(i, a)| format!("  {}) {}", i + 1, a.label()))
424        .collect();
425    let answer = prompt_line(&format!(
426        "Which agents should connect to this mem? (comma-separated, Enter = Claude Code)\n{}\n> ",
427        menu.join("\n"),
428    ))?;
429    let answer = answer.trim();
430    if answer.is_empty() {
431        return Ok(vec![AgentTarget::ClaudeCode]);
432    }
433    let mut selected = Vec::new();
434    for token in answer.split(',') {
435        let token = token.trim();
436        let picked = match token.parse::<usize>() {
437            Ok(n) if (1..=AgentTarget::ALL.len()).contains(&n) => AgentTarget::ALL[n - 1],
438            _ => {
439                return Err(CliError::new(
440                    ExitKind::Validation,
441                    "INVALID_INPUT",
442                    format!(
443                        "unrecognised selection `{token}` — expected numbers 1-{max} \
444                         (comma-separated). Skip the prompt with: memstead quickstart \
445                         --agent claude-code --agent cursor",
446                        max = AgentTarget::ALL.len(),
447                    ),
448                )
449                .into());
450            }
451        };
452        if !selected.contains(&picked) {
453            selected.push(picked);
454        }
455    }
456    Ok(selected)
457}
458
459/// Print `msg` to stderr (stdout carries the command's report) and read
460/// one line from stdin.
461fn prompt_line(msg: &str) -> anyhow::Result<String> {
462    let mut stderr = std::io::stderr();
463    stderr.write_all(msg.as_bytes()).ok();
464    stderr.flush().ok();
465    let mut line = String::new();
466    std::io::stdin().read_line(&mut line).map_err(|e| {
467        CliError::new(
468            ExitKind::Generic,
469            crate::INTERNAL_CODE,
470            format!("read answer from stdin: {e}"),
471        )
472    })?;
473    Ok(line)
474}
475
476/// Resolve the default builtin schema to its concrete pin — the
477/// current generation (1.3.0, the required-opt-in metadata-polarity
478/// generation), so fresh workspaces never start on a superseded
479/// vocabulary.
480fn default_schema_pin() -> anyhow::Result<memstead_schema::SchemaRef> {
481    let reg = memstead_schema::SchemaRegistry::builtin();
482    match reg.get("default", &semver::Version::new(1, 3, 0)) {
483        Some(schema) => {
484            let (name, version) = schema.id();
485            Ok(memstead_schema::SchemaRef::new(name, version))
486        }
487        _ => Err(CliError::new(
488            ExitKind::Generic,
489            crate::INTERNAL_CODE,
490            "builtin schema catalogue has no `default` schema — this binary is broken, please report",
491        )
492        .into()),
493    }
494}
495
496/// Create the seed entity through the engine's validated create path,
497/// so the very first entity in the graph went through the same gate
498/// every later one will.
499fn seed_entity(target: &Path, mem: &str) -> anyhow::Result<String> {
500    let mut engine = BaseEngine::from_workspace_root(target).map_err(|e| {
501        CliError::new(
502            ExitKind::Generic,
503            crate::INTERNAL_CODE,
504            format!("boot engine at {}: {e:#}", target.display()),
505        )
506    })?;
507    let mut sections = indexmap::IndexMap::new();
508    sections.insert(
509        "definition".to_string(),
510        "This mem is a typed knowledge graph: markdown entities validated against a schema, \
511         connected by typed relationships."
512            .to_string(),
513    );
514    sections.insert(
515        "explanation".to_string(),
516        "`memstead quickstart` seeded this entity so the graph starts non-empty. Read it back \
517         with `memstead entity <id>`, list types with `memstead type`, create your own with \
518         `memstead create`, and delete this one any time with `memstead delete <id>`."
519            .to_string(),
520    );
521    let outcome = engine
522        .create_entity(
523            CreateEntityArgs {
524                anchors: Vec::new(),
525                mem: mem.to_string(),
526                title: "Welcome to Memstead".to_string(),
527                entity_type: "concept".to_string(),
528                sections,
529                metadata: indexmap::IndexMap::new(),
530                relations: Vec::new(),
531                dry_run: false,
532            },
533            Actor::Cli,
534            None,
535            Some("seeded by memstead quickstart"),
536        )
537        .map_err(CliError::from_engine_op)?;
538    Ok(outcome.id.as_ref().to_string())
539}
540
541/// The resolved `memstead-mcp` launch command plus a warning when the
542/// binary could not be found (the wiring is still written with the
543/// bare name so a later install fixes it without re-running).
544struct McpBinary {
545    command: String,
546    warning: Option<String>,
547}
548
549/// Resolve the `memstead-mcp` binary: sibling of the running `memstead`
550/// binary first (one install ships both), then `PATH`. Falls back to
551/// the bare name with a warning naming the install command.
552fn resolve_mcp_binary() -> McpBinary {
553    if let Ok(exe) = std::env::current_exe()
554        && let Some(dir) = exe.parent()
555    {
556        let sibling = dir.join("memstead-mcp");
557        if sibling.is_file() {
558            return McpBinary {
559                command: sibling.display().to_string(),
560                warning: None,
561            };
562        }
563    }
564    if let Some(paths) = std::env::var_os("PATH") {
565        for dir in std::env::split_paths(&paths) {
566            let candidate = dir.join("memstead-mcp");
567            if candidate.is_file() {
568                return McpBinary {
569                    command: candidate.display().to_string(),
570                    warning: None,
571                };
572            }
573        }
574    }
575    McpBinary {
576        command: "memstead-mcp".to_string(),
577        warning: Some(
578            "`memstead-mcp` was not found next to this binary or on PATH — the wiring uses the \
579             bare name and will work once it is installed (curl -sSf https://memstead.io/install.sh | sh)"
580                .to_string(),
581        ),
582    }
583}
584
585/// Read and shape-check an agent's existing MCP config file: must be
586/// valid JSON, a top-level object, with `mcpServers` absent or an
587/// object. A missing file is an empty object. Called once as a
588/// preflight before any write lands (so the refusal's "re-run
589/// memstead quickstart" stays true) and again by [`wire_agent`].
590fn read_agent_config(path: &Path) -> anyhow::Result<serde_json::Value> {
591    if !path.is_file() {
592        return Ok(json!({}));
593    }
594    let fix_hint = "fix or remove the file, then re-run: memstead quickstart";
595    let bytes = std::fs::read(path).map_err(|e| {
596        CliError::new(
597            ExitKind::Generic,
598            crate::INTERNAL_CODE,
599            format!("read {}: {e}", path.display()),
600        )
601    })?;
602    let root: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| {
603        CliError::new(
604            ExitKind::Validation,
605            "INVALID_INPUT",
606            format!(
607                "{} exists but is not valid JSON ({e}) — {fix_hint}",
608                path.display()
609            ),
610        )
611    })?;
612    if !root.is_object() {
613        return Err(CliError::new(
614            ExitKind::Validation,
615            "INVALID_INPUT",
616            format!(
617                "{} exists but its top level is not a JSON object — {fix_hint}",
618                path.display(),
619            ),
620        )
621        .into());
622    }
623    let servers = &root["mcpServers"];
624    if !servers.is_null() && !servers.is_object() {
625        return Err(CliError::new(
626            ExitKind::Validation,
627            "INVALID_INPUT",
628            format!(
629                "{}'s `mcpServers` is not a JSON object — {fix_hint}",
630                path.display(),
631            ),
632        )
633        .into());
634    }
635    Ok(root)
636}
637
638/// Write (or merge into) the target's MCP config for one agent. JSON
639/// configs get an `mcpServers.memstead` entry added, preserving every
640/// existing key; an existing `memstead` entry is never overwritten.
641/// Codex gets the exact `codex mcp add` command as its action line.
642fn wire_agent(
643    target: &Path,
644    agent: AgentTarget,
645    mcp_command: &str,
646) -> anyhow::Result<WiringOutcome> {
647    let Some(rel) = agent.config_file() else {
648        // Codex has no project config, so this command IS the wiring —
649        // it must survive an mcp path containing a space exactly as the
650        // verification commands must.
651        let add = ShellCmd::new("codex")
652            .arg("mcp")
653            .arg("add")
654            .arg("memstead")
655            .end_of_options()
656            .arg(mcp_command)
657            .render();
658        return Ok(WiringOutcome {
659            target: agent,
660            action: format!("run: `{add}`"),
661        });
662    };
663    let path = target.join(rel);
664    let mut root = read_agent_config(&path)?;
665
666    let servers = root
667        .as_object_mut()
668        .expect("read_agent_config only returns JSON objects")
669        .entry("mcpServers")
670        .or_insert_with(|| json!({}));
671    let servers = servers.as_object_mut().ok_or_else(|| {
672        CliError::new(
673            ExitKind::Validation,
674            "INVALID_INPUT",
675            format!(
676                "{}'s `mcpServers` is not a JSON object — fix or remove the file, then \
677                 re-run: memstead quickstart",
678                path.display(),
679            ),
680        )
681    })?;
682
683    if servers.contains_key("memstead") {
684        return Ok(WiringOutcome {
685            target: agent,
686            action: format!("`{rel}` already has a `memstead` server entry — left untouched"),
687        });
688    }
689    servers.insert("memstead".to_string(), json!({ "command": mcp_command }));
690
691    if let Some(parent) = path.parent() {
692        std::fs::create_dir_all(parent).map_err(|e| {
693            CliError::new(
694                ExitKind::Generic,
695                crate::INTERNAL_CODE,
696                format!("create {}: {e}", parent.display()),
697            )
698        })?;
699    }
700    let rendered = format!(
701        "{}\n",
702        serde_json::to_string_pretty(&root).unwrap_or_default()
703    );
704    std::fs::write(&path, rendered).map_err(|e| {
705        CliError::new(
706            ExitKind::Generic,
707            crate::INTERNAL_CODE,
708            format!("write {}: {e}", path.display()),
709        )
710    })?;
711    Ok(WiringOutcome {
712        target: agent,
713        action: format!("wrote `{rel}` (server `memstead`)"),
714    })
715}
716
717/// One command line the receipt prints for the reader to run.
718///
719/// Every printed command goes through this rather than through an ad-hoc
720/// `format!`, because each one needs the same three things and each was
721/// independently getting one of them wrong: the program resolved to
722/// something the reader can actually invoke, every argument shell-quoted,
723/// and a `cd` when the command must run inside the new workspace.
724///
725/// The `cd` uses the `--` terminator so a directory named `-graph`
726/// reaches `cd` as an operand instead of an option.
727/// One word of a command line: a value to be quoted, or shell syntax
728/// to emit as-is.
729enum Word {
730    Value(String),
731    Literal(&'static str),
732}
733
734struct ShellCmd {
735    /// `cd` here first. `None` runs wherever the reader is standing.
736    cd: Option<String>,
737    program: String,
738    args: Vec<Word>,
739}
740
741impl ShellCmd {
742    fn new(program: impl Into<String>) -> Self {
743        ShellCmd {
744            cd: None,
745            program: program.into(),
746            args: Vec::new(),
747        }
748    }
749
750    fn arg(mut self, arg: impl Into<String>) -> Self {
751        self.args.push(Word::Value(arg.into()));
752        self
753    }
754
755    /// The literal `--` end-of-options separator. Distinct from
756    /// [`Self::arg`] because it is syntax, not a value: quoting it
757    /// would be harmless to the shell but noise to the reader, and the
758    /// leading-dash rule that protects values must not fire on it.
759    fn end_of_options(mut self) -> Self {
760        self.args.push(Word::Literal("--"));
761        self
762    }
763
764    /// Prefix a `cd` into `dir` unless the reader is already there.
765    fn in_dir(mut self, dir: &Path, already_there: bool) -> Self {
766        if !already_there {
767            self.cd = Some(dir.display().to_string());
768        }
769        self
770    }
771
772    /// The runnable line. This is what both receipts print — the
773    /// markdown surface only adds its own bullet and backticks.
774    fn render(&self) -> String {
775        let mut out = String::new();
776        if let Some(dir) = &self.cd {
777            out.push_str(&format!("cd -- {} && ", shell_quote(dir)));
778        }
779        out.push_str(&shell_quote(&self.program));
780        for arg in &self.args {
781            out.push(' ');
782            match arg {
783                Word::Value(v) => out.push_str(&shell_quote(v)),
784                Word::Literal(l) => out.push_str(l),
785            }
786        }
787        out
788    }
789}
790
791/// Final report: every artifact by name, then the single next action.
792#[allow(clippy::too_many_arguments)]
793fn report(
794    ctx: &CliContext,
795    target: &Path,
796    name: &str,
797    schema_pin: &memstead_schema::SchemaRef,
798    seed_id: &str,
799    wirings: &[WiringOutcome],
800    agents_defaulted: bool,
801    mcp_bin: &McpBinary,
802) -> anyhow::Result<()> {
803    let restart_labels: Vec<&str> = wirings.iter().map(|w| w.target.label()).collect();
804
805    // Every command this receipt prints must run verbatim, from the
806    // directory the caller is actually standing in, with whatever
807    // characters their paths happen to contain. Each printed command is
808    // therefore built as a [`ShellCmd`] rather than formatted inline —
809    // three separate rounds of this receipt shipped a command that did
810    // not run, each time because one `format!` had been missed.
811    //
812    // A verification step the reader cannot reproduce is the same
813    // defect as an undisclosed shape, so this is not cosmetic.
814    let absolute = target
815        .canonicalize()
816        .unwrap_or_else(|_| target.to_path_buf());
817    let in_cwd = std::env::current_dir()
818        .ok()
819        .is_some_and(|cwd| cwd == absolute);
820    let memstead = memstead_program();
821    let overview_cmd = ShellCmd::new(&memstead)
822        .arg("overview")
823        .in_dir(target, in_cwd)
824        .render();
825    let delete_cmd = ShellCmd::new(&memstead)
826        .arg("delete")
827        .arg(seed_id)
828        .in_dir(target, in_cwd)
829        .render();
830    let version_cmd = ShellCmd::new(&mcp_bin.command).arg("--version").render();
831
832    // Codex is wired by a command the reader still has to run, so for
833    // that target the restart registers nothing until they run it. Say
834    // so in order rather than naming a restart that would no-op.
835    let codex_pending = wirings
836        .iter()
837        .any(|w| w.target == AgentTarget::Codex && w.action.starts_with("run:"));
838    let restart_clause = format!(
839        "Restart {} so the `memstead` MCP server registers its tools",
840        restart_labels.join(" / "),
841    );
842    let next_action = if codex_pending {
843        format!(
844            "Run the `codex mcp add` command above first — it is Codex's wiring, and a restart \
845             registers nothing without it. Then: {restart_clause} — then try: {overview_cmd}"
846        )
847    } else {
848        format!("{restart_clause} — then try: {overview_cmd}")
849    };
850    // …but an agent session that just ran onboarding cannot restart
851    // itself mid-run, so the wiring it wrote must be checkable from
852    // inside that session. Held as `{what, command}` pairs so the JSON
853    // surface ships runnable commands and the markdown surface adds its
854    // own bullet decoration — an agent should never have to strip
855    // backticks off a machine field.
856    let mut verify_now: Vec<(&str, String)> = Vec::new();
857    // Only claim the binary answers when we actually found one. In the
858    // not-found case the warning above already names the install
859    // command, and printing an unrunnable check under the heading "no
860    // restart needed" would be the exact defect this block exists to
861    // remove.
862    if mcp_bin.warning.is_none() {
863        verify_now.push(("the wired binary answers", version_cmd));
864    }
865    verify_now.push(("the graph is already readable", overview_cmd.clone()));
866
867    if ctx.json {
868        return print_json(&json!({
869            // Absolute, so a caller that passed a relative argument can
870            // use these without reconstructing its own cwd.
871            "workspace_root": absolute.display().to_string(),
872            "config_path": config_path(&absolute).display().to_string(),
873            "seed_entity_delete_command": delete_cmd,
874            "name": name,
875            "schema": schema_pin.as_display(),
876            "seed_entity": seed_id,
877            "mcp_command": mcp_bin.command,
878            "agents": wirings
879                .iter()
880                .map(|w| json!({
881                    "target": w.target.to_possible_value().map(|v| v.get_name().to_string()),
882                    "action": w.action,
883                }))
884                .collect::<Vec<_>>(),
885            "agents_defaulted": agents_defaulted,
886            "workspace_shape": crate::setup::WorkspaceShape::Filesystem.label(),
887            // The agent surface gets the whole disclosure, not just the
888            // label: which shape, what it cannot do, the command for
889            // the other one — the same three parts the markdown block
890            // carries, from the same value.
891            "workspace_shape_disclosure":
892                crate::setup::shape_disclosure(crate::setup::WorkspaceShape::Filesystem).to_json(),
893            "next_action": next_action,
894            "verify_now": verify_now
895                .iter()
896                .map(|(what, command)| json!({ "what": what, "command": command }))
897                .collect::<Vec<_>>(),
898            "warnings": mcp_bin.warning.as_ref().map(|w| vec![w.clone()]).unwrap_or_default(),
899        }));
900    }
901
902    let mut lines = vec![
903        format!("# Quickstart complete — mem `{name}`"),
904        String::new(),
905        format!("- Workspace:   `{}`", target.display()),
906        format!("- Schema pin:  `{}`", schema_pin.as_display()),
907        format!("- Seed entity: `{seed_id}` (remove any time: `{delete_cmd}`)"),
908    ];
909    for w in wirings {
910        lines.push(format!("- {}: {}", w.target.label(), w.action));
911    }
912    if agents_defaulted {
913        lines.push(
914            "- No `--agent` given and no terminal to ask — defaulted to Claude Code \
915             (re-run with `--agent` for others)"
916                .to_string(),
917        );
918    }
919    if let Some(warning) = &mcp_bin.warning {
920        lines.push(String::new());
921        lines.push(format!("> warning: {warning}"));
922    }
923    // The shape disclosure sits between the artifact list and the next
924    // action: quickstart picked one of two workspace shapes just now,
925    // and this receipt is the only output the newcomer is guaranteed
926    // to read before they hit the first mem-repo-only refusal.
927    lines.push(String::new());
928    lines.extend(crate::setup::shape_disclosure_lines(
929        crate::setup::WorkspaceShape::Filesystem,
930    ));
931    lines.push(String::new());
932    lines.push(format!("Next: {next_action}"));
933    lines.push(String::new());
934    lines.push("Verify from this session, no restart needed:".to_string());
935    lines.extend(
936        verify_now
937            .iter()
938            .map(|(what, command)| format!("- {what}: `{command}`")),
939    );
940    print_markdown(&lines.join("\n"));
941    Ok(())
942}
943
944#[cfg(test)]
945mod tests {
946    use super::*;
947
948    #[test]
949    fn derive_mem_name_handles_common_directory_names() {
950        assert_eq!(derive_mem_name("my-graph").as_deref(), Some("my-graph"));
951        assert_eq!(derive_mem_name("My Project").as_deref(), Some("my-project"));
952        assert_eq!(
953            derive_mem_name("Notes_2026 (v2)").as_deref(),
954            Some("notes-2026-v2")
955        );
956        // Nothing valid survives: prompt/refusal path.
957        assert_eq!(derive_mem_name("日本語"), None);
958        assert_eq!(derive_mem_name(""), None);
959        // Single char fails the two-char slug rule.
960        assert_eq!(derive_mem_name("a"), None);
961    }
962
963    #[test]
964    fn blocking_entries_tolerates_dotfiles_and_readme_grade() {
965        let tmp = tempfile::tempdir().unwrap();
966        for f in [".gitignore", ".mcp.json", "README", "LICENSE", "Readme.txt"] {
967            std::fs::write(tmp.path().join(f), b"x").unwrap();
968        }
969        std::fs::create_dir(tmp.path().join(".git")).unwrap();
970        assert!(blocking_entries(tmp.path()).unwrap().is_empty());
971
972        // A `.md` README blocks — the folder backend would adopt it as
973        // an entity, and quickstart never ingests user content.
974        std::fs::write(tmp.path().join("README.md"), b"# hi").unwrap();
975        assert_eq!(blocking_entries(tmp.path()).unwrap(), vec!["`README.md`"]);
976        std::fs::remove_file(tmp.path().join("README.md")).unwrap();
977
978        std::fs::write(tmp.path().join("main.rs"), b"fn main() {}").unwrap();
979        assert_eq!(blocking_entries(tmp.path()).unwrap(), vec!["`main.rs`"]);
980    }
981
982    #[test]
983    fn wire_agent_merges_and_never_overwrites() {
984        let tmp = tempfile::tempdir().unwrap();
985        // Fresh write.
986        let outcome = wire_agent(tmp.path(), AgentTarget::ClaudeCode, "/bin/memstead-mcp").unwrap();
987        assert!(outcome.action.contains("wrote"), "got: {}", outcome.action);
988        let parsed: serde_json::Value =
989            serde_json::from_slice(&std::fs::read(tmp.path().join(".mcp.json")).unwrap()).unwrap();
990        assert_eq!(
991            parsed["mcpServers"]["memstead"]["command"],
992            "/bin/memstead-mcp"
993        );
994
995        // Existing foreign server entries survive; existing `memstead`
996        // entry is never overwritten.
997        std::fs::write(
998            tmp.path().join(".mcp.json"),
999            serde_json::to_vec_pretty(&serde_json::json!({
1000                "mcpServers": {
1001                    "other": { "command": "/bin/other" },
1002                    "memstead": { "command": "/custom/memstead-mcp" },
1003                }
1004            }))
1005            .unwrap(),
1006        )
1007        .unwrap();
1008        let outcome = wire_agent(tmp.path(), AgentTarget::ClaudeCode, "/bin/memstead-mcp").unwrap();
1009        assert!(
1010            outcome.action.contains("left untouched"),
1011            "got: {}",
1012            outcome.action
1013        );
1014        let parsed: serde_json::Value =
1015            serde_json::from_slice(&std::fs::read(tmp.path().join(".mcp.json")).unwrap()).unwrap();
1016        assert_eq!(
1017            parsed["mcpServers"]["memstead"]["command"],
1018            "/custom/memstead-mcp"
1019        );
1020        assert_eq!(parsed["mcpServers"]["other"]["command"], "/bin/other");
1021    }
1022
1023    #[test]
1024    fn shell_quote_leaves_ordinary_paths_alone_and_quotes_the_rest() {
1025        assert_eq!(
1026            shell_quote("/usr/local/bin/memstead-mcp"),
1027            "/usr/local/bin/memstead-mcp"
1028        );
1029        assert_eq!(shell_quote("my-graph"), "my-graph");
1030        // The case that motivated this: a directory name with a space.
1031        assert_eq!(shell_quote("My Graph"), "'My Graph'");
1032        assert_eq!(
1033            shell_quote("/Users/a b/bin/memstead-mcp"),
1034            "'/Users/a b/bin/memstead-mcp'"
1035        );
1036        // Shell metacharacters are contained, not executed.
1037        assert_eq!(shell_quote("a;rm -rf /"), "'a;rm -rf /'");
1038        assert_eq!(shell_quote("$(whoami)"), "'$(whoami)'");
1039        // An embedded single quote closes, escapes, and reopens.
1040        assert_eq!(shell_quote("it's"), r"'it'\''s'");
1041        assert_eq!(shell_quote(""), "''");
1042    }
1043
1044    #[test]
1045    fn wire_agent_codex_prints_command_writes_nothing() {
1046        let tmp = tempfile::tempdir().unwrap();
1047        let outcome = wire_agent(tmp.path(), AgentTarget::Codex, "/bin/memstead-mcp").unwrap();
1048        assert!(
1049            outcome
1050                .action
1051                .contains("codex mcp add memstead -- /bin/memstead-mcp"),
1052            "got: {}",
1053            outcome.action,
1054        );
1055        assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0);
1056    }
1057}