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;
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.
477fn default_schema_pin() -> anyhow::Result<memstead_schema::SchemaRef> {
478    let reg = memstead_schema::SchemaRegistry::builtin();
479    match reg.resolve_by_name("default") {
480        Ok(Some(schema)) => {
481            let (name, version) = schema.id();
482            Ok(memstead_schema::SchemaRef::new(name, version))
483        }
484        _ => Err(CliError::new(
485            ExitKind::Generic,
486            crate::INTERNAL_CODE,
487            "builtin schema catalogue has no `default` schema — this binary is broken, please report",
488        )
489        .into()),
490    }
491}
492
493/// Create the seed entity through the engine's validated create path,
494/// so the very first entity in the graph went through the same gate
495/// every later one will.
496fn seed_entity(target: &Path, mem: &str) -> anyhow::Result<String> {
497    let mut engine = BaseEngine::from_workspace_root(target).map_err(|e| {
498        CliError::new(
499            ExitKind::Generic,
500            crate::INTERNAL_CODE,
501            format!("boot engine at {}: {e:#}", target.display()),
502        )
503    })?;
504    let mut sections = indexmap::IndexMap::new();
505    sections.insert(
506        "definition".to_string(),
507        "This mem is a typed knowledge graph: markdown entities validated against a schema, \
508         connected by typed relationships."
509            .to_string(),
510    );
511    sections.insert(
512        "explanation".to_string(),
513        "`memstead quickstart` seeded this entity so the graph starts non-empty. Read it back \
514         with `memstead entity <id>`, list types with `memstead type`, create your own with \
515         `memstead create`, and delete this one any time with `memstead delete <id>`."
516            .to_string(),
517    );
518    let outcome = engine
519        .create_entity(
520            CreateEntityArgs {
521                anchors: Vec::new(),
522                mem: mem.to_string(),
523                title: "Welcome to Memstead".to_string(),
524                entity_type: "concept".to_string(),
525                sections,
526                metadata: indexmap::IndexMap::new(),
527                relations: Vec::new(),
528                dry_run: false,
529            },
530            Actor::Cli,
531            None,
532            Some("seeded by memstead quickstart"),
533        )
534        .map_err(CliError::from_engine_op)?;
535    Ok(outcome.id.as_ref().to_string())
536}
537
538/// The resolved `memstead-mcp` launch command plus a warning when the
539/// binary could not be found (the wiring is still written with the
540/// bare name so a later install fixes it without re-running).
541struct McpBinary {
542    command: String,
543    warning: Option<String>,
544}
545
546/// Resolve the `memstead-mcp` binary: sibling of the running `memstead`
547/// binary first (one install ships both), then `PATH`. Falls back to
548/// the bare name with a warning naming the install command.
549fn resolve_mcp_binary() -> McpBinary {
550    if let Ok(exe) = std::env::current_exe()
551        && let Some(dir) = exe.parent()
552    {
553        let sibling = dir.join("memstead-mcp");
554        if sibling.is_file() {
555            return McpBinary {
556                command: sibling.display().to_string(),
557                warning: None,
558            };
559        }
560    }
561    if let Some(paths) = std::env::var_os("PATH") {
562        for dir in std::env::split_paths(&paths) {
563            let candidate = dir.join("memstead-mcp");
564            if candidate.is_file() {
565                return McpBinary {
566                    command: candidate.display().to_string(),
567                    warning: None,
568                };
569            }
570        }
571    }
572    McpBinary {
573        command: "memstead-mcp".to_string(),
574        warning: Some(
575            "`memstead-mcp` was not found next to this binary or on PATH — the wiring uses the \
576             bare name and will work once it is installed (curl -sSf https://memstead.io/install.sh | sh)"
577                .to_string(),
578        ),
579    }
580}
581
582/// Read and shape-check an agent's existing MCP config file: must be
583/// valid JSON, a top-level object, with `mcpServers` absent or an
584/// object. A missing file is an empty object. Called once as a
585/// preflight before any write lands (so the refusal's "re-run
586/// memstead quickstart" stays true) and again by [`wire_agent`].
587fn read_agent_config(path: &Path) -> anyhow::Result<serde_json::Value> {
588    if !path.is_file() {
589        return Ok(json!({}));
590    }
591    let fix_hint = "fix or remove the file, then re-run: memstead quickstart";
592    let bytes = std::fs::read(path).map_err(|e| {
593        CliError::new(
594            ExitKind::Generic,
595            crate::INTERNAL_CODE,
596            format!("read {}: {e}", path.display()),
597        )
598    })?;
599    let root: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| {
600        CliError::new(
601            ExitKind::Validation,
602            "INVALID_INPUT",
603            format!(
604                "{} exists but is not valid JSON ({e}) — {fix_hint}",
605                path.display()
606            ),
607        )
608    })?;
609    if !root.is_object() {
610        return Err(CliError::new(
611            ExitKind::Validation,
612            "INVALID_INPUT",
613            format!(
614                "{} exists but its top level is not a JSON object — {fix_hint}",
615                path.display(),
616            ),
617        )
618        .into());
619    }
620    let servers = &root["mcpServers"];
621    if !servers.is_null() && !servers.is_object() {
622        return Err(CliError::new(
623            ExitKind::Validation,
624            "INVALID_INPUT",
625            format!(
626                "{}'s `mcpServers` is not a JSON object — {fix_hint}",
627                path.display(),
628            ),
629        )
630        .into());
631    }
632    Ok(root)
633}
634
635/// Write (or merge into) the target's MCP config for one agent. JSON
636/// configs get an `mcpServers.memstead` entry added, preserving every
637/// existing key; an existing `memstead` entry is never overwritten.
638/// Codex gets the exact `codex mcp add` command as its action line.
639fn wire_agent(
640    target: &Path,
641    agent: AgentTarget,
642    mcp_command: &str,
643) -> anyhow::Result<WiringOutcome> {
644    let Some(rel) = agent.config_file() else {
645        return Ok(WiringOutcome {
646            target: agent,
647            action: format!("run: `codex mcp add memstead -- {mcp_command}`"),
648        });
649    };
650    let path = target.join(rel);
651    let mut root = read_agent_config(&path)?;
652
653    let servers = root
654        .as_object_mut()
655        .expect("read_agent_config only returns JSON objects")
656        .entry("mcpServers")
657        .or_insert_with(|| json!({}));
658    let servers = servers.as_object_mut().ok_or_else(|| {
659        CliError::new(
660            ExitKind::Validation,
661            "INVALID_INPUT",
662            format!(
663                "{}'s `mcpServers` is not a JSON object — fix or remove the file, then \
664                 re-run: memstead quickstart",
665                path.display(),
666            ),
667        )
668    })?;
669
670    if servers.contains_key("memstead") {
671        return Ok(WiringOutcome {
672            target: agent,
673            action: format!("`{rel}` already has a `memstead` server entry — left untouched"),
674        });
675    }
676    servers.insert("memstead".to_string(), json!({ "command": mcp_command }));
677
678    if let Some(parent) = path.parent() {
679        std::fs::create_dir_all(parent).map_err(|e| {
680            CliError::new(
681                ExitKind::Generic,
682                crate::INTERNAL_CODE,
683                format!("create {}: {e}", parent.display()),
684            )
685        })?;
686    }
687    let rendered = format!(
688        "{}\n",
689        serde_json::to_string_pretty(&root).unwrap_or_default()
690    );
691    std::fs::write(&path, rendered).map_err(|e| {
692        CliError::new(
693            ExitKind::Generic,
694            crate::INTERNAL_CODE,
695            format!("write {}: {e}", path.display()),
696        )
697    })?;
698    Ok(WiringOutcome {
699        target: agent,
700        action: format!("wrote `{rel}` (server `memstead`)"),
701    })
702}
703
704/// Final report: every artifact by name, then the single next action.
705#[allow(clippy::too_many_arguments)]
706fn report(
707    ctx: &CliContext,
708    target: &Path,
709    name: &str,
710    schema_pin: &memstead_schema::SchemaRef,
711    seed_id: &str,
712    wirings: &[WiringOutcome],
713    agents_defaulted: bool,
714    mcp_bin: &McpBinary,
715) -> anyhow::Result<()> {
716    let restart_labels: Vec<&str> = wirings.iter().map(|w| w.target.label()).collect();
717    let next_action = format!(
718        "Restart {} so the `memstead` MCP server registers — then try: memstead overview",
719        restart_labels.join(" / "),
720    );
721
722    if ctx.json {
723        return print_json(&json!({
724            "workspace_root": target.display().to_string(),
725            "config_path": config_path(target).display().to_string(),
726            "name": name,
727            "schema": schema_pin.as_display(),
728            "seed_entity": seed_id,
729            "mcp_command": mcp_bin.command,
730            "agents": wirings
731                .iter()
732                .map(|w| json!({
733                    "target": w.target.to_possible_value().map(|v| v.get_name().to_string()),
734                    "action": w.action,
735                }))
736                .collect::<Vec<_>>(),
737            "agents_defaulted": agents_defaulted,
738            "next_action": next_action,
739            "warnings": mcp_bin.warning.as_ref().map(|w| vec![w.clone()]).unwrap_or_default(),
740        }));
741    }
742
743    let mut lines = vec![
744        format!("# Quickstart complete — mem `{name}`"),
745        String::new(),
746        format!("- Workspace:   `{}`", target.display()),
747        format!("- Schema pin:  `{}`", schema_pin.as_display()),
748        format!("- Seed entity: `{seed_id}` (remove any time: `memstead delete {seed_id}`)"),
749    ];
750    for w in wirings {
751        lines.push(format!("- {}: {}", w.target.label(), w.action));
752    }
753    if agents_defaulted {
754        lines.push(
755            "- No `--agent` given and no terminal to ask — defaulted to Claude Code \
756             (re-run with `--agent` for others)"
757                .to_string(),
758        );
759    }
760    if let Some(warning) = &mcp_bin.warning {
761        lines.push(String::new());
762        lines.push(format!("> warning: {warning}"));
763    }
764    lines.push(String::new());
765    lines.push(format!("Next: {next_action}"));
766    print_markdown(&lines.join("\n"));
767    Ok(())
768}
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773
774    #[test]
775    fn derive_mem_name_handles_common_directory_names() {
776        assert_eq!(derive_mem_name("my-graph").as_deref(), Some("my-graph"));
777        assert_eq!(derive_mem_name("My Project").as_deref(), Some("my-project"));
778        assert_eq!(
779            derive_mem_name("Notes_2026 (v2)").as_deref(),
780            Some("notes-2026-v2")
781        );
782        // Nothing valid survives: prompt/refusal path.
783        assert_eq!(derive_mem_name("日本語"), None);
784        assert_eq!(derive_mem_name(""), None);
785        // Single char fails the two-char slug rule.
786        assert_eq!(derive_mem_name("a"), None);
787    }
788
789    #[test]
790    fn blocking_entries_tolerates_dotfiles_and_readme_grade() {
791        let tmp = tempfile::tempdir().unwrap();
792        for f in [".gitignore", ".mcp.json", "README", "LICENSE", "Readme.txt"] {
793            std::fs::write(tmp.path().join(f), b"x").unwrap();
794        }
795        std::fs::create_dir(tmp.path().join(".git")).unwrap();
796        assert!(blocking_entries(tmp.path()).unwrap().is_empty());
797
798        // A `.md` README blocks — the folder backend would adopt it as
799        // an entity, and quickstart never ingests user content.
800        std::fs::write(tmp.path().join("README.md"), b"# hi").unwrap();
801        assert_eq!(blocking_entries(tmp.path()).unwrap(), vec!["`README.md`"]);
802        std::fs::remove_file(tmp.path().join("README.md")).unwrap();
803
804        std::fs::write(tmp.path().join("main.rs"), b"fn main() {}").unwrap();
805        assert_eq!(blocking_entries(tmp.path()).unwrap(), vec!["`main.rs`"]);
806    }
807
808    #[test]
809    fn wire_agent_merges_and_never_overwrites() {
810        let tmp = tempfile::tempdir().unwrap();
811        // Fresh write.
812        let outcome = wire_agent(tmp.path(), AgentTarget::ClaudeCode, "/bin/memstead-mcp").unwrap();
813        assert!(outcome.action.contains("wrote"), "got: {}", outcome.action);
814        let parsed: serde_json::Value =
815            serde_json::from_slice(&std::fs::read(tmp.path().join(".mcp.json")).unwrap()).unwrap();
816        assert_eq!(
817            parsed["mcpServers"]["memstead"]["command"],
818            "/bin/memstead-mcp"
819        );
820
821        // Existing foreign server entries survive; existing `memstead`
822        // entry is never overwritten.
823        std::fs::write(
824            tmp.path().join(".mcp.json"),
825            serde_json::to_vec_pretty(&serde_json::json!({
826                "mcpServers": {
827                    "other": { "command": "/bin/other" },
828                    "memstead": { "command": "/custom/memstead-mcp" },
829                }
830            }))
831            .unwrap(),
832        )
833        .unwrap();
834        let outcome = wire_agent(tmp.path(), AgentTarget::ClaudeCode, "/bin/memstead-mcp").unwrap();
835        assert!(
836            outcome.action.contains("left untouched"),
837            "got: {}",
838            outcome.action
839        );
840        let parsed: serde_json::Value =
841            serde_json::from_slice(&std::fs::read(tmp.path().join(".mcp.json")).unwrap()).unwrap();
842        assert_eq!(
843            parsed["mcpServers"]["memstead"]["command"],
844            "/custom/memstead-mcp"
845        );
846        assert_eq!(parsed["mcpServers"]["other"]["command"], "/bin/other");
847    }
848
849    #[test]
850    fn wire_agent_codex_prints_command_writes_nothing() {
851        let tmp = tempfile::tempdir().unwrap();
852        let outcome = wire_agent(tmp.path(), AgentTarget::Codex, "/bin/memstead-mcp").unwrap();
853        assert!(
854            outcome
855                .action
856                .contains("codex mcp add memstead -- /bin/memstead-mcp"),
857            "got: {}",
858            outcome.action,
859        );
860        assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0);
861    }
862}