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 — 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        return Ok(WiringOutcome {
649            target: agent,
650            action: format!("run: `codex mcp add memstead -- {mcp_command}`"),
651        });
652    };
653    let path = target.join(rel);
654    let mut root = read_agent_config(&path)?;
655
656    let servers = root
657        .as_object_mut()
658        .expect("read_agent_config only returns JSON objects")
659        .entry("mcpServers")
660        .or_insert_with(|| json!({}));
661    let servers = servers.as_object_mut().ok_or_else(|| {
662        CliError::new(
663            ExitKind::Validation,
664            "INVALID_INPUT",
665            format!(
666                "{}'s `mcpServers` is not a JSON object — fix or remove the file, then \
667                 re-run: memstead quickstart",
668                path.display(),
669            ),
670        )
671    })?;
672
673    if servers.contains_key("memstead") {
674        return Ok(WiringOutcome {
675            target: agent,
676            action: format!("`{rel}` already has a `memstead` server entry — left untouched"),
677        });
678    }
679    servers.insert("memstead".to_string(), json!({ "command": mcp_command }));
680
681    if let Some(parent) = path.parent() {
682        std::fs::create_dir_all(parent).map_err(|e| {
683            CliError::new(
684                ExitKind::Generic,
685                crate::INTERNAL_CODE,
686                format!("create {}: {e}", parent.display()),
687            )
688        })?;
689    }
690    let rendered = format!(
691        "{}\n",
692        serde_json::to_string_pretty(&root).unwrap_or_default()
693    );
694    std::fs::write(&path, rendered).map_err(|e| {
695        CliError::new(
696            ExitKind::Generic,
697            crate::INTERNAL_CODE,
698            format!("write {}: {e}", path.display()),
699        )
700    })?;
701    Ok(WiringOutcome {
702        target: agent,
703        action: format!("wrote `{rel}` (server `memstead`)"),
704    })
705}
706
707/// Final report: every artifact by name, then the single next action.
708#[allow(clippy::too_many_arguments)]
709fn report(
710    ctx: &CliContext,
711    target: &Path,
712    name: &str,
713    schema_pin: &memstead_schema::SchemaRef,
714    seed_id: &str,
715    wirings: &[WiringOutcome],
716    agents_defaulted: bool,
717    mcp_bin: &McpBinary,
718) -> anyhow::Result<()> {
719    let restart_labels: Vec<&str> = wirings.iter().map(|w| w.target.label()).collect();
720    let next_action = format!(
721        "Restart {} so the `memstead` MCP server registers — then try: memstead overview",
722        restart_labels.join(" / "),
723    );
724
725    if ctx.json {
726        return print_json(&json!({
727            "workspace_root": target.display().to_string(),
728            "config_path": config_path(target).display().to_string(),
729            "name": name,
730            "schema": schema_pin.as_display(),
731            "seed_entity": seed_id,
732            "mcp_command": mcp_bin.command,
733            "agents": wirings
734                .iter()
735                .map(|w| json!({
736                    "target": w.target.to_possible_value().map(|v| v.get_name().to_string()),
737                    "action": w.action,
738                }))
739                .collect::<Vec<_>>(),
740            "agents_defaulted": agents_defaulted,
741            "next_action": next_action,
742            "warnings": mcp_bin.warning.as_ref().map(|w| vec![w.clone()]).unwrap_or_default(),
743        }));
744    }
745
746    let mut lines = vec![
747        format!("# Quickstart complete — mem `{name}`"),
748        String::new(),
749        format!("- Workspace:   `{}`", target.display()),
750        format!("- Schema pin:  `{}`", schema_pin.as_display()),
751        format!("- Seed entity: `{seed_id}` (remove any time: `memstead delete {seed_id}`)"),
752    ];
753    for w in wirings {
754        lines.push(format!("- {}: {}", w.target.label(), w.action));
755    }
756    if agents_defaulted {
757        lines.push(
758            "- No `--agent` given and no terminal to ask — defaulted to Claude Code \
759             (re-run with `--agent` for others)"
760                .to_string(),
761        );
762    }
763    if let Some(warning) = &mcp_bin.warning {
764        lines.push(String::new());
765        lines.push(format!("> warning: {warning}"));
766    }
767    lines.push(String::new());
768    lines.push(format!("Next: {next_action}"));
769    print_markdown(&lines.join("\n"));
770    Ok(())
771}
772
773#[cfg(test)]
774mod tests {
775    use super::*;
776
777    #[test]
778    fn derive_mem_name_handles_common_directory_names() {
779        assert_eq!(derive_mem_name("my-graph").as_deref(), Some("my-graph"));
780        assert_eq!(derive_mem_name("My Project").as_deref(), Some("my-project"));
781        assert_eq!(
782            derive_mem_name("Notes_2026 (v2)").as_deref(),
783            Some("notes-2026-v2")
784        );
785        // Nothing valid survives: prompt/refusal path.
786        assert_eq!(derive_mem_name("日本語"), None);
787        assert_eq!(derive_mem_name(""), None);
788        // Single char fails the two-char slug rule.
789        assert_eq!(derive_mem_name("a"), None);
790    }
791
792    #[test]
793    fn blocking_entries_tolerates_dotfiles_and_readme_grade() {
794        let tmp = tempfile::tempdir().unwrap();
795        for f in [".gitignore", ".mcp.json", "README", "LICENSE", "Readme.txt"] {
796            std::fs::write(tmp.path().join(f), b"x").unwrap();
797        }
798        std::fs::create_dir(tmp.path().join(".git")).unwrap();
799        assert!(blocking_entries(tmp.path()).unwrap().is_empty());
800
801        // A `.md` README blocks — the folder backend would adopt it as
802        // an entity, and quickstart never ingests user content.
803        std::fs::write(tmp.path().join("README.md"), b"# hi").unwrap();
804        assert_eq!(blocking_entries(tmp.path()).unwrap(), vec!["`README.md`"]);
805        std::fs::remove_file(tmp.path().join("README.md")).unwrap();
806
807        std::fs::write(tmp.path().join("main.rs"), b"fn main() {}").unwrap();
808        assert_eq!(blocking_entries(tmp.path()).unwrap(), vec!["`main.rs`"]);
809    }
810
811    #[test]
812    fn wire_agent_merges_and_never_overwrites() {
813        let tmp = tempfile::tempdir().unwrap();
814        // Fresh write.
815        let outcome = wire_agent(tmp.path(), AgentTarget::ClaudeCode, "/bin/memstead-mcp").unwrap();
816        assert!(outcome.action.contains("wrote"), "got: {}", outcome.action);
817        let parsed: serde_json::Value =
818            serde_json::from_slice(&std::fs::read(tmp.path().join(".mcp.json")).unwrap()).unwrap();
819        assert_eq!(
820            parsed["mcpServers"]["memstead"]["command"],
821            "/bin/memstead-mcp"
822        );
823
824        // Existing foreign server entries survive; existing `memstead`
825        // entry is never overwritten.
826        std::fs::write(
827            tmp.path().join(".mcp.json"),
828            serde_json::to_vec_pretty(&serde_json::json!({
829                "mcpServers": {
830                    "other": { "command": "/bin/other" },
831                    "memstead": { "command": "/custom/memstead-mcp" },
832                }
833            }))
834            .unwrap(),
835        )
836        .unwrap();
837        let outcome = wire_agent(tmp.path(), AgentTarget::ClaudeCode, "/bin/memstead-mcp").unwrap();
838        assert!(
839            outcome.action.contains("left untouched"),
840            "got: {}",
841            outcome.action
842        );
843        let parsed: serde_json::Value =
844            serde_json::from_slice(&std::fs::read(tmp.path().join(".mcp.json")).unwrap()).unwrap();
845        assert_eq!(
846            parsed["mcpServers"]["memstead"]["command"],
847            "/custom/memstead-mcp"
848        );
849        assert_eq!(parsed["mcpServers"]["other"]["command"], "/bin/other");
850    }
851
852    #[test]
853    fn wire_agent_codex_prints_command_writes_nothing() {
854        let tmp = tempfile::tempdir().unwrap();
855        let outcome = wire_agent(tmp.path(), AgentTarget::Codex, "/bin/memstead-mcp").unwrap();
856        assert!(
857            outcome
858                .action
859                .contains("codex mcp add memstead -- /bin/memstead-mcp"),
860            "got: {}",
861            outcome.action,
862        );
863        assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0);
864    }
865}