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::binding::ScaffoldParams;
30use memstead_base::filesystem::config::{config_path, init_filesystem_mem_at, validate_mem_name};
31use memstead_base::pipeline_store::write_binding;
32use memstead_base::vcs::Actor;
33use memstead_base::{CreateEntityArgs, Engine as BaseEngine};
34use serde_json::json;
35
36use crate::CliError;
37use crate::output::{ExitKind, print_json, print_markdown};
38use crate::setup::{CliContext, memstead_program, shell_quote};
39
40use super::init::find_ancestor_workspace;
41
42/// `memstead quickstart` arguments.
43#[derive(ClapArgs, Debug)]
44pub struct Args {
45    /// Target folder. Defaults to the current working directory.
46    #[arg(value_name = "PATH")]
47    pub path: Option<PathBuf>,
48
49    /// Mem name. Normally derived from the directory name; pass this
50    /// when the derivation fails (or to override it). Slug-shaped:
51    /// `^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`.
52    #[arg(long)]
53    pub name: Option<String>,
54
55    /// Agent target(s) to write MCP wiring for. Repeatable. Skips the
56    /// interactive selection prompt. Without a TTY and without this
57    /// flag, quickstart defaults to `claude-code`.
58    #[arg(long = "agent", value_enum)]
59    pub agents: Vec<AgentTarget>,
60
61    /// Point at an existing repository: bootstrap the workspace *and*
62    /// scaffold a codebase binding over that tree, then print what the
63    /// starter mem does and does not contain. `--repo .` in the repo
64    /// you already have is the whole guided path.
65    ///
66    /// Without a `PATH` argument the repository *is* the workspace:
67    /// `.memstead/` and the mem's own folder land inside it, so the
68    /// binding points at `.` and every artifact id is repo-relative.
69    /// With a `PATH` argument the workspace is bootstrapped there as
70    /// usual and the binding points back at the repository — a
71    /// supported layout whose one caveat quickstart prints.
72    ///
73    /// Nothing is ingested: the binding is the standing obligation,
74    /// the ingest loop is what fills the mem.
75    #[arg(long = "repo", value_name = "PATH")]
76    pub repo: Option<PathBuf>,
77}
78
79/// The supported agent targets and the wiring each one gets. The three
80/// file-writing targets take project-scoped MCP config; Codex reads
81/// MCP servers only from its global `~/.codex/config.toml`, so its
82/// wiring is the exact `codex mcp add` command printed as the next
83/// action — quickstart never writes outside the target directory.
84#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
85pub enum AgentTarget {
86    /// Claude Code — project `.mcp.json`.
87    ClaudeCode,
88    /// OpenAI Codex — prints the `codex mcp add` one-liner (Codex has
89    /// no project-scoped MCP config file).
90    Codex,
91    /// Cursor — project `.cursor/mcp.json`.
92    Cursor,
93    /// Gemini CLI — project `.gemini/settings.json`.
94    Gemini,
95}
96
97impl AgentTarget {
98    fn label(self) -> &'static str {
99        match self {
100            AgentTarget::ClaudeCode => "Claude Code",
101            AgentTarget::Codex => "Codex",
102            AgentTarget::Cursor => "Cursor",
103            AgentTarget::Gemini => "Gemini CLI",
104        }
105    }
106
107    /// Project-relative MCP config file, or `None` for the
108    /// print-a-command target (Codex).
109    fn config_file(self) -> Option<&'static str> {
110        match self {
111            AgentTarget::ClaudeCode => Some(".mcp.json"),
112            AgentTarget::Cursor => Some(".cursor/mcp.json"),
113            AgentTarget::Gemini => Some(".gemini/settings.json"),
114            AgentTarget::Codex => None,
115        }
116    }
117
118    const ALL: [AgentTarget; 4] = [
119        AgentTarget::ClaudeCode,
120        AgentTarget::Codex,
121        AgentTarget::Cursor,
122        AgentTarget::Gemini,
123    ];
124}
125
126/// What happened to one agent's wiring. Held as data rather than as a
127/// rendered sentence because the sentence names a FILE, and a file has to
128/// be named in the frame of whoever is reading — the human receipt speaks
129/// from the reader's directory, the JSON speaks workspace-relative.
130enum WiringAction {
131    /// The config file was written (or the entry added to it).
132    Wrote,
133    /// A `memstead` entry was already there and was left alone.
134    LeftUntouched,
135    /// No project config exists for this target; this command IS the
136    /// wiring. Carries no path, so it renders the same in either frame.
137    RunCommand(String),
138}
139
140impl WiringAction {
141    /// The report line fragment, with any path rendered by `path`.
142    fn render(&self, target: AgentTarget, path: &dyn Fn(&str) -> String) -> String {
143        match self {
144            WiringAction::Wrote => match target.config_file() {
145                Some(rel) => format!("wrote `{}` (server `memstead`)", path(rel)),
146                None => "wrote its config".to_string(),
147            },
148            WiringAction::LeftUntouched => match target.config_file() {
149                Some(rel) => format!(
150                    "`{}` already has a `memstead` server entry — left untouched",
151                    path(rel)
152                ),
153                None => "already wired — left untouched".to_string(),
154            },
155            WiringAction::RunCommand(cmd) => format!("run: `{cmd}`"),
156        }
157    }
158}
159
160/// One wiring outcome per selected target, for the report.
161struct WiringOutcome {
162    target: AgentTarget,
163    /// What happened. Rendered per surface — see [`WiringAction`].
164    action: WiringAction,
165    /// `Some(_)` when a pre-existing `memstead` server entry was left
166    /// untouched: the entry's `command` value as found in the file
167    /// (`None` inside the option is impossible — a non-string command
168    /// yields `Some(None)`-like absence via the outer `None`). The
169    /// receipt uses this so its verify line checks what the file
170    /// actually wires, never what quickstart would have written.
171    existing_command: Option<String>,
172    /// True when the wiring was skipped because an entry already
173    /// existed — regardless of whether its command could be read.
174    preexisting: bool,
175    /// True when the config FILE was already on disk before this run.
176    /// Distinct from [`Self::preexisting`], which is about the `memstead`
177    /// server ENTRY: a file that existed and gained an entry was modified,
178    /// not created, and a receipt that calls it new is wrong about the
179    /// reader's tree.
180    file_existed: bool,
181}
182
183pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
184    // The repository must already exist — quickstart creates workspaces,
185    // never repositories, and a typo'd `--repo` that silently produced an
186    // empty tree would scaffold a binding over nothing.
187    if let Some(repo) = &args.repo
188        && !repo.is_dir()
189    {
190        return Err(CliError::new(
191            ExitKind::Validation,
192            "INVALID_INPUT",
193            format!(
194                "--repo {} is not an existing directory — point it at the repository \
195                 you already have: memstead quickstart --repo .",
196                repo.display(),
197            ),
198        )
199        .with_details(json!({ "repo": repo.display().to_string() }))
200        .into());
201    }
202
203    let target = args
204        .path
205        .clone()
206        .or_else(|| args.repo.clone())
207        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
208
209    if target.exists() && !target.is_dir() {
210        return Err(CliError::new(
211            ExitKind::Validation,
212            "INVALID_INPUT",
213            format!(
214                "target {} exists but is not a directory — point at a folder: \
215                 memstead quickstart my-graph",
216                target.display(),
217            ),
218        )
219        .into());
220    }
221    let target_created = !target.exists();
222    if target_created {
223        // Typed, not INTERNAL: an unwritable or missing parent is an
224        // environment condition the caller can act on (fix permissions,
225        // pick another target). The path rides `details` so an agent
226        // recovers without parsing prose.
227        std::fs::create_dir_all(&target).map_err(|e| {
228            CliError::new(
229                ExitKind::Generic,
230                "INTERNAL_IO_ERROR",
231                format!(
232                    "failed to create target directory {}: {e}",
233                    target.display()
234                ),
235            )
236            .with_details(serde_json::json!({ "path": target.display().to_string() }))
237        })?;
238    }
239
240    // Layout. Without `--repo` the workspace root is the target and the
241    // mem folder collapses onto it — today's shape exactly. With `--repo`,
242    // the mem takes a folder of its own whenever the workspace and the
243    // repository OVERLAP: `--repo .` with no target path (the flagship
244    // case), a workspace nested in the repo (`quickstart ./graph --repo .`),
245    // and a workspace at the common parent of several repos alike.
246    //
247    // The overlap test is the rule, not a convenience, and it earns its
248    // keep at both ends. Workspace inside the source tree: the mem's own
249    // entity files would be inside the binding's scope, and the one
250    // mechanism that keeps them out — the engine's unconditional
251    // exclusion of every mount's storage location — is skipped for a
252    // mount that IS the workspace root (excluding `**` there would empty
253    // every denominator); a folder of its own puts the mem back under
254    // that exclusion. Repository inside the workspace — the common-parent
255    // layout the out-of-root warning itself recommends: the
256    // tolerant-emptiness gate would refuse that parent for containing the
257    // repo, and a folder of its own moves the gate onto the mem's folder,
258    // so the recipe the engine prints is reachable from the front door
259    // that prints it.
260    let mem_in_subfolder = args
261        .repo
262        .as_deref()
263        .is_some_and(|repo| workspace_overlaps_repo(&target, repo));
264
265    // Conflict gate 1: the target itself already carries `.memstead/`.
266    check_no_local_memstead(&target)?;
267
268    // Conflict gate 2: never nest inside an existing workspace — same
269    // rule and walker as `memstead init`. The alternatives named here
270    // must be viable in the workspaces quickstart itself creates
271    // (filesystem-shaped, no mem-lifecycle allowlist), so the message
272    // points at working in the existing workspace or starting a
273    // separate one — never at `memstead mem init`, which refuses on
274    // both counts there.
275    if let Some(found_at) = find_ancestor_workspace(&target)? {
276        return Err(CliError::new(
277            ExitKind::Validation,
278            crate::WORKSPACE_ALREADY_EXISTS_ABOVE_CODE,
279            format!(
280                "{} is already inside the memstead workspace at {} — quickstart \
281                 refuses to nest workspaces. Work in that workspace (memstead \
282                 overview), or start a separate graph outside it: mkdir my-graph && \
283                 cd my-graph && memstead quickstart",
284                target.display(),
285                found_at.display(),
286            ),
287        )
288        .with_details(json!({ "found_at": found_at.display().to_string() }))
289        .into());
290    }
291
292    // Mem name: flag > derivation from the directory > TTY prompt >
293    // refusal carrying the exact command. Resolved before gate 3 because
294    // the guided layout names the mem's folder after it.
295    let name = resolve_mem_name(&target, args.name.as_deref(), args.repo.as_deref())?;
296
297    // The mem's folder — the workspace root itself in the collapsed
298    // shape, a subdirectory named after the mem in the guided in-repo one.
299    let mem_dir = if mem_in_subfolder {
300        target.join(&name)
301    } else {
302        target.clone()
303    };
304
305    // Conflict gate 3: tolerant emptiness — of the folder the mem will
306    // own, which is the only folder whose `.md` files the graph would
307    // adopt. In the guided in-repo layout that is the fresh subdirectory,
308    // so the repository's own files never reach this gate; they are not
309    // the mem's folder, and nothing adopts them.
310    if mem_in_subfolder {
311        guard_guided_mem_folder(&target, &mem_dir, &name)?;
312    }
313    let blocking = blocking_entries(&mem_dir)?;
314    if !blocking.is_empty() {
315        let md_note = if blocking.iter().any(|f| f.ends_with(".md`")) {
316            " (a filesystem mem owns every `.md` file in its folder, so quickstart \
317             would silently adopt them into the graph)"
318        } else {
319            ""
320        };
321        return Err(CliError::new(
322            ExitKind::Validation,
323            crate::TARGET_NOT_EMPTY_CODE,
324            format!(
325                "target {} has content quickstart won't touch: {}{md_note} — move it \
326                 out, or start in a fresh folder: mkdir my-graph && cd my-graph && \
327                 memstead quickstart",
328                mem_dir.display(),
329                blocking.join(", "),
330            ),
331        )
332        .with_details(json!({
333            "path": mem_dir.display().to_string(),
334            "found": blocking,
335        }))
336        .into());
337    }
338
339    // Agent targets: flag > TTY prompt > default (Claude Code, stated).
340    let (agents, agents_defaulted) = resolve_agents(&args.agents)?;
341
342    // Preflight every selected agent's existing config file BEFORE any
343    // write lands: a malformed `.mcp.json` must refuse while "re-run
344    // memstead quickstart" is still true — discovering it after the
345    // workspace exists would leave a half-bootstrapped directory and a
346    // printed retry command that can no longer succeed.
347    for agent in &agents {
348        if let Some(rel) = agent.config_file() {
349            read_agent_config(&target.join(rel))?;
350        }
351    }
352
353    // Schema pin: the current default builtin, resolved by name so the
354    // printed pin tracks the catalogue instead of a hardcoded version.
355    let schema_pin = default_schema_pin()?;
356
357    // Everything the guided mode needs to say and write is derived
358    // BEFORE the first write: a pointer or stem that cannot be formed
359    // must refuse while "re-run memstead quickstart" is still true.
360    let guided_plan = match &args.repo {
361        Some(repo) => Some(GuidedPlan::derive(&target, repo, &name)?),
362        None => None,
363    };
364
365    // Workspace + config through the same shared initialiser `memstead
366    // init` uses — one code path, byte-identical output.
367    init_filesystem_mem_at(&target, &mem_dir, &name, &schema_pin).map_err(|e| {
368        CliError::new(
369            ExitKind::Generic,
370            "INTERNAL_IO_ERROR",
371            format!("initialise filesystem mem: {e}"),
372        )
373    })?;
374
375    // Seed entity, through the engine's validated create path.
376    let seed_id = seed_entity(&target, &name)?;
377
378    // The binding: the standing source→mem obligation the ingest loop
379    // runs against. Scaffolded through the engine's own scaffold, so a
380    // guided binding is the same record `memstead projection init`
381    // writes — no quickstart-private shape.
382    let guided = match guided_plan {
383        Some(plan) => Some(plan.write(&target)?),
384        None => None,
385    };
386
387    // MCP wiring per selected target.
388    let mcp_bin = resolve_mcp_binary();
389    let mut wirings = Vec::with_capacity(agents.len());
390    for agent in &agents {
391        wirings.push(wire_agent(&target, *agent, &mcp_bin.command)?);
392    }
393
394    report(
395        ctx,
396        &target,
397        &mem_dir,
398        &name,
399        &schema_pin,
400        &seed_id,
401        &wirings,
402        agents_defaulted,
403        &mcp_bin,
404        guided.as_ref(),
405        target_created,
406    )
407}
408
409/// Whether the workspace root and the repository overlap — either one
410/// containing the other, the equal case included. Both sides are
411/// canonicalized where possible so a symlinked parent (`/tmp` on macOS)
412/// does not read as disjoint; a path that cannot be canonicalized falls
413/// back to its own form, which claims no overlap.
414fn workspace_overlaps_repo(target: &Path, repo: &Path) -> bool {
415    let t = target
416        .canonicalize()
417        .unwrap_or_else(|_| target.to_path_buf());
418    let r = repo.canonicalize().unwrap_or_else(|_| repo.to_path_buf());
419    t.starts_with(&r) || r.starts_with(&t)
420}
421
422/// The workspace root expressed relative to the repository, or `None`
423/// when the workspace is not inside it (`Some("")` for the equal case).
424///
425/// This is the frame the receipt owes a reader asking "what appeared in
426/// my repository?": every artifact path quickstart knows is relative to
427/// the WORKSPACE, and the two frames coincide only when the two roots do.
428fn workspace_within_repo(target: &Path, repo: &Path) -> Option<String> {
429    let t = target
430        .canonicalize()
431        .unwrap_or_else(|_| target.to_path_buf());
432    let r = repo.canonicalize().unwrap_or_else(|_| repo.to_path_buf());
433    let rel = t.strip_prefix(&r).ok()?;
434    Some(rel.to_string_lossy().replace('\\', "/"))
435}
436
437/// Refuse a guided in-repo layout whose mem folder would collide with
438/// something already in the repository. The mem folder is a directory
439/// quickstart creates and the graph then owns; adopting a folder the
440/// repository already uses is the same defect as adopting its `.md`
441/// files, so this refuses and names the flag that resolves it.
442fn guard_guided_mem_folder(repo: &Path, mem_dir: &Path, name: &str) -> anyhow::Result<()> {
443    if !mem_dir.exists() {
444        return Ok(());
445    }
446    let retry = ShellCmd::new(memstead_program())
447        .arg("quickstart")
448        .arg("--repo")
449        .arg(repo.display().to_string())
450        .arg("--name")
451        .arg(format!("{name}-mem"))
452        .render();
453    if !mem_dir.is_dir() {
454        return Err(CliError::new(
455            ExitKind::Validation,
456            crate::TARGET_NOT_EMPTY_CODE,
457            format!(
458                "the mem would take the folder {}, and that path already exists as a file \
459                 — name the mem something else: {retry}",
460                mem_dir.display(),
461            ),
462        )
463        .with_details(json!({ "path": mem_dir.display().to_string() }))
464        .into());
465    }
466    let occupied = std::fs::read_dir(mem_dir)
467        .map(|entries| entries.count() > 0)
468        .unwrap_or(true);
469    if occupied {
470        return Err(CliError::new(
471            ExitKind::Validation,
472            crate::TARGET_NOT_EMPTY_CODE,
473            format!(
474                "the mem would take the folder {}, and that folder already exists and is \
475                 not empty — quickstart won't adopt a folder the repository already uses. \
476                 Name the mem something else: {retry}",
477                mem_dir.display(),
478            ),
479        )
480        .with_details(json!({ "path": mem_dir.display().to_string() }))
481        .into());
482    }
483    Ok(())
484}
485
486/// The guided mode's binding, resolved before any write and written
487/// after the workspace exists. Split in two so a pointer or stem that
488/// cannot be formed refuses while the retry command is still true.
489struct GuidedPlan {
490    /// The medium pointer, workspace-relative (`.` for the in-repo layout).
491    pointer: String,
492    /// The `<stem>` half of the binding id.
493    stem: String,
494    /// The repository as the reader sees it, for the receipt.
495    repo_display: String,
496    /// The layout caveat, when the repository sits outside the workspace.
497    layout_warning: Option<String>,
498    /// The workspace root relative to the repository, when it is inside
499    /// it at all (`Some("")` when they are the same directory). `None`
500    /// means this run wrote nothing into the repository.
501    workspace_in_repo: Option<String>,
502    /// Whether the source tree is actually a git repository.
503    is_git_repo: bool,
504    mem: String,
505}
506
507/// What the receipt reports about the scaffolded binding.
508struct GuidedOutcome {
509    binding_id: String,
510    pointer: String,
511    repo_display: String,
512    /// Workspace-relative path of the written record.
513    record: String,
514    /// The deny globs the record actually carries — printed from the
515    /// record, never from the constant, so the brief cannot drift from
516    /// what was written.
517    deny_paths: Vec<String>,
518    /// Which operations the record declares.
519    operations: Vec<String>,
520    /// Scaffold + layout warnings, in that order.
521    warnings: Vec<String>,
522    /// The workspace root relative to the repository — see
523    /// [`GuidedPlan::workspace_in_repo`].
524    workspace_in_repo: Option<String>,
525    /// Whether the source tree is actually a git repository. `--repo`
526    /// accepts any directory and the binding works either way, but the
527    /// brief must not describe history a plain folder does not have.
528    is_git_repo: bool,
529}
530
531impl GuidedPlan {
532    fn derive(workspace_root: &Path, repo: &Path, mem: &str) -> anyhow::Result<Self> {
533        let workspace_abs = workspace_root
534            .canonicalize()
535            .unwrap_or_else(|_| workspace_root.to_path_buf());
536        let repo_abs = repo.canonicalize().unwrap_or_else(|_| repo.to_path_buf());
537        let pointer = if repo_abs == workspace_abs {
538            ".".to_string()
539        } else {
540            let rel = memstead_base::ingest::cursor::relative_to(&workspace_abs, &repo_abs);
541            if rel.as_os_str().is_empty() {
542                ".".to_string()
543            } else {
544                rel.to_string_lossy().replace('\\', "/")
545            }
546        };
547        // The stem is a file-path component and half the binding id, so
548        // it gets the same slug treatment as the mem name; a repository
549        // basename that slugs to nothing falls back to the mem name,
550        // which already passed the slug rule.
551        let stem = repo_abs
552            .file_name()
553            .map(|n| n.to_string_lossy().to_string())
554            .and_then(|n| derive_mem_name(&n))
555            .unwrap_or_else(|| mem.to_string());
556        let layout_warning = memstead_base::ingest::cursor::out_of_root_layout_warning(
557            &pointer,
558            &workspace_abs,
559            memstead_base::MediumType::Codebase,
560        );
561        Ok(GuidedPlan {
562            pointer,
563            stem,
564            repo_display: repo_abs.display().to_string(),
565            layout_warning,
566            workspace_in_repo: workspace_within_repo(workspace_root, repo),
567            is_git_repo: repo_abs.join(".git").exists(),
568            mem: mem.to_string(),
569        })
570    }
571
572    fn write(self, workspace_root: &Path) -> anyhow::Result<GuidedOutcome> {
573        let GuidedPlan {
574            pointer,
575            stem,
576            repo_display,
577            layout_warning,
578            workspace_in_repo,
579            is_git_repo,
580            mem,
581        } = self;
582        let scaffolded = memstead_base::binding::scaffold_binding(ScaffoldParams {
583            destination_mem: &mem,
584            source_name: &stem,
585            pointer: &pointer,
586            medium_type: memstead_base::MediumType::Codebase,
587            intent: Some(format!(
588                "Model the `{stem}` codebase in the `{mem}` mem: what each part is for, \
589                 how the parts fit together, and the decisions behind them."
590            )),
591            additional_deny_paths: Vec::new(),
592        });
593        write_binding(workspace_root, &mem, &stem, &scaffolded.binding).map_err(|e| {
594            CliError::new(
595                ExitKind::Generic,
596                "PROJECTION_INIT_FAILED",
597                format!("could not scaffold binding `{mem}/{stem}`: {e}"),
598            )
599            .with_details(json!({ "binding": format!("{mem}/{stem}"), "error": e.to_string() }))
600        })?;
601        let mut warnings: Vec<String> = scaffolded.warnings;
602        warnings.extend(layout_warning);
603        Ok(GuidedOutcome {
604            binding_id: format!("{mem}/{stem}"),
605            pointer,
606            repo_display,
607            record: format!(".memstead/projections/{mem}/{stem}.json"),
608            deny_paths: scaffolded.binding.deny_paths.clone(),
609            operations: scaffolded
610                .operations
611                .iter()
612                .map(|o| (*o).to_string())
613                .collect(),
614            warnings,
615            workspace_in_repo,
616            is_git_repo,
617        })
618    }
619}
620
621/// Refuse when the target already carries `.memstead/` — either a
622/// finished workspace (point at the next command, don't re-initialise)
623/// or a foreign/partial `.memstead/` directory quickstart must not
624/// adopt or overwrite.
625fn check_no_local_memstead(target: &Path) -> anyhow::Result<()> {
626    let store = target.join(memstead_base::WORKSPACE_STORE_DIR);
627    if !store.exists() {
628        return Ok(());
629    }
630    if memstead_base::is_workspace_root(target) {
631        return Err(CliError::new(
632            ExitKind::Validation,
633            "WORKSPACE_ALREADY_INITIALISED",
634            format!(
635                "{} is already a Memstead workspace — nothing to bootstrap. \
636                 Inspect it with: memstead overview",
637                target.display(),
638            ),
639        )
640        .with_details(json!({ "path": target.display().to_string() }))
641        .into());
642    }
643    Err(CliError::new(
644        ExitKind::Validation,
645        "FOREIGN_MEMSTEAD_DIR",
646        format!(
647            "{} contains a `.memstead/` directory that is not a workspace \
648             (no workspace.toml) — quickstart won't adopt or overwrite it. \
649             Move it aside, or start fresh: mkdir my-graph && cd my-graph && \
650             memstead quickstart",
651            target.display(),
652        ),
653    )
654    .with_details(json!({ "path": store.display().to_string() }))
655    .into())
656}
657
658/// Directory entries that block quickstart. Tolerated: dotfiles
659/// (`.git`, `.gitignore`, `.mcp.json`, editor config, …) and non-`.md`
660/// README-grade files (README, LICENSE.txt, …). Every `.md` file blocks
661/// — including `README.md` — because the folder backend treats each
662/// `.md` in the mem folder as an entity, and silently adopting user
663/// content into the graph is the one thing quickstart must never do.
664/// `.memstead` is handled earlier by [`check_no_local_memstead`].
665fn blocking_entries(target: &Path) -> anyhow::Result<Vec<String>> {
666    // A folder that does not exist yet blocks nothing — the guided
667    // layout's mem folder is created by the initialiser.
668    if !target.exists() {
669        return Ok(Vec::new());
670    }
671    let read_err = |e: std::io::Error| {
672        CliError::new(
673            ExitKind::Generic,
674            "INTERNAL_IO_ERROR",
675            format!("read target {}: {e}", target.display()),
676        )
677    };
678    let mut blocking = Vec::new();
679    for entry in std::fs::read_dir(target).map_err(read_err)? {
680        let entry = entry.map_err(read_err)?;
681        let name = entry.file_name().to_string_lossy().to_string();
682        if name.starts_with('.') {
683            continue;
684        }
685        let lower = name.to_lowercase();
686        let readme_grade = lower.starts_with("readme")
687            || lower.starts_with("license")
688            || lower.starts_with("licence");
689        if readme_grade && !lower.ends_with(".md") {
690            continue;
691        }
692        blocking.push(format!("`{name}`"));
693    }
694    blocking.sort();
695    Ok(blocking)
696}
697
698/// Resolve the mem name: `--name` wins, then slug derivation from the
699/// directory basename, then (TTY only) one prompt, else a refusal
700/// carrying the exact retry command.
701fn resolve_mem_name(
702    target: &Path,
703    flag: Option<&str>,
704    repo: Option<&Path>,
705) -> anyhow::Result<String> {
706    // A refusal's retry command must reproduce the invocation that hit it:
707    // a guided run retried without `--repo` lands on the tolerant-emptiness
708    // gate instead of succeeding. The guided form is built, never formatted
709    // — a repository path can contain anything a shell would eat. The plain
710    // form stays the literal it has always been: it takes no path argument,
711    // and its wording is pinned by test as part of the unchanged plain path.
712    let retry = |name: &str| match repo {
713        Some(repo) => ShellCmd::new(memstead_program())
714            .arg("quickstart")
715            .arg("--repo")
716            .arg(repo.display().to_string())
717            .arg("--name")
718            .arg(name)
719            .render(),
720        None => format!("memstead quickstart --name {name}"),
721    };
722    if let Some(name) = flag {
723        validate_mem_name(name).map_err(|e| {
724            CliError::new(
725                ExitKind::Validation,
726                "INVALID_INPUT",
727                format!(
728                    "invalid --name: {e}. Retry with a slug, e.g.: {}",
729                    retry(&derive_mem_name(name).unwrap_or_else(|| "my-graph".to_string())),
730                ),
731            )
732        })?;
733        return Ok(name.to_string());
734    }
735    let basename = std::fs::canonicalize(target)
736        .ok()
737        .and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string()))
738        .unwrap_or_default();
739    if let Some(derived) = derive_mem_name(&basename) {
740        return Ok(derived);
741    }
742    if std::io::stdin().is_terminal() {
743        let answer = prompt_line(&format!(
744            "Could not derive a mem name from `{basename}`. Mem name (lowercase letters, digits, hyphens): ",
745        ))?;
746        let answer = answer.trim();
747        validate_mem_name(answer).map_err(|e| {
748            CliError::new(
749                ExitKind::Validation,
750                "INVALID_INPUT",
751                format!("invalid mem name: {e}. Retry with: {}", retry("my-graph")),
752            )
753        })?;
754        return Ok(answer.to_string());
755    }
756    Err(CliError::new(
757        ExitKind::Validation,
758        "INVALID_INPUT",
759        format!(
760            "could not derive a mem name from directory `{basename}` — \
761             pass one explicitly: {}",
762            retry("my-graph"),
763        ),
764    )
765    .with_details(json!({ "directory": basename }))
766    .into())
767}
768
769/// Slug-derive a mem name from a directory basename: lowercase,
770/// non-alphanumerics to hyphens, runs collapsed, edges trimmed, capped
771/// at the 64-char rule. `None` when nothing valid survives.
772fn derive_mem_name(basename: &str) -> Option<String> {
773    let mut out = String::with_capacity(basename.len());
774    for c in basename.to_lowercase().chars() {
775        if c.is_ascii_lowercase() || c.is_ascii_digit() {
776            out.push(c);
777        } else if !out.is_empty() && !out.ends_with('-') {
778            out.push('-');
779        }
780    }
781    let mut slug: String = out.trim_matches('-').chars().take(64).collect();
782    slug = slug.trim_matches('-').to_string();
783    validate_mem_name(&slug).ok().map(|()| slug)
784}
785
786/// Resolve the agent-target list. Returns the targets plus whether the
787/// non-interactive Claude Code default was applied (the report states
788/// it, so a scripted run knows the choice was made for it).
789fn resolve_agents(flag: &[AgentTarget]) -> anyhow::Result<(Vec<AgentTarget>, bool)> {
790    if !flag.is_empty() {
791        let mut seen = Vec::with_capacity(flag.len());
792        for a in flag {
793            if !seen.contains(a) {
794                seen.push(*a);
795            }
796        }
797        return Ok((seen, false));
798    }
799    if std::io::stdin().is_terminal() {
800        return Ok((prompt_agents()?, false));
801    }
802    Ok((vec![AgentTarget::ClaudeCode], true))
803}
804
805/// The one interactive agent-target prompt. Empty answer means Claude
806/// Code; otherwise comma-separated numbers from the printed list.
807fn prompt_agents() -> anyhow::Result<Vec<AgentTarget>> {
808    let menu: Vec<String> = AgentTarget::ALL
809        .iter()
810        .enumerate()
811        .map(|(i, a)| format!("  {}) {}", i + 1, a.label()))
812        .collect();
813    let answer = prompt_line(&format!(
814        "Which agents should connect to this mem? (comma-separated, Enter = Claude Code)\n{}\n> ",
815        menu.join("\n"),
816    ))?;
817    let answer = answer.trim();
818    if answer.is_empty() {
819        return Ok(vec![AgentTarget::ClaudeCode]);
820    }
821    let mut selected = Vec::new();
822    for token in answer.split(',') {
823        let token = token.trim();
824        let picked = match token.parse::<usize>() {
825            Ok(n) if (1..=AgentTarget::ALL.len()).contains(&n) => AgentTarget::ALL[n - 1],
826            _ => {
827                return Err(CliError::new(
828                    ExitKind::Validation,
829                    "INVALID_INPUT",
830                    format!(
831                        "unrecognised selection `{token}` — expected numbers 1-{max} \
832                         (comma-separated). Skip the prompt with: memstead quickstart \
833                         --agent claude-code --agent cursor",
834                        max = AgentTarget::ALL.len(),
835                    ),
836                )
837                .into());
838            }
839        };
840        if !selected.contains(&picked) {
841            selected.push(picked);
842        }
843    }
844    Ok(selected)
845}
846
847/// Print `msg` to stderr (stdout carries the command's report) and read
848/// one line from stdin.
849fn prompt_line(msg: &str) -> anyhow::Result<String> {
850    let mut stderr = std::io::stderr();
851    stderr.write_all(msg.as_bytes()).ok();
852    stderr.flush().ok();
853    let mut line = String::new();
854    std::io::stdin().read_line(&mut line).map_err(|e| {
855        CliError::new(
856            ExitKind::Generic,
857            "INTERNAL_IO_ERROR",
858            format!("read answer from stdin: {e}"),
859        )
860    })?;
861    Ok(line)
862}
863
864/// Resolve the default builtin schema to its concrete pin — the
865/// current generation (1.3.0, the required-opt-in metadata-polarity
866/// generation), so fresh workspaces never start on a superseded
867/// vocabulary.
868fn default_schema_pin() -> anyhow::Result<memstead_schema::SchemaRef> {
869    let reg = memstead_schema::SchemaRegistry::builtin();
870    match reg.get("default", &semver::Version::new(1, 3, 0)) {
871        Some(schema) => {
872            let (name, version) = schema.id();
873            Ok(memstead_schema::SchemaRef::new(name, version))
874        }
875        _ => Err(CliError::new(
876            ExitKind::Generic,
877            crate::INTERNAL_CODE,
878            "builtin schema catalogue has no `default` schema — this binary is broken, please report",
879        )
880        .into()),
881    }
882}
883
884/// Create the seed entity through the engine's validated create path,
885/// so the very first entity in the graph went through the same gate
886/// every later one will.
887fn seed_entity(target: &Path, mem: &str) -> anyhow::Result<String> {
888    let mut engine = BaseEngine::from_workspace_root(target).map_err(|e| {
889        CliError::new(
890            ExitKind::Generic,
891            crate::INTERNAL_CODE,
892            format!("boot engine at {}: {e:#}", target.display()),
893        )
894    })?;
895    let mut sections = indexmap::IndexMap::new();
896    sections.insert(
897        "definition".to_string(),
898        "This mem is a typed knowledge graph: markdown entities validated against a schema, \
899         connected by typed relationships."
900            .to_string(),
901    );
902    sections.insert(
903        "explanation".to_string(),
904        "`memstead quickstart` seeded this entity so the graph starts non-empty. Read it back \
905         with `memstead entity <id>`, list types with `memstead type`, create your own with \
906         `memstead create`, and delete this one any time with `memstead delete <id>`."
907            .to_string(),
908    );
909    let outcome = engine
910        .create_entity(
911            CreateEntityArgs {
912                anchors: Vec::new(),
913                mem: mem.to_string(),
914                title: "Welcome to Memstead".to_string(),
915                entity_type: "concept".to_string(),
916                sections,
917                metadata: indexmap::IndexMap::new(),
918                relations: Vec::new(),
919                dry_run: false,
920            },
921            Actor::Cli,
922            None,
923            Some("seeded by memstead quickstart"),
924        )
925        .map_err(CliError::from_engine_op)?;
926    Ok(outcome.id.as_ref().to_string())
927}
928
929/// The resolved `memstead-mcp` launch command plus a warning when the
930/// binary could not be found (the wiring is still written with the
931/// bare name so a later install fixes it without re-running).
932struct McpBinary {
933    command: String,
934    warning: Option<String>,
935}
936
937/// Resolve the `memstead-mcp` binary: sibling of the running `memstead`
938/// binary first (one install ships both), then `PATH`. Falls back to
939/// the bare name with a warning naming the install command.
940fn resolve_mcp_binary() -> McpBinary {
941    if let Ok(exe) = std::env::current_exe()
942        && let Some(dir) = exe.parent()
943    {
944        let sibling = dir.join("memstead-mcp");
945        if sibling.is_file() {
946            return McpBinary {
947                command: sibling.display().to_string(),
948                warning: None,
949            };
950        }
951    }
952    if let Some(paths) = std::env::var_os("PATH") {
953        for dir in std::env::split_paths(&paths) {
954            let candidate = dir.join("memstead-mcp");
955            if candidate.is_file() {
956                return McpBinary {
957                    command: candidate.display().to_string(),
958                    warning: None,
959                };
960            }
961        }
962    }
963    McpBinary {
964        command: "memstead-mcp".to_string(),
965        warning: Some(
966            "`memstead-mcp` was not found next to this binary or on PATH — the wiring uses the \
967             bare name and will work once it is installed (curl -sSf https://memstead.io/install.sh | sh)"
968                .to_string(),
969        ),
970    }
971}
972
973/// Read and shape-check an agent's existing MCP config file: must be
974/// valid JSON, a top-level object, with `mcpServers` absent or an
975/// object. A missing file is an empty object. Called once as a
976/// preflight before any write lands (so the refusal's "re-run
977/// memstead quickstart" stays true) and again by [`wire_agent`].
978fn read_agent_config(path: &Path) -> anyhow::Result<serde_json::Value> {
979    if !path.is_file() {
980        return Ok(json!({}));
981    }
982    let fix_hint = "fix or remove the file, then re-run: memstead quickstart";
983    let bytes = std::fs::read(path).map_err(|e| {
984        CliError::new(
985            ExitKind::Generic,
986            "INTERNAL_IO_ERROR",
987            format!("read {}: {e}", path.display()),
988        )
989    })?;
990    let root: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| {
991        CliError::new(
992            ExitKind::Validation,
993            "INVALID_INPUT",
994            format!(
995                "{} exists but is not valid JSON ({e}) — {fix_hint}",
996                path.display()
997            ),
998        )
999    })?;
1000    if !root.is_object() {
1001        return Err(CliError::new(
1002            ExitKind::Validation,
1003            "INVALID_INPUT",
1004            format!(
1005                "{} exists but its top level is not a JSON object — {fix_hint}",
1006                path.display(),
1007            ),
1008        )
1009        .into());
1010    }
1011    let servers = &root["mcpServers"];
1012    if !servers.is_null() && !servers.is_object() {
1013        return Err(CliError::new(
1014            ExitKind::Validation,
1015            "INVALID_INPUT",
1016            format!(
1017                "{}'s `mcpServers` is not a JSON object — {fix_hint}",
1018                path.display(),
1019            ),
1020        )
1021        .into());
1022    }
1023    Ok(root)
1024}
1025
1026/// Write (or merge into) the target's MCP config for one agent. JSON
1027/// configs get an `mcpServers.memstead` entry added, preserving every
1028/// existing key; an existing `memstead` entry is never overwritten.
1029/// Codex gets the exact `codex mcp add` command as its action line.
1030fn wire_agent(
1031    target: &Path,
1032    agent: AgentTarget,
1033    mcp_command: &str,
1034) -> anyhow::Result<WiringOutcome> {
1035    let Some(rel) = agent.config_file() else {
1036        // Codex has no project config, so this command IS the wiring —
1037        // it must survive an mcp path containing a space exactly as the
1038        // verification commands must.
1039        let add = ShellCmd::new("codex")
1040            .arg("mcp")
1041            .arg("add")
1042            .arg("memstead")
1043            .end_of_options()
1044            .arg(mcp_command)
1045            .render();
1046        return Ok(WiringOutcome {
1047            target: agent,
1048            action: WiringAction::RunCommand(add),
1049            existing_command: None,
1050            preexisting: false,
1051            file_existed: false,
1052        });
1053    };
1054    let path = target.join(rel);
1055    let file_existed = path.exists();
1056    let mut root = read_agent_config(&path)?;
1057
1058    let servers = root
1059        .as_object_mut()
1060        .expect("read_agent_config only returns JSON objects")
1061        .entry("mcpServers")
1062        .or_insert_with(|| json!({}));
1063    let servers = servers.as_object_mut().ok_or_else(|| {
1064        CliError::new(
1065            ExitKind::Validation,
1066            "INVALID_INPUT",
1067            format!(
1068                "{}'s `mcpServers` is not a JSON object — fix or remove the file, then \
1069                 re-run: memstead quickstart",
1070                path.display(),
1071            ),
1072        )
1073    })?;
1074
1075    if let Some(existing) = servers.get("memstead") {
1076        // Capture what the file actually wires so the receipt can
1077        // verify it (or state honestly that it could not).
1078        let existing_command = existing
1079            .get("command")
1080            .and_then(|c| c.as_str())
1081            .map(str::to_string);
1082        return Ok(WiringOutcome {
1083            target: agent,
1084            action: WiringAction::LeftUntouched,
1085            existing_command,
1086            preexisting: true,
1087            file_existed,
1088        });
1089    }
1090    servers.insert("memstead".to_string(), json!({ "command": mcp_command }));
1091
1092    if let Some(parent) = path.parent() {
1093        std::fs::create_dir_all(parent).map_err(|e| {
1094            CliError::new(
1095                ExitKind::Generic,
1096                "INTERNAL_IO_ERROR",
1097                format!("create {}: {e}", parent.display()),
1098            )
1099        })?;
1100    }
1101    let rendered = format!(
1102        "{}\n",
1103        serde_json::to_string_pretty(&root).unwrap_or_default()
1104    );
1105    std::fs::write(&path, rendered).map_err(|e| {
1106        CliError::new(
1107            ExitKind::Generic,
1108            "INTERNAL_IO_ERROR",
1109            format!("write {}: {e}", path.display()),
1110        )
1111    })?;
1112    Ok(WiringOutcome {
1113        target: agent,
1114        action: WiringAction::Wrote,
1115        existing_command: None,
1116        preexisting: false,
1117        file_existed,
1118    })
1119}
1120
1121/// One command line the receipt prints for the reader to run.
1122///
1123/// Every printed command goes through this rather than through an ad-hoc
1124/// `format!`, because each one needs the same three things and each was
1125/// independently getting one of them wrong: the program resolved to
1126/// something the reader can actually invoke, every argument shell-quoted,
1127/// and a `cd` when the command must run inside the new workspace.
1128///
1129/// The `cd` uses the `--` terminator so a directory named `-graph`
1130/// reaches `cd` as an operand instead of an option.
1131/// One word of a command line: a value to be quoted, or shell syntax
1132/// to emit as-is.
1133enum Word {
1134    Value(String),
1135    Literal(&'static str),
1136}
1137
1138struct ShellCmd {
1139    /// `cd` here first. `None` runs wherever the reader is standing.
1140    cd: Option<String>,
1141    program: String,
1142    args: Vec<Word>,
1143}
1144
1145impl ShellCmd {
1146    fn new(program: impl Into<String>) -> Self {
1147        ShellCmd {
1148            cd: None,
1149            program: program.into(),
1150            args: Vec::new(),
1151        }
1152    }
1153
1154    fn arg(mut self, arg: impl Into<String>) -> Self {
1155        self.args.push(Word::Value(arg.into()));
1156        self
1157    }
1158
1159    /// The literal `--` end-of-options separator. Distinct from
1160    /// [`Self::arg`] because it is syntax, not a value: quoting it
1161    /// would be harmless to the shell but noise to the reader, and the
1162    /// leading-dash rule that protects values must not fire on it.
1163    fn end_of_options(mut self) -> Self {
1164        self.args.push(Word::Literal("--"));
1165        self
1166    }
1167
1168    /// Prefix a `cd` into `dir` unless the reader is already there.
1169    fn in_dir(mut self, dir: &Path, already_there: bool) -> Self {
1170        if !already_there {
1171            self.cd = Some(dir.display().to_string());
1172        }
1173        self
1174    }
1175
1176    /// The runnable line. This is what both receipts print — the
1177    /// markdown surface only adds its own bullet and backticks.
1178    fn render(&self) -> String {
1179        let mut out = String::new();
1180        if let Some(dir) = &self.cd {
1181            out.push_str(&format!("cd -- {} && ", shell_quote(dir)));
1182        }
1183        out.push_str(&shell_quote(&self.program));
1184        for arg in &self.args {
1185            out.push(' ');
1186            match arg {
1187                Word::Value(v) => out.push_str(&shell_quote(v)),
1188                Word::Literal(l) => out.push_str(l),
1189            }
1190        }
1191        out
1192    }
1193}
1194
1195/// Final report: every artifact by name, then the single next action.
1196#[allow(clippy::too_many_arguments)]
1197fn report(
1198    ctx: &CliContext,
1199    target: &Path,
1200    mem_dir: &Path,
1201    name: &str,
1202    schema_pin: &memstead_schema::SchemaRef,
1203    seed_id: &str,
1204    wirings: &[WiringOutcome],
1205    agents_defaulted: bool,
1206    mcp_bin: &McpBinary,
1207    guided: Option<&GuidedOutcome>,
1208    // Whether this run created the workspace directory itself — the
1209    // difference between "one new directory appeared" and "files appeared
1210    // inside a directory you already had".
1211    target_created: bool,
1212) -> anyhow::Result<()> {
1213    let restart_labels: Vec<&str> = wirings.iter().map(|w| w.target.label()).collect();
1214
1215    // Every command this receipt prints must run verbatim, from the
1216    // directory the caller is actually standing in, with whatever
1217    // characters their paths happen to contain. Each printed command is
1218    // therefore built as a [`ShellCmd`] rather than formatted inline —
1219    // three separate rounds of this receipt shipped a command that did
1220    // not run, each time because one `format!` had been missed.
1221    //
1222    // A verification step the reader cannot reproduce is the same
1223    // defect as an undisclosed shape, so this is not cosmetic.
1224    let absolute = target
1225        .canonicalize()
1226        .unwrap_or_else(|_| target.to_path_buf());
1227    let in_cwd = std::env::current_dir()
1228        .ok()
1229        .is_some_and(|cwd| cwd == absolute);
1230    let memstead = memstead_program();
1231    let overview_cmd = ShellCmd::new(&memstead)
1232        .arg("overview")
1233        .in_dir(target, in_cwd)
1234        .render();
1235    let delete_cmd = ShellCmd::new(&memstead)
1236        .arg("delete")
1237        .arg(seed_id)
1238        .in_dir(target, in_cwd)
1239        .render();
1240    let version_cmd = ShellCmd::new(&mcp_bin.command).arg("--version").render();
1241    // The one command that starts the ingest loop the brief points at.
1242    let brief_cmd = guided.map(|g| {
1243        ShellCmd::new(&memstead)
1244            .arg("projection")
1245            .arg("brief")
1246            .arg(&g.binding_id)
1247            .in_dir(target, in_cwd)
1248            .render()
1249    });
1250    // Where the mem's own files actually are, resolved for the JSON
1251    // surface the same way `absolute` resolves the workspace root.
1252    let mem_absolute = mem_dir.canonicalize().unwrap_or_else(|_| {
1253        if mem_dir == target {
1254            absolute.clone()
1255        } else {
1256            mem_dir.to_path_buf()
1257        }
1258    });
1259    // A path the reader is told to open, rendered from where the receipt
1260    // leaves them standing. Every command beside it carries a `cd` when
1261    // the workspace is not the cwd; a bare workspace-relative path in that
1262    // company is the same defect in prose form — it does not resolve from
1263    // the directory the reader is actually in.
1264    //
1265    // Resolve, then re-express: joining the workspace-relative form onto
1266    // the root and canonicalizing collapses `..` instead of printing
1267    // `./ws/..`, which is the only way an upward pointer comes out as
1268    // something the reader can act on. Falls back to the absolute path
1269    // when the target is not under the cwd — still resolvable, never a
1270    // form that resolves somewhere else.
1271    let cwd_canon = std::env::current_dir()
1272        .ok()
1273        .map(|c| c.canonicalize().unwrap_or(c));
1274    let from_here = |workspace_relative: &str| -> String {
1275        let joined = absolute.join(workspace_relative);
1276        let resolved = joined.canonicalize().unwrap_or(joined);
1277        let absolute_form = resolved.display().to_string();
1278        let Some(cwd) = &cwd_canon else {
1279            return absolute_form;
1280        };
1281        let rel = memstead_base::ingest::cursor::relative_to(cwd, &resolved)
1282            .to_string_lossy()
1283            .replace('\\', "/");
1284        if rel.is_empty() {
1285            return ".".to_string();
1286        }
1287        // An upward chain is fine — the `cd` beside it uses the same
1288        // shape — but past the point where it is longer than the plain
1289        // absolute path it stops helping anyone read it.
1290        if rel.len() <= absolute_form.len() {
1291            rel
1292        } else {
1293            absolute_form
1294        }
1295    };
1296    // The mem's own folder in the reader's frame, for the prose that
1297    // tells them where their entities are. `mem_folder_rel` below stays
1298    // workspace-relative: it is the machine field, and an agent reading
1299    // `--json` already has `workspace_root` to resolve it against.
1300    // Everything a human is told to open goes through `from_here`.
1301    let mem_folder_here: Option<String> = match mem_dir
1302        .strip_prefix(target)
1303        .ok()
1304        .map(|r| r.to_string_lossy().to_string())
1305        .filter(|r| !r.is_empty())
1306    {
1307        Some(rel) => Some(from_here(&rel)),
1308        // The mem collapsed onto the workspace root. "In this folder" is
1309        // true only if the reader is standing in it — in the guided
1310        // disjoint layout they are not, so name the folder instead. The
1311        // plain path keeps the inherited wording: its receipt is pinned
1312        // observably equivalent, and this branch never fires there.
1313        None if guided.is_some() && !in_cwd => Some(from_here(".")),
1314        None => None,
1315    };
1316    // The mem's own folder, workspace-relative, when it is not the root.
1317    let mem_folder_rel: Option<String> = mem_dir
1318        .strip_prefix(target)
1319        .ok()
1320        .map(|r| r.to_string_lossy().to_string())
1321        .filter(|r| !r.is_empty());
1322
1323    // Codex is wired by a command the reader still has to run, so for
1324    // that target the restart registers nothing until they run it. Say
1325    // so in order rather than naming a restart that would no-op.
1326    let codex_pending = wirings
1327        .iter()
1328        .any(|w| w.target == AgentTarget::Codex && matches!(w.action, WiringAction::RunCommand(_)));
1329    let restart_clause = format!(
1330        "Restart {} so the `memstead` MCP server registers its tools",
1331        restart_labels.join(" / "),
1332    );
1333    let next_action = if codex_pending {
1334        format!(
1335            "Run the `codex mcp add` command above first — it is Codex's wiring, and a restart \
1336             registers nothing without it. Then: {restart_clause} — then try: {overview_cmd}"
1337        )
1338    } else {
1339        format!("{restart_clause} — then try: {overview_cmd}")
1340    };
1341    // …but an agent session that just ran onboarding cannot restart
1342    // itself mid-run, so the wiring it wrote must be checkable from
1343    // inside that session. Held as `{what, command}` pairs so the JSON
1344    // surface ships runnable commands and the markdown surface adds its
1345    // own bullet decoration — an agent should never have to strip
1346    // backticks off a machine field.
1347    let mut verify_now: Vec<(&str, String)> = Vec::new();
1348    // Only claim the binary answers when we actually found one. In the
1349    // not-found case the warning above already names the install
1350    // command, and printing an unrunnable check under the heading "no
1351    // restart needed" would be the exact defect this block exists to
1352    // remove.
1353    // Verify only what is actually in the wiring files. A pre-existing
1354    // `memstead` entry was left untouched, so checking the binary
1355    // quickstart *would* have wired asserts nothing about that file —
1356    // seeded with a broken entry, the old check passed while the wiring
1357    // was broken. Fresh wirings (and the codex instruction) still
1358    // verify the resolved binary; preserved entries verify their own
1359    // command, or are stated as left-as-is when no plain command exists.
1360    let fresh_wiring = wirings.iter().any(|w| !w.preexisting);
1361    if fresh_wiring && mcp_bin.warning.is_none() {
1362        verify_now.push(("the wired binary answers", version_cmd));
1363    }
1364    let mut seen_existing: Vec<String> = Vec::new();
1365    for w in wirings.iter().filter(|w| w.preexisting) {
1366        match &w.existing_command {
1367            Some(cmd) if !seen_existing.contains(cmd) => {
1368                seen_existing.push(cmd.clone());
1369                verify_now.push((
1370                    "the pre-existing `memstead` entry's binary answers",
1371                    ShellCmd::new(cmd).arg("--version").render(),
1372                ));
1373            }
1374            _ => {}
1375        }
1376    }
1377    verify_now.push(("the graph is already readable", overview_cmd.clone()));
1378    if let Some(cmd) = &brief_cmd {
1379        verify_now.push(("the binding renders its ingest brief", cmd.clone()));
1380    }
1381
1382    // The honest brief: what the starter mem holds now, what it does not,
1383    // and what turns the second into the first. Every line is derived from
1384    // what was just written — the deny list comes off the record, the
1385    // commands off the builder — because a brief that is composed rather
1386    // than derived is exactly how printed claims drift from behaviour.
1387    // Built once per FRAME, not once: the brief names paths, and the
1388    // human receipt speaks from the reader's directory while the JSON
1389    // speaks workspace-relative. One rendering serving both is how a
1390    // payload ends up carrying a path that resolves in neither.
1391    let build_brief = |path: &dyn Fn(&str) -> String| -> Vec<String> {
1392        match (guided, &brief_cmd) {
1393            (Some(g), Some(brief)) => {
1394                let mut b = vec![
1395                    "## What this mem holds".to_string(),
1396                    String::new(),
1397                    format!(
1398                        "- Now: one seed entity (`{seed_id}`). Nothing else — scaffolding a \
1399                     binding reads no source file and creates no entity from one."
1400                    ),
1401                    format!(
1402                        "- Not yet: anything from `{}`. Its {} are the binding's subject, \
1403                     not its content.",
1404                        g.repo_display,
1405                        if g.is_git_repo {
1406                            "code, docs and history"
1407                        } else {
1408                            "files"
1409                        },
1410                    ),
1411                    format!(
1412                        "- Growth: the ingest loop against binding `{}` — one batch at a \
1413                     time, each entity written through the same validated path as the \
1414                     seed. Start with: `{brief}`, or follow the walkthrough at \
1415                     https://memstead.com/dev/guides/grow-a-mem-from-a-source/",
1416                        g.binding_id,
1417                    ),
1418                    format!(
1419                        "- Scope: everything under `{}`, minus what the record denies ({}) \
1420                     and minus {}, which the engine excludes unconditionally. The deny \
1421                     list is yours to edit: `{}`",
1422                        path(&g.pointer),
1423                        g.deny_paths
1424                            .iter()
1425                            .map(|d| format!("`{d}`"))
1426                            .collect::<Vec<_>>()
1427                            .join(", "),
1428                        // Say what this layout actually excludes. The mem's
1429                        // folder is only inside the scope — and only excluded
1430                        // by the mount rule — when it is a folder of its own;
1431                        // in the collapsed layout the workspace sits outside
1432                        // the source tree, so naming it here would claim an
1433                        // exclusion that never had to fire.
1434                        match &mem_folder_rel {
1435                            Some(rel) => {
1436                                format!("engine state and the mem's own folder `{}/`", path(rel))
1437                            }
1438                            None => "engine state (`.memstead/`)".to_string(),
1439                        },
1440                        path(&g.record),
1441                    ),
1442                    format!(
1443                        "- Operations the binding declares: {}",
1444                        g.operations.join(", ")
1445                    ),
1446                ];
1447                // What appeared in the reader's repository — the one claim
1448                // they can check with `git status` in ten seconds, so it is
1449                // stated in THEIR frame, not the workspace's. Every artifact
1450                // path quickstart holds is workspace-relative; the two frames
1451                // coincide only when the workspace is the repository, so the
1452                // paths are re-expressed against the repo root and the line
1453                // is omitted entirely when the workspace is somewhere else.
1454                if let Some(ws_rel) = &g.workspace_in_repo {
1455                    let in_repo = |p: &str| {
1456                        if ws_rel.is_empty() {
1457                            format!("`{p}`")
1458                        } else {
1459                            format!("`{ws_rel}/{p}`")
1460                        }
1461                    };
1462                    let mut written = Vec::new();
1463                    if target_created && !ws_rel.is_empty() {
1464                        // The whole workspace directory is what `git status`
1465                        // will show — one untracked path, not three inside it.
1466                        written.push(format!(
1467                            "`{ws_rel}/` (the workspace: its state, the binding record, \
1468                         the mem, and the agent wiring — plus the engine's cache, \
1469                         which appears inside it once the binding is first measured)"
1470                        ));
1471                    } else {
1472                        written.push(format!(
1473                            "{} (workspace state and the binding record; a sibling \
1474                         `.memstead.cache/` appears once the binding is first measured)",
1475                            in_repo(".memstead/")
1476                        ));
1477                        if let Some(rel) = &mem_folder_rel {
1478                            written.push(format!("{} (the mem)", in_repo(&format!("{rel}/"))));
1479                        }
1480                        // A config file that already existed was MODIFIED, not
1481                        // added — `preexisting` tracks the `memstead` server
1482                        // entry, which is a different fact from the file.
1483                        for w in wirings.iter().filter(|w| !w.preexisting) {
1484                            if let Some(f) = w.target.config_file() {
1485                                let verb = if w.file_existed {
1486                                    "agent wiring added to it"
1487                                } else {
1488                                    "agent wiring"
1489                                };
1490                                written.push(format!("{} ({verb})", in_repo(f)));
1491                            }
1492                        }
1493                    }
1494                    b.push(format!(
1495                        "- Written into your {}: {}. Nothing else in the tree was touched.",
1496                        if g.is_git_repo {
1497                            "repository"
1498                        } else {
1499                            "source directory"
1500                        },
1501                        written.join(", "),
1502                    ));
1503                }
1504                b
1505            }
1506            _ => Vec::new(),
1507        }
1508    };
1509    // The reader's frame for the printed receipt; the workspace frame for
1510    // the machine surface, where `workspace_root` is the resolution base.
1511    let brief_lines = build_brief(&from_here);
1512    let brief_lines_machine = build_brief(&|rel: &str| rel.to_string());
1513
1514    if ctx.json {
1515        let mut payload = json!({
1516            // Absolute, so a caller that passed a relative argument can
1517            // use these without reconstructing its own cwd.
1518            "workspace_root": absolute.display().to_string(),
1519            // The MEM's config, which lives under the MEM's folder — the
1520            // same directory as its entities. Only in the collapsed shape
1521            // is that also the workspace root, so this is derived from the
1522            // mem folder, never from the root.
1523            "config_path": config_path(&mem_absolute).display().to_string(),
1524            "seed_entity_delete_command": delete_cmd,
1525            "name": name,
1526            "schema": schema_pin.as_display(),
1527            "seed_entity": seed_id,
1528            "mcp_command": mcp_bin.command,
1529            "agents": wirings
1530                .iter()
1531                .map(|w| json!({
1532                    "target": w.target.to_possible_value().map(|v| v.get_name().to_string()),
1533                    "action": w.action.render(w.target, &|rel: &str| rel.to_string()),
1534                }))
1535                .collect::<Vec<_>>(),
1536            "agents_defaulted": agents_defaulted,
1537            "workspace_shape": crate::setup::WorkspaceShape::Filesystem.label(),
1538            // The agent surface gets the whole disclosure, not just the
1539            // label: which shape, what it cannot do, the command for
1540            // the other one — the same three parts the markdown block
1541            // carries, from the same value.
1542            "workspace_shape_disclosure":
1543                crate::setup::shape_disclosure_in(
1544                    crate::setup::WorkspaceShape::Filesystem,
1545                    mem_folder_rel.as_deref(),
1546                ).to_json(),
1547            "next_action": next_action,
1548            "verify_now": verify_now
1549                .iter()
1550                .map(|(what, command)| json!({ "what": what, "command": command }))
1551                .collect::<Vec<_>>(),
1552            "warnings": mcp_bin.warning.as_ref().map(|w| vec![w.clone()]).unwrap_or_default(),
1553        });
1554        // Guided-mode fields are additive and present only in guided mode:
1555        // a plain quickstart's JSON is the same document it has always been.
1556        if let Some(g) = guided {
1557            payload["mem_folder"] =
1558                json!(mem_folder_rel.clone().unwrap_or_else(|| ".".to_string()));
1559            payload["repo"] = json!(g.repo_display);
1560            payload["binding"] = json!({
1561                "id": g.binding_id,
1562                "pointer": g.pointer,
1563                "record": g.record,
1564                "deny_paths": g.deny_paths,
1565                "operations": g.operations,
1566            });
1567            payload["brief"] = json!(
1568                brief_lines_machine
1569                    .iter()
1570                    .filter(|l| l.starts_with("- "))
1571                    .map(|l| l.trim_start_matches("- ").to_string())
1572                    .collect::<Vec<_>>()
1573            );
1574            if !g.warnings.is_empty() {
1575                let mut w: Vec<String> = payload["warnings"]
1576                    .as_array()
1577                    .map(|a| {
1578                        a.iter()
1579                            .filter_map(|v| v.as_str().map(str::to_string))
1580                            .collect()
1581                    })
1582                    .unwrap_or_default();
1583                w.extend(g.warnings.iter().cloned());
1584                payload["warnings"] = json!(w);
1585            }
1586        }
1587        return print_json(&payload);
1588    }
1589
1590    let mut lines = vec![
1591        format!("# Quickstart complete — mem `{name}`"),
1592        String::new(),
1593        // The guided path is normally invoked as `--repo .`, and a
1594        // receipt that answers "where is it?" with "." tells the reader
1595        // nothing they did not type; resolve it for that case only.
1596        format!(
1597            "- Workspace:   `{}`",
1598            if guided.is_some() {
1599                absolute.display().to_string()
1600            } else {
1601                target.display().to_string()
1602            }
1603        ),
1604    ];
1605    if let Some(rel) = &mem_folder_rel {
1606        lines.push(format!(
1607            "- Mem folder:  `{}/` (the graph owns this folder and nothing else)",
1608            from_here(rel),
1609        ));
1610    }
1611    lines.push(format!("- Schema pin:  `{}`", schema_pin.as_display()));
1612    lines.push(format!(
1613        "- Seed entity: `{seed_id}` (remove any time: `{delete_cmd}`)"
1614    ));
1615    if let Some(g) = guided {
1616        lines.push(format!(
1617            "- Binding:     `{}` over `{}` (record: `{}`)",
1618            g.binding_id,
1619            from_here(&g.pointer),
1620            from_here(&g.record),
1621        ));
1622    }
1623    for w in wirings {
1624        lines.push(format!(
1625            "- {}: {}",
1626            w.target.label(),
1627            w.action.render(w.target, &from_here),
1628        ));
1629    }
1630    if agents_defaulted {
1631        lines.push(
1632            "- No `--agent` given and no terminal to ask — defaulted to Claude Code \
1633             (re-run with `--agent` for others)"
1634                .to_string(),
1635        );
1636    }
1637    let mut warnings: Vec<String> = mcp_bin.warning.iter().cloned().collect();
1638    warnings.extend(guided.iter().flat_map(|g| g.warnings.iter().cloned()));
1639    if !warnings.is_empty() {
1640        lines.push(String::new());
1641        for warning in &warnings {
1642            lines.push(format!("> warning: {warning}"));
1643        }
1644    }
1645    if !brief_lines.is_empty() {
1646        lines.push(String::new());
1647        lines.extend(brief_lines.iter().cloned());
1648    }
1649    // The shape disclosure sits between the artifact list and the next
1650    // action: quickstart picked one of two workspace shapes just now,
1651    // and this receipt is the only output the newcomer is guaranteed
1652    // to read before they hit the first mem-repo-only refusal.
1653    lines.push(String::new());
1654    lines.extend(crate::setup::shape_disclosure_lines_in(
1655        crate::setup::WorkspaceShape::Filesystem,
1656        mem_folder_here.as_deref(),
1657    ));
1658    lines.push(String::new());
1659    lines.push(format!("Next: {next_action}"));
1660    lines.push(String::new());
1661    lines.push("Verify from this session, no restart needed:".to_string());
1662    lines.extend(
1663        verify_now
1664            .iter()
1665            .map(|(what, command)| format!("- {what}: `{command}`")),
1666    );
1667    print_markdown(&lines.join("\n"));
1668    Ok(())
1669}
1670
1671#[cfg(test)]
1672mod tests {
1673    use super::*;
1674
1675    #[test]
1676    fn derive_mem_name_handles_common_directory_names() {
1677        assert_eq!(derive_mem_name("my-graph").as_deref(), Some("my-graph"));
1678        assert_eq!(derive_mem_name("My Project").as_deref(), Some("my-project"));
1679        assert_eq!(
1680            derive_mem_name("Notes_2026 (v2)").as_deref(),
1681            Some("notes-2026-v2")
1682        );
1683        // Nothing valid survives: prompt/refusal path.
1684        assert_eq!(derive_mem_name("日本語"), None);
1685        assert_eq!(derive_mem_name(""), None);
1686        // Single char fails the two-char slug rule.
1687        assert_eq!(derive_mem_name("a"), None);
1688    }
1689
1690    #[test]
1691    fn blocking_entries_tolerates_dotfiles_and_readme_grade() {
1692        let tmp = tempfile::tempdir().unwrap();
1693        for f in [".gitignore", ".mcp.json", "README", "LICENSE", "Readme.txt"] {
1694            std::fs::write(tmp.path().join(f), b"x").unwrap();
1695        }
1696        std::fs::create_dir(tmp.path().join(".git")).unwrap();
1697        assert!(blocking_entries(tmp.path()).unwrap().is_empty());
1698
1699        // A `.md` README blocks — the folder backend would adopt it as
1700        // an entity, and quickstart never ingests user content.
1701        std::fs::write(tmp.path().join("README.md"), b"# hi").unwrap();
1702        assert_eq!(blocking_entries(tmp.path()).unwrap(), vec!["`README.md`"]);
1703        std::fs::remove_file(tmp.path().join("README.md")).unwrap();
1704
1705        std::fs::write(tmp.path().join("main.rs"), b"fn main() {}").unwrap();
1706        assert_eq!(blocking_entries(tmp.path()).unwrap(), vec!["`main.rs`"]);
1707    }
1708
1709    #[test]
1710    fn wire_agent_merges_and_never_overwrites() {
1711        let tmp = tempfile::tempdir().unwrap();
1712        // Fresh write.
1713        let outcome = wire_agent(tmp.path(), AgentTarget::ClaudeCode, "/bin/memstead-mcp").unwrap();
1714        let rendered = outcome
1715            .action
1716            .render(outcome.target, &|rel: &str| rel.to_string());
1717        assert!(rendered.contains("wrote"), "got: {rendered}");
1718        let parsed: serde_json::Value =
1719            serde_json::from_slice(&std::fs::read(tmp.path().join(".mcp.json")).unwrap()).unwrap();
1720        assert_eq!(
1721            parsed["mcpServers"]["memstead"]["command"],
1722            "/bin/memstead-mcp"
1723        );
1724
1725        // Existing foreign server entries survive; existing `memstead`
1726        // entry is never overwritten.
1727        std::fs::write(
1728            tmp.path().join(".mcp.json"),
1729            serde_json::to_vec_pretty(&serde_json::json!({
1730                "mcpServers": {
1731                    "other": { "command": "/bin/other" },
1732                    "memstead": { "command": "/custom/memstead-mcp" },
1733                }
1734            }))
1735            .unwrap(),
1736        )
1737        .unwrap();
1738        let outcome = wire_agent(tmp.path(), AgentTarget::ClaudeCode, "/bin/memstead-mcp").unwrap();
1739        let rendered = outcome
1740            .action
1741            .render(outcome.target, &|rel: &str| rel.to_string());
1742        assert!(rendered.contains("left untouched"), "got: {rendered}");
1743        let parsed: serde_json::Value =
1744            serde_json::from_slice(&std::fs::read(tmp.path().join(".mcp.json")).unwrap()).unwrap();
1745        assert_eq!(
1746            parsed["mcpServers"]["memstead"]["command"],
1747            "/custom/memstead-mcp"
1748        );
1749        assert_eq!(parsed["mcpServers"]["other"]["command"], "/bin/other");
1750    }
1751
1752    #[test]
1753    fn shell_quote_leaves_ordinary_paths_alone_and_quotes_the_rest() {
1754        assert_eq!(
1755            shell_quote("/usr/local/bin/memstead-mcp"),
1756            "/usr/local/bin/memstead-mcp"
1757        );
1758        assert_eq!(shell_quote("my-graph"), "my-graph");
1759        // The case that motivated this: a directory name with a space.
1760        assert_eq!(shell_quote("My Graph"), "'My Graph'");
1761        assert_eq!(
1762            shell_quote("/Users/a b/bin/memstead-mcp"),
1763            "'/Users/a b/bin/memstead-mcp'"
1764        );
1765        // Shell metacharacters are contained, not executed.
1766        assert_eq!(shell_quote("a;rm -rf /"), "'a;rm -rf /'");
1767        assert_eq!(shell_quote("$(whoami)"), "'$(whoami)'");
1768        // An embedded single quote closes, escapes, and reopens.
1769        assert_eq!(shell_quote("it's"), r"'it'\''s'");
1770        assert_eq!(shell_quote(""), "''");
1771    }
1772
1773    #[test]
1774    fn wire_agent_codex_prints_command_writes_nothing() {
1775        let tmp = tempfile::tempdir().unwrap();
1776        let outcome = wire_agent(tmp.path(), AgentTarget::Codex, "/bin/memstead-mcp").unwrap();
1777        let rendered = outcome
1778            .action
1779            .render(outcome.target, &|rel: &str| rel.to_string());
1780        assert!(
1781            rendered.contains("codex mcp add memstead -- /bin/memstead-mcp"),
1782            "got: {rendered}",
1783        );
1784        assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0);
1785    }
1786}