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