Skip to main content

memstead_cli/
cli.rs

1//! Clap derive for the `memstead` binary, lifted out of `main.rs` so
2//! the xtask doc generator can call `Cli::command()` against the same
3//! tree the binary exposes — no duplicated declarations, no drift.
4//!
5//! One crate, two build configs: the default (`mem-repo`) build
6//! exposes the full command set including the multi-mem / mem-repo
7//! lifecycle subcommands; `--no-default-features` drops those, leaving
8//! the engine-agnostic surface.
9
10use clap::{Parser, Subcommand};
11
12use crate::commands;
13
14/// Top-level `--help` epilog describing the exit-code posture. The
15/// taxonomy is intentionally coarse — success vs failure — because
16/// agents read JSON, not exit codes, and shell scripts can lift the
17/// granular `code` from `--json | jq .code`.
18///
19/// Code 6 breaks that success/failure symmetry on purpose: it means the
20/// measurement completed and the caller asked to be gated on what it
21/// found. A CI job needs three outcomes, not two, and it cannot get the
22/// third from a code that also means "the engine failed to boot". Keep
23/// it exclusive to explicit opt-in gate modes — the moment a run that
24/// FAILED returns 6, the distinction stops being worth anything.
25///
26/// The line is "did the measurement complete", not "was everything
27/// well". An artifact the pass could not read is a finding: it was
28/// observed and could not be adjudicated, which is an answer. An
29/// unreadable anchors sidecar is not: nothing could be observed at all,
30/// so verify refuses with `ANCHORS_SIDECAR_UNREADABLE` rather than
31/// reporting every artifact uncovered — that was a live defect, found
32/// 2026-08-21, where a corrupt file produced a red build blaming the
33/// mem.
34///
35/// This string is the source the published reference renders from
36/// (`docs-site/.../reference/cli/cli.md`, xtask-generated and
37/// drift-gated). Editing the table here and not regenerating leaves the
38/// published page asserting an exit-code space the binary no longer has.
39pub const EXIT_CODES_HELP: &str = "\
40Exit codes:
41  0  success
42  1  generic failure (catch-all for non-classified errors)
43  2  usage error (clap argument-parse failure — unknown flag, bad value)
44  3  not found (entity / mem / resource missing)
45  4  hash mismatch (optimistic-locking failure on a mutation)
46  5  validation / schema / policy refusal
47  6  findings present — the measurement COMPLETED and recorded
48     something you asked to be gated on
49     (`projection verify --fail-on-findings`). A run that could not
50     complete returns its own code above, so a CI job can tell \"the
51     mem and its source disagree\" from \"the engine could not run\".
52     An artifact the pass could not read is a finding, not an error:
53     it was observed, and not being able to adjudicate it is the
54     measurement's answer.
55
56  For programmatic branching, prefer `--json` over the exit code:
57    memstead <subcommand> ... --json | jq -r .code
58  One caveat, and it bites exactly where code 6 matters: a gate-mode run
59  that exits 6 emits TWO documents on stdout — the report, then the typed
60  error. The recipe above reads only the first and prints `null`. Read the
61  stream instead:
62    memstead ... --fail-on-findings --json | jq -s -r '.[-1].code'
63  The JSON envelope's `code` field carries the typed token
64  (e.g. INVALID_TITLE, HAS_INCOMING_REFS, CROSS_MEM_LINK_NOT_ALLOWED)
65  with structured recovery details under `.details`.";
66
67/// Query and mutate Memstead knowledge graphs from the shell.
68#[derive(Parser, Debug)]
69// `--version` prints the full build version (engine semver plus the
70// git build sha for dev builds) so two builds between releases stay
71// distinguishable in the field.
72#[command(name = "memstead", version = memstead_base::build_info::full_version(), about, long_about = None, after_long_help = EXIT_CODES_HELP)]
73pub struct Cli {
74    /// Emit JSON instead of markdown. Matches MCP `structured_content` shape.
75    #[arg(long, global = true)]
76    pub json: bool,
77
78    /// Suppress engine startup logs on stderr.
79    #[arg(long, global = true)]
80    pub quiet: bool,
81
82    /// Operate on the workspace at PATH instead of walking up from the
83    /// current directory (like `git -C`: the process runs as if
84    /// invoked from PATH, so relative path arguments resolve against
85    /// it). Also settable via the `MEMSTEAD_WORKSPACE` environment
86    /// variable; the flag wins when both are present. A PATH that is
87    /// not an initialised workspace refuses with
88    /// `WORKSPACE_NOT_INITIALISED` naming the path — it never falls
89    /// back to the directory walk.
90    #[arg(long, global = true, value_name = "PATH")]
91    pub workspace: Option<std::path::PathBuf>,
92
93    /// Declare the role this invocation's mutations are performed in
94    /// (agent-trust plan 13): `author` | `checker` | `verifier`.
95    /// Recorded immutably alongside each mutation (commit trailer /
96    /// ledger). Omit to record mutations as unspecified — legal
97    /// forever, never refused.
98    #[arg(long = "role", global = true)]
99    pub role: Option<String>,
100
101    #[command(subcommand)]
102    pub command: Command,
103}
104
105#[derive(Subcommand, Debug)]
106pub enum Command {
107    /// Node / edge counts, schema distribution, and per-binding projection state.
108    Status,
109
110    /// Read one entity as markdown.
111    Entity(commands::entity::Args),
112
113    /// List typed edges for an entity.
114    Relations(commands::relations::Args),
115
116    /// Find entities by text or graph proximity.
117    Search(commands::search::Args),
118
119    /// Filter entities by metadata (no text match — use `search` for that).
120    List(commands::list::Args),
121
122    /// Read an entity's community cluster.
123    Context(commands::context::Args),
124
125    /// All clusters with summaries and member lists. The full build
126    /// renders the same rich content the MCP `memstead_overview` tool
127    /// emits — both surfaces share the engine composer in `memstead-engine`.
128    Overview(commands::overview::Args),
129
130    /// Describe one type, or list all types when no name given.
131    Type(commands::type_cmd::Args),
132
133    /// Health summary (orphans, stubs, stale entities, missing fields).
134    Health(commands::health::Args),
135
136    /// Render the due-brief: open entities whose schema-declared due
137    /// date falls inside the window (default 90d), overdue first.
138    Due(commands::due::Args),
139
140    /// Export a mem: markdown in place, a portable `.mem` archive, JSON, one self-contained HTML page, or one agent-readable Markdown document (`llms-txt`).
141    Export(commands::export::Args),
142
143    /// Initialise a filesystem mem in the current (or named) folder.
144    /// Strict: errors out when the target is not empty.
145    Init(commands::init::InitArgs),
146
147    /// One-command cold start: workspace + default-schema mem + seed
148    /// entity + MCP wiring for your agent(s), in the current (or named)
149    /// folder. Tolerates dotfiles and README-grade files; derives the
150    /// mem name from the folder. For the strict, script-safe variant
151    /// use `memstead init`. Restart the agent session afterwards: a
152    /// session that is already running does not attach an MCP server
153    /// added while it runs.
154    Quickstart(commands::quickstart::Args),
155
156    /// Install a sealed `.mem` mem — either a local file, or `<scope>/<name>`
157    /// from the memstead.io registry. Registers it as a workspace-level
158    /// read-only mount; `memstead uninstall` is the symmetric removal.
159    /// MEM-REPO WORKSPACES ONLY — refuses with
160    /// `UNSUPPORTED_WORKSPACE_SHAPE` on the filesystem-mem workspace
161    /// `memstead quickstart` produces; bootstrap with
162    /// `memstead mem-repo init` instead when you intend to install mems.
163    #[cfg(feature = "mem-repo")]
164    Install(commands::install::Args),
165
166    /// Remove an installed read-mem's workspace-level mount. The global
167    /// cache copy survives by default; re-`install` re-registers it.
168    /// MEM-REPO WORKSPACES ONLY (see `install`).
169    #[cfg(feature = "mem-repo")]
170    Uninstall(commands::uninstall::Args),
171
172    /// Verify every anchor in a mem against its declared source — the
173    /// standalone drift statement, no binding required. Mutates no entity,
174    /// but records its findings store like any verify run.
175    #[command(name = "verify-anchors")]
176    VerifyAnchors(commands::verify_anchors::Args),
177
178    /// Link a filesystem mem to a registry-published dependency.
179    /// `memstead link <scope/name>` fetches the archive into the
180    /// workspace and records the dependency in the workspace config.
181    Link(commands::link::LinkArgs),
182
183    /// Publish a `.mem` archive to the registry. Triggers GitHub
184    /// Device Flow on first use; subsequent runs are silent.
185    Publish(commands::publish::Args),
186
187    /// Unpublish (hard-delete) `<scope>/<name>` from the registry.
188    /// Permitted to the original uploader and to admins. The same
189    /// `<scope>/<name>` becomes immediately re-publishable.
190    Unpublish(commands::unpublish::Args),
191
192    /// Domain-authority publishing: generate the signing key for a domain you
193    /// control and print the `.well-known` manifest to host. `publish --scope
194    /// <domain>:<handle>` then signs with that key — no GitHub account needed.
195    Domain {
196        #[command(subcommand)]
197        action: commands::domain::DomainAction,
198    },
199
200    /// Admin-only registry moderation: take a mem down or deny-list
201    /// bytes. Gated server-side by the `MEMSTEAD_ADMINS` allowlist; every
202    /// action is recorded in the registry's append-only audit log.
203    Admin {
204        #[command(subcommand)]
205        action: commands::admin::AdminAction,
206    },
207
208    /// Authenticate with a registry via GitHub Device Flow. Optional —
209    /// `publish` auto-triggers the same flow on first use.
210    Login(commands::login::Args),
211
212    /// Remove stored credentials for a registry.
213    Logout(commands::logout::Args),
214
215    /// Create a new entity. Provide `--title`, `--type`, and the required
216    /// section fields, or pass `--from <file.json>` with the full payload.
217    Create(commands::create::Args),
218
219    /// Modify an existing entity. `--expected-hash` is required unless
220    /// `--auto-hash` (refetch before write) or `--force` (skip check) is given.
221    Update(commands::update::Args),
222
223    /// Add or remove a typed relationship between two entities.
224    Relate(commands::relate::Args),
225
226    /// Delete an entity. Use `--dry-run` to preview impact first.
227    /// Delete is hashless by design (no post-state to race on); race
228    /// protection comes from `HAS_INCOMING_REFS` — and
229    /// `RESIDUAL_STUB_FOR_READONLY_REFERRERS` for read-only-referrer cases.
230    Delete(commands::delete::Args),
231
232    /// Rename an entity (changes ID, file path, and every incoming wiki-link).
233    Rename(commands::rename::Args),
234
235    /// Update many entities in one atomic call. Input is a JSON file
236    /// with a top-level `updates: [...]` array (one entry per entity,
237    /// each with its own hash mode and mutation fields). All-or-nothing:
238    /// if any entry fails (validation, hash mismatch, missing entity)
239    /// the whole batch is refused and NOTHING is committed — fix the
240    /// named entry and resubmit. On success the batch lands as one
241    /// commit. Mirrors `memstead update` per entry.
242    /// MEM-REPO WORKSPACES ONLY — refuses with
243    /// `UNSUPPORTED_WORKSPACE_SHAPE` on the filesystem-mem workspace
244    /// `memstead quickstart` produces; fall back to one `memstead
245    /// update` per entity there.
246    #[cfg(feature = "mem-repo")]
247    #[command(name = "batch-update")]
248    BatchUpdate(commands::batch_update::Args),
249
250    /// Create many entities in one atomic call. Input is a JSON file
251    /// with a top-level `creates: [...]` array — each entry the same
252    /// shape as `create --from`, with its own provenance `note`.
253    /// Intra-batch references resolve as real targets (cycles included
254    /// where the schema permits), so a mutually-referencing set lands
255    /// in a single pass with no stubs. All-or-nothing: any invalid
256    /// entry refuses the whole batch and names EVERY failing entry.
257    /// One commit per touched mem.
258    /// MEM-REPO WORKSPACES ONLY — refuses with
259    /// `UNSUPPORTED_WORKSPACE_SHAPE` on the filesystem-mem workspace
260    /// `memstead quickstart` produces; fall back to one `memstead
261    /// create` per entity there (losing atomicity and intra-batch
262    /// reference resolution).
263    #[cfg(feature = "mem-repo")]
264    #[command(name = "batch-create")]
265    BatchCreate(commands::batch_create::Args),
266
267    /// Apply many edge changes in one atomic call. Input is a JSON
268    /// file with a top-level `relates: [...]` array mixing additions
269    /// and removals, applied in order — each entry mirrors `relate`
270    /// (`from` / `type` / `to`, optional `remove`, `description`,
271    /// per-entry `note`). All-or-nothing: any invalid entry refuses
272    /// the whole batch and names EVERY failing entry. One commit per
273    /// touched mem.
274    /// MEM-REPO WORKSPACES ONLY — refuses with
275    /// `UNSUPPORTED_WORKSPACE_SHAPE` on the filesystem-mem workspace
276    /// `memstead quickstart` produces; fall back to one `memstead
277    /// relate` per edge there.
278    #[cfg(feature = "mem-repo")]
279    #[command(name = "batch-relate")]
280    BatchRelate(commands::batch_relate::Args),
281
282    /// Apply parse-time-drift recovery across writable mems. Walks
283    /// `PARSED_RELATION_INVALID` warnings, re-renders affected
284    /// source entities to drop the stale rows, and reports per-entry
285    /// outcomes. Read-only-origin drops surface as skipped.
286    /// MEM-REPO WORKSPACES ONLY (see `install`).
287    #[cfg(feature = "mem-repo")]
288    Recover(commands::recover::Args),
289
290    /// Read provenance anchors (E3a): `memstead anchors <id>` lists an
291    /// entity's anchors + composition; `memstead anchors --artifact <path>`
292    /// reverse-looks-up every entity whose anchor references that path
293    /// (the query the check-realization hook consumes).
294    Anchors(commands::anchors::Args),
295
296    /// List and resolve git merge conflicts in folder-backed mems —
297    /// the one sanctioned repair when a merge in the user's repo
298    /// writes conflict markers into entity files. `conflicts list`
299    /// shows conflicted entities; `conflicts resolve <id> --side
300    /// ours|theirs` keeps one side, validated before it lands and
301    /// committed as an attributed mutation.
302    Conflicts(commands::conflicts::Args),
303
304    /// Diff a mem's HEAD against a commit SHA. Pass `--since` = a
305    /// prior `commit_sha` from a mutation, or the canonical empty-tree
306    /// hash `4b825dc642cb6eb9a060e54bf8d69288fbee4904` for a first sync.
307    Changes(commands::changes::Args),
308
309    /// Record a check: "entity E checked, verdict ok | failed, via
310    /// method M" — an engine-recorded act carrying the session's
311    /// `--role`, never a mutation (entity markdown, hash, and mem
312    /// commits untouched). Derived check state serves via
313    /// `memstead entity <id> --provenance`.
314    Check(commands::check::Args),
315
316    /// Read and move the per-mem review mark — the engine's one
317    /// pointer per mem to the last human-approved state. `list` shows
318    /// every mem's mark and head; `set`/`clear` move it (explicit
319    /// target only); `diff` reports the unreviewed delta. Marks never
320    /// gate writes.
321    #[command(name = "review-mark")]
322    ReviewMark(commands::review_mark::Args),
323
324    /// Reload one writable mem's slice of the in-memory store from
325    /// its on-disk branch tip — or every writable mem when
326    /// `--mem` is omitted. CLI parity with the MCP `memstead_reload`
327    /// tool.
328    Reload(commands::reload::Args),
329
330    /// Fetch a mem's branch refs from a git remote into the mem-repo
331    /// (no local branch moves — inspect first, then `pull`). Requires a
332    /// git-branch-backed mem (`INVALID_INPUT` on folder mounts);
333    /// refuses `UNKNOWN_REMOTE` when the remote is not configured.
334    #[cfg(feature = "mem-repo")]
335    Fetch(commands::transport::FetchArgs),
336
337    /// Fast-forward a mem's branch to its fetched remote counterpart
338    /// and reload the in-memory store. Refuses `LOCAL_DIVERGENCE` when
339    /// the local branch is not an ancestor of the remote — reconcile
340    /// via `branch-reset`, or resolve on another clone and push.
341    #[cfg(feature = "mem-repo")]
342    Pull(commands::transport::PullArgs),
343
344    /// Push a mem's branch to a git remote. `--force` uses
345    /// force-with-lease semantics; without it, non-fast-forward pushes
346    /// refuse (`NON_FAST_FORWARD`). Refuses `UNKNOWN_REMOTE` when the
347    /// remote is not configured.
348    #[cfg(feature = "mem-repo")]
349    Push(commands::transport::PushArgs),
350
351    /// Reset a mem's branch pointer to a target ref/SHA. Refuses to
352    /// discard commits reachable from any remote ref
353    /// (`PUSHED_COMMITS_PROTECTED`).
354    #[cfg(feature = "mem-repo")]
355    #[command(name = "branch-reset")]
356    BranchReset(commands::branch_reset::BranchResetArgs),
357
358    /// Mem lifecycle commands.
359    #[cfg(feature = "mem-repo")]
360    Mem {
361        #[command(subcommand)]
362        action: commands::mem::MemAction,
363    },
364
365    /// Mem-repo-git lifecycle commands.
366    #[cfg(feature = "mem-repo")]
367    #[command(name = "mem-repo")]
368    MemRepo {
369        #[command(subcommand)]
370        action: commands::mem_repo::MemRepoAction,
371    },
372
373    /// Introspect and configure workspace policy — `dump` reads the
374    /// effective config; `allow-create`/`revoke-create`/`allow-delete`/
375    /// `revoke-delete`/`grant-cross-link`/`revoke-cross-link`/`set-mutations`
376    /// write the mem-lifecycle allowlist, cross-mem link grants, and
377    /// mutation policy.
378    #[cfg(feature = "mem-repo")]
379    Workspace {
380        #[command(subcommand)]
381        action: commands::workspace::WorkspaceAction,
382    },
383
384    /// Author-time schema tooling. `memstead schema validate <path>`
385    /// checks a schema package directory against the engine's loader
386    /// without touching a workspace.
387    Schema(commands::schema::Args),
388
389    /// Pipeline tooling — one versioned v2 binding per pipeline, sources
390    /// inline. `memstead projection brief <binding>` renders a binding's
391    /// run-brief (the Markdown prompt an agent consumes); `memstead
392    /// projection init` scaffolds a fresh v2 record non-interactively;
393    /// `memstead projection migrate` converts every prior on-disk generation
394    /// (gen-1 root folders, the four-primitive store, the v1 three-file
395    /// store) into v2 records in place; `memstead projection advance`
396    /// records disposition-gated sync-baseline advances; `memstead projection
397    /// enable <build|sync|verify> <binding>` adds a missing operation block.
398    Projection(commands::projection::Args),
399}
400
401impl Command {
402    /// The subcommand's user-facing verb name, as typed on the command
403    /// line — the `verb` field the friction ledger records on a typed
404    /// refusal. Nested action groups report their top-level noun
405    /// (`mem`, `mem-repo`, `workspace`, `domain`, `admin`): per-verb
406    /// counts at that granularity already answer the design questions,
407    /// and nothing payload-shaped can leak through a static name.
408    pub fn verb(&self) -> &'static str {
409        match self {
410            Command::Status => "status",
411            Command::Entity(_) => "entity",
412            Command::Relations(_) => "relations",
413            Command::Search(_) => "search",
414            Command::List(_) => "list",
415            Command::Context(_) => "context",
416            Command::Overview(_) => "overview",
417            Command::Type(_) => "type",
418            Command::Health(_) => "health",
419            Command::Due(_) => "due",
420            Command::Export(_) => "export",
421            Command::Init(_) => "init",
422            Command::Quickstart(_) => "quickstart",
423            #[cfg(feature = "mem-repo")]
424            Command::Install(_) => "install",
425            #[cfg(feature = "mem-repo")]
426            Command::Uninstall(_) => "uninstall",
427            Command::VerifyAnchors(_) => "verify-anchors",
428            Command::Link(_) => "link",
429            Command::Publish(_) => "publish",
430            Command::Unpublish(_) => "unpublish",
431            Command::Domain { .. } => "domain",
432            Command::Admin { .. } => "admin",
433            Command::Login(_) => "login",
434            Command::Logout(_) => "logout",
435            Command::Create(_) => "create",
436            Command::Update(_) => "update",
437            Command::Relate(_) => "relate",
438            Command::Delete(_) => "delete",
439            Command::Rename(_) => "rename",
440            #[cfg(feature = "mem-repo")]
441            Command::BatchUpdate(_) => "batch-update",
442            #[cfg(feature = "mem-repo")]
443            Command::BatchCreate(_) => "batch-create",
444            #[cfg(feature = "mem-repo")]
445            Command::BatchRelate(_) => "batch-relate",
446            #[cfg(feature = "mem-repo")]
447            Command::Recover(_) => "recover",
448            Command::Anchors(_) => "anchors",
449            Command::Conflicts(_) => "conflicts",
450            Command::Changes(_) => "changes",
451            Command::Check(_) => "check",
452            Command::ReviewMark(_) => "review-mark",
453            Command::Reload(_) => "reload",
454            #[cfg(feature = "mem-repo")]
455            Command::Fetch(_) => "fetch",
456            #[cfg(feature = "mem-repo")]
457            Command::Pull(_) => "pull",
458            #[cfg(feature = "mem-repo")]
459            Command::Push(_) => "push",
460            #[cfg(feature = "mem-repo")]
461            Command::BranchReset(_) => "branch-reset",
462            #[cfg(feature = "mem-repo")]
463            Command::Mem { .. } => "mem",
464            #[cfg(feature = "mem-repo")]
465            Command::MemRepo { .. } => "mem-repo",
466            #[cfg(feature = "mem-repo")]
467            Command::Workspace { .. } => "workspace",
468            Command::Schema(_) => "schema",
469            Command::Projection(_) => "projection",
470        }
471    }
472}