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