Skip to main content

memstead_cli/commands/
mem.rs

1//! `memstead mem init` / `memstead mem delete` — full-only mem-lifecycle
2//! CLI front-ends.
3//!
4//! Both subcommands call the
5//! engine in-process via `memstead_engine::mem_management::create_mem` /
6//! `delete_mem`. An earlier design spawned `memstead-mcp --operator-mode`
7//! as a child process and drove the matching MCP tool over JSON-RPC
8//! — wire-format parity with the agent path was the intent,
9//! but the CLI-as-MCP-consumer relationship cut against the layering
10//! posture that CLI, MCP, and UniFFI are sibling surfaces over the
11//! engine (AGENTS.md's parity rule). In-process
12//! collapses CLI and MCP onto the same Rust call, with `operator_mode:
13//! true` hardcoded at the call site so the engine bypasses the
14//! `[[mem_management.create]]` / `[[mem_management.delete]]`
15//! allowlists and the `MEM_REFERENCED_BY_POLICY` safeguard for these
16//! two operator-tool surfaces (matching the spirit of the
17//! transport-establishes-posture rule).
18//!
19//! Outer-repo gitignore: a CLI-only concern. The shared helper in
20//! [`crate::outer_gitignore`] walks upward from the workspace root
21//! looking for an enclosing `.git/`, then idempotently appends the
22//! workspace path (or `mem-repo/` inside it) to the outer repo's
23//! `.gitignore`. Refused for `$HOME` and disabled by `--no-gitignore`.
24
25use std::path::PathBuf;
26
27use clap::{Args, Subcommand};
28
29use crate::CliError;
30use crate::outer_gitignore::{OuterRepoOutcome, apply_outer_gitignore};
31use crate::output::ExitKind;
32use crate::setup::{CliContext, CliEngine, find_workspace_root};
33use memstead_engine::mem_management::{
34    self, MemCreateParams, MemCreateResponse, MemDeleteParams, MemDeleteResponse,
35};
36
37/// Subcommands under `memstead mem`.
38#[derive(Subcommand, Debug)]
39pub enum MemAction {
40    /// Register a new mem via the engine's mem-management
41    /// orchestrator.
42    Init(InitArgs),
43    /// Router-only removal — unregisters the mem from the workspace
44    /// but leaves its stored content in place for archive workflows.
45    /// Cross-mem grants pointing at the unregistered mem stay valid
46    /// (the data they rely on survives); a follow-up `memstead mem init
47    /// <same name>` re-attaches against the preserved storage. Refuses
48    /// with `MEM_HAS_INCOMING_REFS` when entities in other mems still
49    /// link into this one — remove those incoming cross-mem references
50    /// first (mirrors `mem delete`'s precondition).
51    Unregister(UnregisterArgs),
52    /// Storage-destroying removal — unregisters the mem AND deletes
53    /// its stored content. Refuses with `MEM_REFERENCED_BY_POLICY`
54    /// when any other writable mem has a `cross_mem_links` grant
55    /// pointing at the target (revoke the grant first). For router-only
56    /// removal that keeps the storage, use `memstead mem unregister`.
57    Delete(DeleteArgs),
58    /// Update a mem's `version` field. The version is consumed by
59    /// `memstead export --format mem` to stamp the archive filename and
60    /// the `.mem` archive's published config. `version` is seeded at
61    /// init (`0.1.0`); bump via this command before publishing.
62    #[command(name = "set-version")]
63    SetVersion(SetVersionArgs),
64    /// Set a mem's schema pin — the integrity-driven schema-migration
65    /// trigger. Already-integral mems switch immediately; otherwise
66    /// the mem enters dual-pin migration (writes validate against
67    /// the target) and the response lists the non-integral entities.
68    /// Re-issue after repairing to complete the switch.
69    #[command(name = "set-schema")]
70    SetSchema(SetSchemaArgs),
71    /// Set a mem's one-line `description` — embedded in `.mem` archive
72    /// exports and surfaced on the registry card at publish time. An
73    /// empty string clears the field. Set it before `memstead export` /
74    /// `memstead publish` so the shared archive carries its card text.
75    #[command(name = "set-description")]
76    SetDescription(SetDescriptionArgs),
77    /// Set (or clear) one opaque sync-state token in a mem's config —
78    /// the ingest layer's durable "last synced source state" baseline.
79    /// `<KEY>` and `<TOKEN>` are opaque to the engine (the ingest layer
80    /// keys per `(ingest, facet)` and owns the token's meaning). An
81    /// empty `<TOKEN>` clears the key. Written into the per-mem config
82    /// and surfaced verbatim on `memstead workspace dump`.
83    #[command(name = "set-sync-state")]
84    SetSyncState(SetSyncStateArgs),
85    /// Enumerate every mounted mem in the workspace with its
86    /// schema pin, version, entity count, and capability (writable
87    /// vs read-only). Markdown by default; pass `--json` (root flag)
88    /// for the structured envelope.
89    List(ListArgs),
90}
91
92/// `memstead mem list` — no positional args. The verb itself is the
93/// signal; `--json` (root-level) toggles the output shape.
94#[derive(Args, Debug)]
95pub struct ListArgs {}
96
97/// `memstead mem set-version <NAME> <VERSION>` arguments.
98#[derive(Args, Debug)]
99pub struct SetVersionArgs {
100    /// Mem name (the leaf-folder identifier the engine assigned at
101    /// init time). Must already be registered in the workspace.
102    pub name: String,
103
104    /// New semver version (e.g. `0.2.0`, `1.0.0-beta.1`). Malformed
105    /// values refuse with `INVALID_INPUT`. The engine bypasses the
106    /// mem-create allowlist for this surface — set-version is
107    /// gate-free.
108    pub version: String,
109
110    /// Optional provenance note (≤280 chars) recorded on the
111    /// version-bump commit body, like the other commit-producing
112    /// mem-lifecycle commands. When the workspace sets
113    /// `require_notes`, omitting it rides a non-blocking `NOTE_MISSING`
114    /// warning (the bump still lands).
115    #[arg(long)]
116    pub note: Option<String>,
117}
118
119/// `memstead mem set-description <NAME> <DESCRIPTION>` arguments.
120#[derive(Args, Debug)]
121pub struct SetDescriptionArgs {
122    /// Mem name (must be registered in the workspace).
123    pub name: String,
124
125    /// One-line description of the mem — what a registry visitor (or
126    /// an agent browsing the catalogue) should know before installing.
127    /// An empty string clears the field.
128    pub description: String,
129
130    /// Optional provenance note (≤280 chars) recorded on the commit
131    /// body, like the other commit-producing mem-lifecycle commands.
132    #[arg(long)]
133    pub note: Option<String>,
134}
135
136/// `memstead mem set-sync-state <NAME> <KEY> <TOKEN>` arguments.
137#[derive(Args, Debug)]
138pub struct SetSyncStateArgs {
139    /// Mem name (must be registered in the workspace).
140    pub name: String,
141
142    /// Opaque sync-state key. The ingest layer keys per `(ingest,
143    /// facet)`, conventionally `"<ingest>/<facet>"`, but the engine
144    /// treats it as an arbitrary string.
145    pub key: String,
146
147    /// Opaque token recording the source state last synced under
148    /// `<KEY>` (git → commit id, graph → snapshot token, filesystem →
149    /// a JSON-stringified stat digest). An **empty** value clears the
150    /// key. The engine never parses it.
151    pub token: String,
152
153    /// Optional provenance note (≤280 chars) recorded on the commit
154    /// body, like the other commit-producing mem-lifecycle commands.
155    #[arg(long)]
156    pub note: Option<String>,
157}
158
159/// `memstead mem set-schema <NAME> <SCHEMA>` arguments.
160#[derive(Args, Debug)]
161pub struct SetSchemaArgs {
162    /// Mem name (must be registered in the workspace).
163    pub name: String,
164
165    /// Target schema ref, exact `name@x.y.z`. Must resolve against
166    /// the loaded schema catalogue; unresolvable refs refuse with
167    /// `SCHEMA_NOT_FOUND`, malformed refs with `INVALID_INPUT`.
168    pub schema: String,
169}
170
171/// `memstead mem init <path>` arguments.
172///
173/// `--vcs-shared` translates into the engine's `vcs` block;
174/// `--no-gitignore` suppresses the outer-repo `.gitignore` append. The
175/// `<path>` argument supplies the new mem's `location` (relative
176/// to the workspace root) plus its `name` (basename of the path); a
177/// slashed `<a>/<b>` form additionally derives `--org-path a` so
178/// `memstead mem init a/b` and `memstead mem init b --org-path a` produce
179/// identical engine calls. Cross-mem edge authorization is
180/// workspace-level policy (`[cross_mem_links]` in `.memstead/workspace.toml`); the
181/// previous `--belongs-to` flag is gone.
182#[derive(Args, Debug)]
183pub struct InitArgs {
184    /// Mem name — the full hierarchical identifier (e.g. `foo` for
185    /// a flat-layout mem, `team/sub-mem` for a hierarchical
186    /// layout). The value flows through to the engine verbatim with no
187    /// auto-split or composition step. Grammar:
188    /// `[a-z0-9-]+(/[a-z0-9-]+)*` — lowercase ASCII letters, digits,
189    /// hyphens; segments separated by `/`; no leading, trailing, or
190    /// double slashes (validated engine-side; bad names return
191    /// `INVALID_INPUT`).
192    pub path: PathBuf,
193
194    /// Schema pin (`name@x.y.z`) for the new mem. Defaults to
195    /// `default@1.0.0` so the common case stays one argument.
196    #[arg(long, default_value = "default@1.0.0")]
197    pub schema: String,
198
199    /// Pass a shared-gitdir `vcs` block to `memstead_mem_create`:
200    /// `{ "gitdir": "../.git", "worktree": ".." }`. Without this flag the
201    /// engine uses the default isolated layout.
202    #[arg(long)]
203    pub vcs_shared: bool,
204
205    /// Skip outer-repo `.gitignore` auto-append. Useful when the user
206    /// intends to track the workspace as a git submodule, or when the
207    /// detection heuristic would pick the wrong outer repo.
208    #[arg(long)]
209    pub no_gitignore: bool,
210
211    /// Optional provenance note recorded in the seed commit's body
212    /// (≤280 chars). Forwarded as the MCP tool's `note` parameter.
213    #[arg(long)]
214    pub note: Option<String>,
215
216    /// Adopt residual entities left by a prior `memstead mem unregister`
217    /// at this mem's path instead of failing on detected residue.
218    /// Default when the residue carries an `unregistered_at` tombstone
219    /// (the deliberate unregister signal); pass `--reattach` explicitly
220    /// to override for crash-residue you have verified is safe to adopt.
221    /// Mutually exclusive with `--force-overwrite` and
222    /// `--hard-cleanup-first`.
223    #[arg(long, group = "recovery_action")]
224    pub reattach: bool,
225
226    /// Destroy residual storage at this mem's path and proceed with a
227    /// fresh create. **Not yet implemented** — currently refuses with
228    /// `INVALID_INPUT` pointing at `memstead mem delete <name>`. Mutually
229    /// exclusive with `--reattach` and `--hard-cleanup-first`.
230    #[arg(long = "force-overwrite", group = "recovery_action")]
231    pub force_overwrite: bool,
232
233    /// Refuse with `MEM_STORAGE_RESIDUE_DETECTED` instructing the
234    /// caller to run `memstead mem delete <name>` first — a hard barrier
235    /// that keeps residue cleanup a separate, named operation rather
236    /// than destructive auto-recovery. Mutually exclusive with
237    /// `--reattach` and `--force-overwrite`.
238    #[arg(long = "hard-cleanup-first", group = "recovery_action")]
239    pub hard_cleanup_first: bool,
240
241    /// Bypass the workspace `[[mem_management.create]]` allowlist
242    /// for this invocation. The CLI honours the allowlist by default
243    /// (matching the MCP-surface posture); operator-mode is explicit
244    /// opt-in. Also settable via the `MEMSTEAD_OPERATOR_MODE=1` env var for
245    /// script convenience; the flag wins when both are set. Use this
246    /// when the CLI invocation is the operator administering the
247    /// workspace itself (initial scaffold, recovery flows) rather than
248    /// scripted/agent usage.
249    #[arg(long = "operator-mode")]
250    pub operator_mode: bool,
251
252    /// Optional per-instance writing guidance as a JSON object, written
253    /// verbatim into the new mem's config `writeGuidance` map — e.g.
254    /// `--write-guidance '{"phase_context":"early design","stack":"Rust"}'`.
255    /// Opaque to the engine (schema-strictness D8 — the keys are
256    /// client-owned vocabulary); a wrapper that read the schema
257    /// package's `mem-template.json` fills the instance keys. Omit to
258    /// seed no guidance. Must be a JSON object; anything else refuses
259    /// with `INVALID_INPUT`.
260    #[arg(long = "write-guidance")]
261    pub write_guidance: Option<String>,
262}
263
264/// `memstead mem delete <name>` arguments — full destruction. The
265/// CLI honours the workspace `[[mem_management.delete]]`
266/// allowlist by default; pass `--operator-mode` or set
267/// `MEMSTEAD_OPERATOR_MODE=1` to skip the allowlist. The
268/// `MEM_REFERENCED_BY_POLICY` and `MEM_HAS_INCOMING_REFS`
269/// safeguards always fire regardless of operator-mode. The verb
270/// uniquely identifies the storage-destroying intent — use
271/// `memstead mem unregister` for router-only removal.
272///
273/// On success delete scrubs only the now-dangling
274/// `[cross_mem_links]` grants naming this mem on either side
275/// (reported in the `## Allowlist entries scrubbed` block of the
276/// response) — those reference the gone instance and would otherwise
277/// dangle. The workspace's `[[mem_management.create]]` /
278/// `[[mem_management.delete]]` allowlist rules are PRESERVED, exact
279/// name and glob alike: they are forward-looking permissions for the
280/// name, not references to the instance. Re-creating a mem of the
281/// same name afterward needs no fresh `allow-create` / `allow-delete`
282/// grant.
283#[derive(Args, Debug)]
284pub struct DeleteArgs {
285    /// Name of the mem to destroy.
286    pub name: String,
287
288    /// Optional provenance note (≤280 chars). Captured on the engine
289    /// trace surface; surfaces via the outer-repo Stop hook. No
290    /// per-mem commit is produced by delete.
291    #[arg(long)]
292    pub note: Option<String>,
293
294    /// Bypass the workspace `[[mem_management.delete]]` allowlist
295    /// for this invocation. See `InitArgs::operator_mode` for the
296    /// full design rationale. Also settable via `MEMSTEAD_OPERATOR_MODE=1`.
297    #[arg(long = "operator-mode")]
298    pub operator_mode: bool,
299}
300
301/// `memstead mem unregister <name>` arguments — router-only removal,
302/// storage preserved. The CLI honours the workspace
303/// `[[mem_management.delete]]` allowlist by default; pass
304/// `--operator-mode` or set `MEMSTEAD_OPERATOR_MODE=1` to skip the
305/// allowlist. The `MEM_REFERENCED_BY_POLICY` safeguard does not
306/// apply to unregister (storage is preserved), so unregistering a
307/// mem with cross-mem grants pointing at it succeeds without
308/// refusing — the data the grants rely on survives.
309///
310/// Refuses with `MEM_HAS_INCOMING_REFS` when an entity in another
311/// Write-Mem still carries a graph edge into this mem (`details.referrers`
312/// names each `{from_id, rel_types, mem}`) — remove those edges via
313/// `memstead relate --remove` / `memstead update` first. This guard fires for
314/// `unregister` just as it does for `delete`: the edge-graph axis is
315/// independent of the storage-preservation choice, so a gentle
316/// removal that left dangling cross-mem edges would be just as broken.
317#[derive(Args, Debug)]
318pub struct UnregisterArgs {
319    /// Name of the mem to unregister.
320    pub name: String,
321
322    /// Optional provenance note (≤280 chars). Captured on the engine
323    /// trace surface; surfaces via the outer-repo Stop hook.
324    #[arg(long)]
325    pub note: Option<String>,
326
327    /// Bypass the workspace `[[mem_management.delete]]` allowlist
328    /// for this invocation. See `InitArgs::operator_mode` for the
329    /// full design rationale. Also settable via `MEMSTEAD_OPERATOR_MODE=1`.
330    #[arg(long = "operator-mode")]
331    pub operator_mode: bool,
332}
333
334pub fn run(ctx: &CliContext, args: InitArgs) -> anyhow::Result<()> {
335    let cwd = std::env::current_dir()
336        .map_err(|e| generic_error(format!("determine current directory: {e}")))?;
337
338    // Locate the workspace via the post-rebuild marker
339    // (`.memstead/workspace.toml`). The presence of this file is the
340    // engine's own boot precondition — `memstead-mcp` walks for it too.
341    let workspace_root = find_workspace_root(&cwd).ok_or_else(|| {
342        validation_error(format!(
343            "no workspace found above {}. Run `memstead mem-repo init` first or \
344             change directory into an existing workspace.",
345            cwd.display(),
346        ))
347    })?;
348
349    // Hierarchical paths are first-class mem identifiers. The CLI forwards
350    // the `<PATH>` argument verbatim as `params.name` (`team/sub-mem`
351    // or just `sub-mem` — the engine's mem-name grammar
352    // validates the shape). There is no `--org-path` flag or path-vs-name
353    // auto-split — the value flows through unchanged.
354    let mem_name = args.path.to_str().map(|s| s.to_string()).ok_or_else(|| {
355        invalid_input_error(format!(
356            "mem name {:?} is not valid UTF-8 — mem names must be ASCII \
357                 (lowercase letters / digits / hyphens) optionally segmented by '/'.",
358            args.path.display(),
359        ))
360    })?;
361    let location = mem_name.clone();
362
363    let schema_ref: memstead_schema::SchemaRef = args
364        .schema
365        .parse()
366        .map_err(|e| invalid_input_error(format!("invalid schema ref {:?}: {e}", args.schema)))?;
367    let vcs_config = if args.vcs_shared {
368        Some(memstead_schema::VcsConfig {
369            gitdir: "../.git".to_string(),
370            worktree: "..".to_string(),
371        })
372    } else {
373        None
374    };
375    let write_guidance = match &args.write_guidance {
376        None => std::collections::HashMap::new(),
377        Some(raw) => {
378            serde_json::from_str::<std::collections::HashMap<String, serde_json::Value>>(raw)
379                .map_err(|e| {
380                    invalid_input_error(format!("--write-guidance must be a JSON object: {e}"))
381                })?
382        }
383    };
384    let params = MemCreateParams {
385        name: mem_name.clone(),
386        location: PathBuf::from(&location),
387        schema_ref,
388        vcs: vcs_config,
389        note: args.note.clone(),
390        write_guidance,
391        // The workspace `[[mem_management.create]]`
392        // allowlist applies to CLI calls by default; the operator
393        // opts into bypass explicitly via `--operator-mode` (flag
394        // wins) or `MEMSTEAD_OPERATOR_MODE=1` (env-var fallback).
395        operator_mode: resolve_operator_mode(args.operator_mode),
396        recovery: recovery_from_flags(args.reattach, args.force_overwrite, args.hard_cleanup_first),
397    };
398
399    let mut engine = match ctx.cli_engine()? {
400        CliEngine::MemRepo(e) => e,
401        CliEngine::Filesystem(_) => {
402            return Err(validation_error(format!(
403                "`memstead mem init` requires a mem-repo workspace; the workspace at {} is filesystem-shaped. Use `memstead mem-repo init` first to migrate.",
404                workspace_root.display(),
405            )));
406        }
407    };
408    let response =
409        mem_management::create_mem(&mut engine, params).map_err(pro_engine_err_to_cli)?;
410    if ctx.json {
411        crate::output::print_json(&serde_json::json!({
412            "name": response.name,
413            "location": response.location,
414            "schema_ref": response.schema_ref.to_string(),
415            "seed_commit_sha": response.seed_commit_sha,
416            // The reattach branch surfaces `MEM_REATTACHED_AFTER_UNREGISTER`
417            // through the response envelope rather than dropping it on
418            // the floor. Fresh-create ships an empty array.
419            "warnings": response
420                .warnings
421                .iter()
422                .map(|w| serde_json::json!({"code": w.code(), "message": w.message()}))
423                .collect::<Vec<_>>(),
424        }))?;
425    } else {
426        crate::output::print_markdown(&render_mem_create_markdown(&response));
427    }
428
429    // Outer-repo gitignore handling. Append `mem-repo/` (the post-cutover
430    // gitignore target — every mem's content lives inside that one
431    // directory) to the outer repo's `.gitignore`. Idempotent on re-run;
432    // refuses when the outer is `$HOME`.
433    if !args.no_gitignore {
434        let mem_repo_path = workspace_root.join("mem-repo");
435        let walk_start = workspace_root
436            .parent()
437            .map(|p| p.to_path_buf())
438            .unwrap_or_else(|| workspace_root.clone());
439        // Outer-repo provenance is human-facing context, not part of the
440        // structured result. It goes to stderr — never stdout — so a `--json`
441        // caller's stdout stays exactly one JSON document (the contract
442        // `--help` advertises and steers callers to pipe through `jq`). A
443        // human still sees it on the terminal in normal runs; `--quiet`
444        // suppresses it, the first time this site consults the flag.
445        match apply_outer_gitignore(&walk_start, &mem_repo_path)? {
446            OuterRepoOutcome::Appended { outer_root, rel } => {
447                if !ctx.quiet {
448                    eprintln!(
449                        "  outer:    {} — added `{}` to .gitignore",
450                        outer_root.display(),
451                        rel,
452                    );
453                }
454            }
455            OuterRepoOutcome::AlreadyIgnored { outer_root, rel } => {
456                if !ctx.quiet {
457                    eprintln!(
458                        "  outer:    {} — `{}` already in .gitignore, no change",
459                        outer_root.display(),
460                        rel,
461                    );
462                }
463            }
464            OuterRepoOutcome::NoOuter | OuterRepoOutcome::Skipped => {}
465        }
466    }
467
468    // Client-side mem-template consumption: when the operator did not
469    // supply --write-guidance, surface the resolved schema's
470    // mem-template instance keys so they know what to fill. The engine
471    // treats `writeGuidance` opaquely — filling is the operator's job.
472    if let Some(note) =
473        mem_template_guidance_note(&response.schema_ref, args.write_guidance.is_some())
474        && !ctx.quiet
475    {
476        eprintln!("  template: {note}");
477    }
478
479    Ok(())
480}
481
482/// When the operator did not supply `--write-guidance`, surface the
483/// resolved (built-in) schema's `mem-template.json` instance guidance
484/// keys so they know what to fill. Returns the operator notice, or
485/// `None` when there is nothing to surface — guidance was already given,
486/// the schema ships no template, or its template carries no guidance.
487/// Reads only built-in templates; an installed/authored package's
488/// template is a follow-up.
489fn mem_template_guidance_note(
490    schema_ref: &memstead_schema::SchemaRef,
491    guidance_given: bool,
492) -> Option<String> {
493    if guidance_given {
494        return None;
495    }
496    let template = memstead_schema::builtins::builtin_mem_template(&schema_ref.name)?;
497    let wg = template.get("writeGuidance")?.as_object()?;
498    if wg.is_empty() {
499        return None;
500    }
501    let keys: Vec<&str> = wg.keys().map(String::as_str).collect();
502    let first = keys.first().copied().unwrap_or("key");
503    Some(format!(
504        "schema {schema_ref} ships a mem-template with instance guidance key(s) [{}] — \
505         the mem was created without guidance. Re-run with \
506         --write-guidance '{{\"{first}\": \"…\"}}' (or edit the mem config) to fill them.",
507        keys.join(", "),
508    ))
509}
510
511pub fn run_delete(ctx: &CliContext, args: DeleteArgs) -> anyhow::Result<()> {
512    run_delete_inner(
513        ctx,
514        args.name,
515        args.note,
516        /* delete_files */ true,
517        "delete",
518        resolve_operator_mode(args.operator_mode),
519    )
520}
521
522pub fn run_unregister(ctx: &CliContext, args: UnregisterArgs) -> anyhow::Result<()> {
523    run_delete_inner(
524        ctx,
525        args.name,
526        args.note,
527        /* delete_files */ false,
528        "unregister",
529        resolve_operator_mode(args.operator_mode),
530    )
531}
532
533/// Resolve the effective operator-mode for a CLI invocation. The
534/// workspace allowlist applies by default; the operator opts into
535/// bypass via `--operator-mode` (highest precedence) or the
536/// `MEMSTEAD_OPERATOR_MODE` env var. The env-var accepts `1`, `true`, `yes`
537/// (case-insensitive); any other value is treated as unset.
538fn resolve_operator_mode(flag: bool) -> bool {
539    if flag {
540        return true;
541    }
542    match std::env::var("MEMSTEAD_OPERATOR_MODE") {
543        Ok(v) => matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"),
544        Err(_) => false,
545    }
546}
547
548fn run_delete_inner(
549    ctx: &CliContext,
550    name: String,
551    note: Option<String>,
552    delete_files: bool,
553    verb: &str,
554    operator_mode: bool,
555) -> anyhow::Result<()> {
556    let cwd = std::env::current_dir()
557        .map_err(|e| generic_error(format!("determine current directory: {e}")))?;
558    let workspace_root = find_workspace_root(&cwd).ok_or_else(|| {
559        validation_error(format!(
560            "no workspace found above {}. `memstead mem {verb}` must run \
561             inside a configured workspace.",
562            cwd.display(),
563        ))
564    })?;
565
566    let params = MemDeleteParams {
567        name: name.clone(),
568        delete_files,
569        note: note.clone(),
570        operator_mode,
571    };
572    let mut engine = match ctx.cli_engine()? {
573        CliEngine::MemRepo(e) => e,
574        CliEngine::Filesystem(_) => {
575            return Err(validation_error(format!(
576                "`memstead mem {verb}` requires a mem-repo workspace; the workspace at {} is filesystem-shaped.",
577                workspace_root.display(),
578            )));
579        }
580    };
581    let response =
582        mem_management::delete_mem(&mut engine, params).map_err(pro_engine_err_to_cli)?;
583    if ctx.json {
584        crate::output::print_json(&serde_json::json!({
585            "name": response.name,
586            "deleted_from_router": response.deleted_from_router,
587            "files_deleted": response.files_deleted,
588            "warnings": response
589                .warnings
590                .iter()
591                .map(|w| serde_json::json!({"code": w.code(), "message": w.message()}))
592                .collect::<Vec<_>>(),
593            // Surface scrubbed `.memstead/workspace.toml` entries so the
594            // agent sees every policy side effect in one round-trip.
595            "allowlist_entries_removed": &response.allowlist_entries_removed,
596        }))?;
597    } else {
598        crate::output::print_markdown(&render_mem_delete_markdown(&response, verb));
599    }
600    Ok(())
601}
602
603/// Render a successful `MemCreateResponse` as a CLI markdown block.
604/// The CLI owns its own prose rather than echoing the MCP subprocess's
605/// pre-rendered text channel.
606fn render_mem_create_markdown(r: &MemCreateResponse) -> String {
607    // The reattach
608    // branch surfaces a `MEM_REATTACHED_AFTER_UNREGISTER` warning on
609    // the response. Adjust the heading so an operator picking up an
610    // empty `seed_commit_sha` plus the reattach warning learns the
611    // branch tip kept its prior history rather than starting fresh.
612    let reattached = r.warnings.iter().any(|w| {
613        matches!(
614            w,
615            memstead_base::ops::WarningHint::MemReattachedAfterUnregister { .. }
616        )
617    });
618    let heading = if reattached {
619        format!("# Mem `{}` reattached\n\n", r.name)
620    } else {
621        format!("# Mem `{}` created\n\n", r.name)
622    };
623    let mut out = heading;
624    out.push_str(&format!("- Location: `{}`\n", r.location.display()));
625    out.push_str(&format!("- Schema: `{}`\n", r.schema_ref));
626    out.push_str(&format!("- Seed commit: `{}`\n", r.seed_commit_sha));
627    if !r.warnings.is_empty() {
628        out.push_str("\n## Warnings\n\n");
629        for w in &r.warnings {
630            out.push_str(&format!("- **{}**: {}\n", w.code(), w.message()));
631        }
632    }
633    out
634}
635
636/// Render a successful `MemDeleteResponse` as a CLI markdown block.
637/// `verb` is the CLI subcommand name (`"delete"` or `"unregister"`)
638/// — drives the heading prose so the output matches the user's
639/// invocation.
640fn render_mem_delete_markdown(r: &MemDeleteResponse, verb: &str) -> String {
641    let past_participle = match verb {
642        "unregister" => "unregistered",
643        _ => "deleted",
644    };
645    let mut out = format!("# Mem `{}` {past_participle}\n\n", r.name);
646    out.push_str(&format!(
647        "- Removed from router: {}\n",
648        r.deleted_from_router,
649    ));
650    out.push_str(&format!("- Files deleted: {}\n", r.files_deleted));
651    // Surface every scrubbed `.memstead/workspace.toml` entry so the
652    // operator sees what the destructive delete just cleaned up.
653    if !r.allowlist_entries_removed.is_empty() {
654        out.push_str("\n## Allowlist entries scrubbed\n\n");
655        for entry in &r.allowlist_entries_removed {
656            match (&entry.pattern, &entry.from, &entry.to) {
657                (Some(p), _, _) => {
658                    out.push_str(&format!("- `[{}]` pattern `{p}`\n", entry.table,));
659                }
660                (_, Some(from), Some(to)) => {
661                    out.push_str(&format!("- `[{}]` `{from} → {to}`\n", entry.table,));
662                }
663                _ => {
664                    out.push_str(&format!("- `[{}]`\n", entry.table));
665                }
666            }
667        }
668    }
669    if !r.warnings.is_empty() {
670        out.push_str("\n## Warnings\n\n");
671        for w in &r.warnings {
672            out.push_str(&format!("- **{}**: {}\n", w.code(), w.message()));
673        }
674    }
675    out
676}
677
678/// Lift a `FullEngineError` into a typed `CliError`. The lift sources
679/// every field from the engine error directly — `err.code()` for the
680/// wire token, `err.details()` for the structured payload,
681/// `err.prose_render()` for the text message. Wrapped lean errors
682/// delegate to [`crate::CliError::from_engine_op`] so the per-variant
683/// exit-kind mapping (`NotFound` → exit 3, `HashMismatch` → exit 4,
684/// validation → exit 5, generic → exit 1) is consumed in one place;
685/// lifecycle variants (`MEM_PATH_NOT_ALLOWED`,
686/// `MEM_SCHEMA_NOT_ALLOWED`, `MEM_REFERENCED_BY_POLICY`,
687/// `INVALID_MEM_NAME`, `CONFIG_ERROR`, `MEM_STORAGE_RESIDUE_DETECTED`)
688/// are user-recoverable validation refusals and land at exit 5.
689///
690/// Sourcing from the engine error directly means any new engine code
691/// automatically reaches the CLI envelope without a hand-maintained
692/// translation table to update.
693fn pro_engine_err_to_cli(err: memstead_engine::FullEngineError) -> anyhow::Error {
694    match err {
695        memstead_engine::FullEngineError::Lean(inner) => CliError::from_engine_op(inner).into(),
696        lifecycle => {
697            let code = lifecycle.code();
698            let details = lifecycle.details();
699            let message = lifecycle.prose_render();
700            CliError {
701                kind: ExitKind::Validation,
702                code,
703                message,
704                details: Some(details),
705            }
706            .into()
707        }
708    }
709}
710
711/// `memstead mem set-version <NAME> <VERSION>` — bump the mem's
712/// `version` field via the in-process engine, persisting through the
713/// backend's `write_mem_config`. Unlike `init` / `delete`, this
714/// surface doesn't spawn the MCP subprocess: set-version is gate-free
715/// (no operator-mode bypass needed), so a direct engine call keeps
716/// the implementation simpler and faster.
717pub fn run_set_version(ctx: &CliContext, args: SetVersionArgs) -> anyhow::Result<()> {
718    let new_version = semver::Version::parse(&args.version).map_err(|e| {
719        invalid_input_error(format!(
720            "version {:?} is not a valid semver: {e}",
721            args.version,
722        ))
723    })?;
724
725    let note = args.note.as_deref();
726    let outcome = match ctx.cli_engine()? {
727        crate::setup::CliEngine::MemRepo(mut engine) => engine
728            .set_mem_version(&args.name, new_version, note)
729            .map_err(crate::CliError::from_engine_op)?,
730        crate::setup::CliEngine::Filesystem(mut engine) => engine
731            .set_mem_version(&args.name, new_version, note)
732            .map_err(crate::CliError::from_engine_op)?,
733    };
734
735    if ctx.json {
736        crate::output::print_json(&outcome)?;
737    } else {
738        let old = outcome
739            .old_version
740            .as_ref()
741            .map(|v| v.to_string())
742            .unwrap_or_else(|| "<none>".to_string());
743        let warnings = if outcome.warnings.is_empty() {
744            String::new()
745        } else {
746            let rendered: Vec<String> = outcome.warnings.iter().map(ToString::to_string).collect();
747            format!("\n\n> warnings:\n> - {}", rendered.join("\n> - "))
748        };
749        crate::output::print_markdown(&format!(
750            "# Mem `{}` version updated\n\n- Old version: {}\n- New version: {}{}",
751            outcome.mem, old, outcome.new_version, warnings,
752        ));
753    }
754    Ok(())
755}
756
757/// `memstead mem set-description <NAME> <DESCRIPTION>` — set or clear
758/// the mem's one-line description via the in-process engine,
759/// persisting through the backend's `write_mem_config`. Like
760/// set-version, this surface is gate-free and calls the engine
761/// directly. An empty DESCRIPTION clears the field.
762pub fn run_set_description(ctx: &CliContext, args: SetDescriptionArgs) -> anyhow::Result<()> {
763    let new_description = {
764        let trimmed = args.description.trim();
765        if trimmed.is_empty() {
766            None
767        } else {
768            Some(trimmed.to_string())
769        }
770    };
771    let note = args.note.as_deref();
772    let outcome = match ctx.cli_engine()? {
773        crate::setup::CliEngine::MemRepo(mut engine) => engine
774            .set_mem_description(&args.name, new_description, note)
775            .map_err(crate::CliError::from_engine_op)?,
776        crate::setup::CliEngine::Filesystem(mut engine) => engine
777            .set_mem_description(&args.name, new_description, note)
778            .map_err(crate::CliError::from_engine_op)?,
779    };
780
781    if ctx.json {
782        crate::output::print_json(&outcome)?;
783    } else {
784        let old = outcome.old_description.as_deref().unwrap_or("<none>");
785        let new = outcome.new_description.as_deref().unwrap_or("<cleared>");
786        let warnings = if outcome.warnings.is_empty() {
787            String::new()
788        } else {
789            let rendered: Vec<String> = outcome.warnings.iter().map(ToString::to_string).collect();
790            format!("\n\n> warnings:\n> - {}", rendered.join("\n> - "))
791        };
792        crate::output::print_markdown(&format!(
793            "# Mem `{}` description updated\n\n- Old: {}\n- New: {}{}",
794            outcome.mem, old, new, warnings,
795        ));
796    }
797    Ok(())
798}
799
800/// `memstead mem set-sync-state <NAME> <KEY> <TOKEN>` — set or clear
801/// one opaque sync-state token in a mem's config via the in-process
802/// engine, persisting through the backend's `write_mem_config`. Like
803/// set-version, this surface is gate-free and calls the engine directly.
804pub fn run_set_sync_state(ctx: &CliContext, args: SetSyncStateArgs) -> anyhow::Result<()> {
805    let note = args.note.as_deref();
806    let outcome = match ctx.cli_engine()? {
807        crate::setup::CliEngine::MemRepo(mut engine) => engine
808            .set_mem_sync_state(&args.name, &args.key, &args.token, note)
809            .map_err(crate::CliError::from_engine_op)?,
810        crate::setup::CliEngine::Filesystem(mut engine) => engine
811            .set_mem_sync_state(&args.name, &args.key, &args.token, note)
812            .map_err(crate::CliError::from_engine_op)?,
813    };
814
815    if ctx.json {
816        crate::output::print_json(&outcome)?;
817    } else {
818        let action = if outcome.removed {
819            "cleared".to_string()
820        } else if outcome.previous.is_some() {
821            "overwrote".to_string()
822        } else {
823            "set".to_string()
824        };
825        let warnings = if outcome.warnings.is_empty() {
826            String::new()
827        } else {
828            let rendered: Vec<String> = outcome.warnings.iter().map(ToString::to_string).collect();
829            format!("\n\n> warnings:\n> - {}", rendered.join("\n> - "))
830        };
831        crate::output::print_markdown(&format!(
832            "# Mem `{}` sync state {}\n\n- Key: `{}`{}",
833            outcome.mem, action, outcome.key, warnings,
834        ));
835    }
836    Ok(())
837}
838
839pub fn run_set_schema(ctx: &CliContext, args: SetSchemaArgs) -> anyhow::Result<()> {
840    let target: memstead_schema::SchemaRef = args
841        .schema
842        .parse()
843        .map_err(|e| invalid_input_error(format!("invalid schema ref {:?}: {e}", args.schema)))?;
844    let outcome = match ctx.cli_engine()? {
845        crate::setup::CliEngine::MemRepo(mut engine) => engine
846            .set_mem_schema(&args.name, &target)
847            .map_err(crate::CliError::from_engine_op)?,
848        crate::setup::CliEngine::Filesystem(mut engine) => engine
849            .set_mem_schema(&args.name, &target)
850            .map_err(crate::CliError::from_engine_op)?,
851    };
852    if ctx.json {
853        crate::output::print_json(&outcome)?;
854    } else {
855        let findings = if outcome.findings.is_empty() {
856            String::new()
857        } else {
858            let rendered: Vec<String> = outcome
859                .findings
860                .iter()
861                .map(|f| format!("- {} — {}", f.id, f.code))
862                .collect();
863            format!("\n\n## Non-integral entities\n\n{}", rendered.join("\n"))
864        };
865        crate::output::print_markdown(&format!(
866            "# Mem `{}` schema: {:?}\n\n- Pin: {}\n- Migration target: {}{}",
867            outcome.mem,
868            outcome.outcome,
869            outcome.schema_pin,
870            outcome.migration_target.as_deref().unwrap_or("<none>"),
871            findings,
872        ));
873    }
874    Ok(())
875}
876
877fn generic_error(msg: String) -> anyhow::Error {
878    CliError {
879        code: "MEM_ERROR",
880        kind: ExitKind::Generic,
881        message: msg,
882        details: None,
883    }
884    .into()
885}
886
887fn validation_error(msg: String) -> anyhow::Error {
888    CliError {
889        code: "VALIDATION_FAILED",
890        kind: ExitKind::Validation,
891        message: msg,
892        details: None,
893    }
894    .into()
895}
896
897fn invalid_input_error(msg: String) -> anyhow::Error {
898    CliError {
899        code: "INVALID_INPUT",
900        kind: ExitKind::Validation,
901        message: msg,
902        details: None,
903    }
904    .into()
905}
906
907/// Bridge the three single-purpose CLI flags into a single
908/// `RecoveryAction` enum value. clap's `group = "recovery_action"`
909/// annotation on each flag enforces the mutex at parse time, so at most
910/// one boolean is `true` here. Returns `None` for the bare invocation,
911/// mapping to the engine's tombstone-driven default (residue with
912/// tombstone → `Reattach`; residue without → refuse).
913fn recovery_from_flags(
914    reattach: bool,
915    force_overwrite: bool,
916    hard_cleanup_first: bool,
917) -> Option<memstead_engine::RecoveryAction> {
918    if reattach {
919        Some(memstead_engine::RecoveryAction::Reattach)
920    } else if force_overwrite {
921        Some(memstead_engine::RecoveryAction::ForceOverwrite)
922    } else if hard_cleanup_first {
923        Some(memstead_engine::RecoveryAction::HardCleanupFirst)
924    } else {
925        None
926    }
927}
928
929pub fn run_list(ctx: &CliContext, _args: ListArgs) -> anyhow::Result<()> {
930    let setup_ctx = CliContext {
931        json: ctx.json,
932        quiet: ctx.quiet,
933    };
934    let engine = crate::setup::pro_engine(&setup_ctx)
935        .map_err(|e| generic_error(format!("mem list: could not initialize engine: {e}")))?;
936
937    let mut rows: Vec<serde_json::Value> = Vec::new();
938    for name in engine.mem_names() {
939        let cfg = engine
940            .mem_configs_named()
941            .find(|(n, _)| *n == name)
942            .map(|(_, c)| c);
943        let entity_count = engine
944            .store()
945            .all_entities()
946            .filter(|e| e.id.mem() == name && !e.stub)
947            .count();
948        let capability = if engine.mem_router().is_writable(name) {
949            "write"
950        } else {
951            "read_only"
952        };
953        rows.push(serde_json::json!({
954            "name": name,
955            "schema_ref": cfg.and_then(|c| c.schema.as_ref()).map(|s| s.to_string()),
956            "version": cfg.and_then(|c| c.version.clone()),
957            "entity_count": entity_count,
958            "capability": capability,
959        }));
960    }
961
962    if ctx.json {
963        crate::output::print_json(&serde_json::json!({ "mems": rows }))?;
964        return Ok(());
965    }
966
967    let mut lines: Vec<String> = vec![format!("# Mems ({})", rows.len()), String::new()];
968    if rows.is_empty() {
969        lines.push("_no mems mounted_".to_string());
970    } else {
971        for v in &rows {
972            let name = v["name"].as_str().unwrap_or("?");
973            let schema = v["schema_ref"].as_str().unwrap_or("—");
974            let version = v["version"].as_str().unwrap_or("—");
975            let count = v["entity_count"].as_u64().unwrap_or(0);
976            let cap = v["capability"].as_str().unwrap_or("?");
977            lines.push(format!(
978                "- `{name}` ({cap}) — schema `{schema}`, version `{version}`, {count} entities"
979            ));
980        }
981    }
982    crate::output::print_markdown(&lines.join("\n"));
983    Ok(())
984}
985
986#[cfg(test)]
987mod tests {
988    use super::*;
989    use memstead_base::EngineError;
990    use memstead_base::ReferrerInfo;
991    use memstead_engine::FullEngineError;
992    use std::path::PathBuf;
993
994    fn lifted_cli_error(err: FullEngineError) -> CliError {
995        let any = pro_engine_err_to_cli(err);
996        any.downcast::<CliError>()
997            .expect("pro_engine_err_to_cli must lift to a CliError")
998    }
999
1000    /// The client-side mem-template consumer surfaces a built-in
1001    /// schema's instance guidance keys when `--write-guidance` is
1002    /// omitted, stays silent when guidance is given, and is silent for a
1003    /// schema that ships no template.
1004    #[test]
1005    fn mem_template_guidance_note_surfaces_builtin_keys() {
1006        let planning: memstead_schema::SchemaRef = "planning@0.1.0".parse().unwrap();
1007        let note = mem_template_guidance_note(&planning, false)
1008            .expect("planning ships a mem-template — a note is due");
1009        assert!(note.contains("phase_context"), "note names the key: {note}");
1010        assert!(
1011            note.contains("--write-guidance"),
1012            "note tells how to fill: {note}"
1013        );
1014        // Operator supplied guidance → nothing to surface.
1015        assert!(mem_template_guidance_note(&planning, false).is_some());
1016        assert!(mem_template_guidance_note(&planning, true).is_none());
1017        // A schema with no mem-template → no note.
1018        let default_: memstead_schema::SchemaRef = "default@1.0.0".parse().unwrap();
1019        assert!(mem_template_guidance_note(&default_, false).is_none());
1020    }
1021
1022    /// The CLI's mem command surface does not translate the engine's
1023    /// typed code through a static table — a code added on the engine
1024    /// side reaches the CLI envelope unchanged. Pins the regression
1025    /// where `MEM_HAS_INCOMING_REFS` silently degraded to
1026    /// `VALIDATION_FAILED`.
1027    #[test]
1028    fn mem_has_incoming_refs_keeps_typed_code_and_carries_details() {
1029        let err = FullEngineError::Lean(EngineError::MemHasIncomingRefs {
1030            mem: "other".to_string(),
1031            referrers: vec![ReferrerInfo {
1032                from_id: "test--source".to_string(),
1033                rel_types: vec!["USES".to_string()],
1034                mem: "test".to_string(),
1035            }],
1036        });
1037        let cli = lifted_cli_error(err);
1038        assert_eq!(cli.code, "MEM_HAS_INCOMING_REFS");
1039        assert_eq!(cli.kind, ExitKind::Validation);
1040        let details = cli.details.expect("details must reach the CLI envelope");
1041        assert_eq!(details["mem"], "other");
1042        let referrers = details["referrers"].as_array().expect("referrers array");
1043        assert_eq!(referrers.len(), 1);
1044        assert_eq!(referrers[0]["from_id"], "test--source");
1045        assert_eq!(referrers[0]["mem"], "test");
1046    }
1047
1048    /// Lifecycle refusal (a full-only variant) is
1049    /// promoted through with the same code + structured details the
1050    /// MCP wire ships. `MEM_PATH_NOT_ALLOWED` carries the candidate,
1051    /// the patterns list, and the typed reason discriminator.
1052    #[test]
1053    fn mem_path_not_allowed_carries_structured_details() {
1054        let err = FullEngineError::MemPathNotAllowed {
1055            attempted: PathBuf::from("/ws/bogus"),
1056            candidate: "bogus".to_string(),
1057            patterns: vec!["specs".to_string(), "team/*".to_string()],
1058            reason: "no_match",
1059            policy_table: "mem_management.create",
1060        };
1061        let cli = lifted_cli_error(err);
1062        assert_eq!(cli.code, "MEM_PATH_NOT_ALLOWED");
1063        assert_eq!(cli.kind, ExitKind::Validation);
1064        let details = cli.details.expect("details");
1065        assert_eq!(details["candidate"], "bogus");
1066        assert_eq!(details["reason"], "no_match");
1067        assert_eq!(details["patterns"][0], "specs");
1068        // The `policy_table` disambiguator reaches the CLI envelope.
1069        assert_eq!(details["policy_table"], "mem_management.create");
1070        assert_eq!(details["patterns"][1], "team/*");
1071    }
1072
1073    /// `VALIDATION_FAILED` is not
1074    /// used as the fallback for engine-sourced refusals. A
1075    /// typed lifecycle variant must not degrade to the catch-all.
1076    #[test]
1077    fn lifecycle_refusal_never_degrades_to_validation_failed_token() {
1078        let cases = [
1079            FullEngineError::MemPathNotAllowed {
1080                attempted: PathBuf::from("/x"),
1081                candidate: "x".to_string(),
1082                patterns: vec![],
1083                reason: "no_allowlist_configured",
1084                policy_table: "mem_management.create",
1085            },
1086            FullEngineError::MemReferencedByPolicy {
1087                name: "x".to_string(),
1088                referring_mems: vec!["y".to_string()],
1089            },
1090            FullEngineError::MemSchemaNotAllowed {
1091                candidate: "x".to_string(),
1092                matched_pattern: "p".to_string(),
1093                requested_schema: "default@1.0.0".to_string(),
1094                allowed_schemas: vec!["other@1.0.0".to_string()],
1095            },
1096            FullEngineError::InvalidMemName {
1097                name: "BadName".to_string(),
1098                reason: "invalid_char",
1099            },
1100        ];
1101        for err in cases {
1102            let cli = lifted_cli_error(err);
1103            assert_ne!(
1104                cli.code, "VALIDATION_FAILED",
1105                "engine-sourced refusal must carry its typed code: got {} with details {:?}",
1106                cli.code, cli.details,
1107            );
1108        }
1109    }
1110
1111    /// Wrapped lean errors keep the
1112    /// per-variant exit-kind mapping (`NotFound` → exit 3,
1113    /// `HashMismatch` → exit 4, etc.) by delegating to
1114    /// `CliError::from_engine_op`. The lift doesn't flatten every
1115    /// lean variant to `Validation`.
1116    #[test]
1117    fn wrapped_lean_error_preserves_per_variant_exit_kind() {
1118        let err = FullEngineError::Lean(EngineError::NotFound {
1119            id: "specs--missing".to_string(),
1120        });
1121        let cli = lifted_cli_error(err);
1122        assert_eq!(cli.code, "ENTITY_NOT_FOUND");
1123        assert_eq!(cli.kind, ExitKind::NotFound);
1124        assert_eq!(cli.details.as_ref().unwrap()["id"], "specs--missing");
1125    }
1126}