mnem-cli 0.1.6

Command-line interface for mnem - git for knowledge graphs.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
#![forbid(unsafe_code)]
#![deny(missing_docs)]
//! `mnem` - the Git-shaped porcelain for mnem.
//!
//! Minimum-viable CLI for the adoption surface: init / status / log /
//! show / add / query / diff / ref / config / stats. The Git-verb
//! spine ships incrementally: `branch`, `blame`, `cat-file`, `remote`,
//! `clone file://`, `fetch`, `push`, and `pull` are wired (with
//! wire verbs over HTTP). `merge` / `revert` / `fsck` / `gc` remain
//! advertised in `mnem --help` but fail with EX_CONFIG until a later
//! revision.

use std::path::PathBuf;

use clap::{Parser, Subcommand};

mod commands;
mod config;
mod doctor;
mod global;
mod http;
mod integrate;
mod mcp;
mod repo;
mod unintegrate;
mod wizard;

#[derive(Parser)]
#[command(
    name = "mnem",
    version,
    about = "git for knowledge graphs - versioned, content-addressed, embeddable.",
    long_about = None,
    propagate_version = true
)]
pub(crate) struct Cli {
    /// Path to the repository directory (the directory that contains
    /// `.mnem/`). Defaults to walking up from the current directory,
    /// like `git` does.
    #[arg(long, short = 'R', global = true)]
    repo: Option<PathBuf>,

    /// Optional so bare `mnem` drops into the first-run wizard (or
    /// `mnem status` for returning users) instead of printing help.
    /// `mnem --help` still prints help because clap captures that
    /// before we reach `main`.
    #[command(subcommand)]
    cmd: Option<Cmd>,
}

#[derive(Subcommand)]
enum Cmd {
    /// Initialize a new mnem repository (creates `.mnem/repo.redb`).
    Init(commands::init::Args),
    /// Print current op-head, head commit, ref summary, and label counts.
    #[command(after_long_help = "\
Examples:
  mnem status                    # current op + head commit + ref count
  mnem -R ~/notes status         # explicit repo path
  mnem status && mnem retrieve \"query\"
")]
    Status,
    /// Walk the op-log backwards from the current head.
    Log(commands::log::Args),
    /// Show the full detail of one operation.
    Show(commands::show::Args),
    /// Add a node or edge (commits immediately).
    #[command(
        subcommand,
        after_long_help = "\
Examples:
  mnem add node -s \"Alice lives in Berlin\"
  mnem add node --label Person --prop name=Alice --prop city=Berlin \\
    -s \"Alice is a climber\"
  mnem add edge --from <src-uuid> --to <dst-uuid> --label knows

See `mnem add node --help` / `mnem add edge --help` for full options.
"
    )]
    Add(commands::add::AddCmd),
    /// Query the graph via property filters + optional traversal.
    Query(commands::query::Args),
    /// Agent-facing retrieval: property filters + cosine vector
    /// (requires an embedder when a text query is set). When a
    /// learned-sparse provider is configured (`[sparse]` in
    /// config.toml), results are fused with the sparse lane via
    /// min-max convex combination (or RRF via `--fusion rrf`). There
    /// is no separate `--sparse` flag: sparse participation is
    /// driven by config.
    Retrieve(commands::retrieve::Args),
    /// Backfill embeddings for nodes that don't have one. Needs
    /// `embed.provider` + `embed.model` configured (see `mnem config`).
    Embed(commands::embed_cmd::Args),
    /// Retro-embed nodes that lack a vector. Like `mnem embed` but
    /// adds `--since <commit>` and a `label + props` fallback for
    /// nodes without a `--summary`. Promoted as the recovery path
    /// when `mnem add node` warned that the embedder was unreachable
    /// (audit-2026-04-25 C7-5).
    #[command(after_long_help = "\
Examples:
  mnem reindex                       # embed every node missing a vector
  mnem reindex --label Person        # only nodes of this label
  mnem reindex --since <commit>      # only nodes added since <commit>
  mnem reindex --force               # re-embed even already-embedded nodes
  mnem reindex --dry-run             # report count without changing anything
")]
    Reindex(commands::reindex::Args),
    /// Embedder-manifest tooling. Currently exposes `audit`, the
    /// CI-enforceable check that every provider declares a noise
    /// floor (Gap 15).
    #[command(subcommand)]
    Embedder(commands::embed_cmd::EmbedderCmd),
    /// Report which refs and how many nodes/edges differ between two ops.
    Diff(commands::diff::Args),
    /// Manage named refs (branches, tags, arbitrary labels).
    #[command(
        subcommand,
        after_long_help = "\
Examples:
  mnem ref list                                 # every ref in the current view
  mnem ref set refs/heads/main <commit-cid>     # point `main` at a commit
  mnem ref delete refs/heads/scratch            # remove a ref

Refs are just named pointers to commit CIDs. mnem does not distinguish
branches from tags at the data layer; the convention `refs/heads/*`
for branches and `refs/tags/*` for tags follows git.
"
    )]
    Ref(commands::refs::RefCmd),
    /// Get or set repository configuration (`user.name`, `user.email`, ...).
    Config(commands::cfg_cmd::Args),
    /// Short one-line stats useful in prompts and shell scripts.
    #[command(after_long_help = "\
Examples:
  mnem stats                     # one-line summary (nodes/edges/refs)
  mnem stats | tee prompt.txt    # append to an LLM system prompt
  watch -n 5 mnem stats          # live tail while ingesting
")]
    Stats,
    /// Export the subtree reachable from a ref / CID to a CAR v1
    /// archive. The file can be shipped over any channel (email, USB,
    /// SSH, S3) and imported on the other side with `mnem import`.
    Export(commands::export::Args),
    /// Import a CAR v1 archive into the current repository. Every
    /// block is CID-verified before being stored.
    Import(commands::import::Args),
    /// Ingest external source files (Markdown / text / PDF / chat JSON)
    /// into the current repository as a Doc + Chunk + Entity subgraph.
    #[command(after_long_help = "\
Examples:
  mnem ingest notes.md                           # single-file markdown
  mnem ingest --chunker recursive --max-tokens 1024 book.pdf
  mnem ingest --recursive docs/                  # walk a directory
")]
    Ingest(commands::ingest::Args),
    /// Manage `refs/heads/<name>` pointers (git analog: `git branch`).
    #[command(
        subcommand,
        after_long_help = "\
Examples:
  mnem branch list                          # every refs/heads/*
  mnem branch create feature/oauth          # at the current head
  mnem branch create hotfix --from <cid>    # at a specific commit
  mnem branch delete old-experiment
"
    )]
    Branch(commands::branch::BranchCmd),
    /// Walk the incoming-edge index for a node and report who points
    /// at it (git analog: `git blame`, but coarser - ).
    #[command(after_long_help = "\
Examples:
  mnem blame <node-uuid>
  mnem blame <node-uuid> --etype authored
")]
    Blame(commands::blame::Args),
    /// Fetch a single node by UUID and print its ntype, summary, and props.
    /// Use `mnem cat-file --json <cid>` when you have a block CID instead.
    #[command(after_long_help = "\
Examples:
  mnem get <uuid>
  mnem get <uuid> --content                 # also print the content body
  mnem global get <uuid>
")]
    Get(commands::get_node::Args),
    /// Soft-delete a node: records a tombstone with an optional reason.
    /// Tombstoned nodes are excluded from `mnem retrieve` and `mnem query`
    /// but remain in the blockstore and op-log for auditing.
    /// Prefer this over `mnem delete` when an audit trail matters.
    #[command(after_long_help = "\
Examples:
  mnem tombstone <uuid>
  mnem tombstone <uuid> --reason \"superseded by newer decision\"
  mnem global tombstone <uuid>
")]
    Tombstone(commands::tombstone::Args),
    /// Hard-delete a node from the current view. No audit trail.
    /// The block remains in the blockstore until `mnem gc` runs.
    /// Use `mnem tombstone` when an audit trail matters.
    #[command(after_long_help = "\
Examples:
  mnem delete <uuid>
  mnem global delete <uuid>
")]
    Delete(commands::delete_node::Args),
    /// Emit the raw bytes of a CID (binary-safe) or a decoded JSON
    /// preview (`--json`). Git analog: `git cat-file`.
    #[command(
        name = "cat-file",
        after_long_help = "\
Examples:
  mnem cat-file <cid>                       # raw DAG-CBOR bytes
  mnem cat-file <cid> --json | jq .         # pretty JSON
"
    )]
    CatFile(commands::cat_file::Args),
    /// Manage the global repo registry (~/.mnemglobal/repos.toml).
    /// Lists, sets the default, or prunes stale entries.
    #[command(subcommand)]
    Repos(commands::repos::ReposCmd),
    /// Read and write the global anchor graph (~/.mnemglobal/.mnem/) directly.
    /// All subcommands operate exclusively on the global graph.
    #[command(subcommand)]
    Global(commands::global_cmd::GlobalCmd),
    /// Manage `[remote.<name>]` entries in `.mnem/config.toml`. Pure
    /// local config-file ops; no network in 0.3.
    #[command(
        subcommand,
        after_long_help = "\
Examples:
  mnem remote add origin file:///tmp/alice.car
  mnem remote list
  mnem remote show origin
  mnem remote remove origin
"
    )]
    Remote(commands::remote::RemoteCmd),
    /// Clone a mnem repo from an archive. 0.3 supports `file://` URLs
    /// and bare `.car` paths; remote schemes land in PR 3.
    #[command(after_long_help = "\
Examples:
  mnem clone file:///tmp/alice.car /tmp/mirror
  mnem clone ./alice.car
")]
    Clone(commands::clone::Args),
    /// Fetch new blocks from a remote (`refs/remotes/<name>/*` tracking refs).
    #[command(after_long_help = "\
Examples:
  mnem fetch                         # fetch from `origin`
  mnem fetch upstream                # explicit remote name

Authentication (when the remote requires it):
  MNEM_REMOTE_ORIGIN_TOKEN=... mnem fetch origin
  MNEM_HTTP_PUSH_TOKEN=...    mnem fetch origin  # fallback
")]
    Fetch(commands::fetch::Args),
    /// Push commits + blocks to a remote and advance its named ref.
    #[command(after_long_help = "\
Examples:
  mnem push                          # push HEAD to origin/main
  mnem push origin main              # explicit remote + branch

Authentication:
  MNEM_REMOTE_ORIGIN_TOKEN=... mnem push origin main
")]
    Push(commands::push::Args),
    /// Fast-forward pull from a remote into the local branch.
    #[command(after_long_help = "\
Examples:
  mnem pull                          # ff origin/main into local main
  mnem pull origin main

Fast-forward only. Use `mnem merge <remote>/<branch>` (B4) for 3-way merges.
")]
    Pull(commands::pull::Args),
    /// 3-way merge between branches. LCA + structured conflict
    /// detector + executor with `ours`/`theirs`/`manual` strategies.
    #[command(after_long_help = "\
Examples:
  mnem merge feature                    # 3-way merge `refs/heads/feature` into HEAD
  mnem merge feature --strategy=ours    # pick left / current side on conflict
  mnem merge feature --strategy=theirs  # pick right / incoming side on conflict
  mnem merge feature --dry-run          # preview outcome, persist nothing
  mnem merge --continue                 # finish an in-progress merge after manual edits
  mnem merge --abort                    # cancel an in-progress merge
")]
    Merge(commands::merge::Args),
    /// Revert a commit. Not implemented in 0.3
    Revert(commands::deferred::RevertArgs),
    /// Check the object DAG for corruption. Not implemented in 0.3
    Fsck(commands::deferred::FsckArgs),
    /// Garbage-collect unreferenced blocks. Not implemented in 0.3
    Gc(commands::deferred::GcArgs),
    /// Wire mnem into every detected MCP agent host (Claude Desktop,
    /// Cursor, Continue, Zed) with atomic backups of the originals.
    Integrate(integrate::Args),
    /// Remove mnem wiring from one or more agent hosts. Reads
    /// `~/.mnemglobal/integrations.toml` to show what is currently
    /// integrated; interactive if no host names are given.
    #[command(after_long_help = "\
Examples:
  mnem unintegrate                  # interactive: pick hosts to remove
  mnem unintegrate claude-code      # remove a specific host
  mnem unintegrate --all            # remove everything
  mnem unintegrate --dry-run --all  # preview without changes
")]
    Unintegrate(unintegrate::UnintegrateArgs),
    /// Non-mutating health check: binaries, repo, config, embedder,
    /// wired hosts. Exits 1 if any check fails.
    Doctor(doctor::Args),
    /// Emit a shell completion script for bash / zsh / fish /
    /// powershell / elvish. Pipe the output into your shell's
    /// completion directory.
    #[command(after_long_help = "\
Examples:
  mnem completions bash > ~/.local/share/bash-completion/completions/mnem
  mnem completions zsh > ~/.zsh/completions/_mnem
  mnem completions fish > ~/.config/fish/completions/mnem.fish
  mnem completions powershell >> $PROFILE
  mnem completions elvish > ~/.config/elvish/lib/mnem.elv
")]
    Completions(commands::completions::Args),
    /// Run mnem-bench: download datasets, run scorers, emit
    /// RESULTS.md. 0.1.0 ships LongMemEval, LoCoMo, ConvoMem,
    /// MemBench (simple-roles + highlevel-movie), and
    /// LongMemEval-hybrid-v4 against the in-process mnem adapter.
    /// mem0 / MemPalace adapters, CPU-parallel mode, and
    /// Docker-compose mode are stubbed and print a "coming 0.2.0"
    /// message at runtime.
    #[command(after_long_help = "\
Examples:
  mnem bench                                          # interactive setup
  mnem bench list --pretty                            # list all benches as JSON
  mnem bench fetch longmemeval                        # download a dataset
  mnem bench run --benches longmemeval --with mnem \\
    --mode cpu-local --out ./bench-out --top-k 10 --limit 5 --non-interactive
  mnem bench results ./bench-out                      # re-render RESULTS.md
")]
    Bench(commands::bench::BenchArgs),
    /// Model Context Protocol server (JSON-RPC over stdio). Exposes
    /// `mnem_retrieve`, `mnem_commit`, `mnem_global_retrieve`, and 12+
    /// more tools to any MCP-aware host (Claude Desktop, Cursor, Claude
    /// Code, Continue, Zed, ...).
    ///
    /// ```bash
    /// mnem mcp                        # auto-detect repo via walk-up
    /// mnem mcp --repo ./my-repo       # explicit repo path
    /// ```
    ///
    /// Use `mnem integrate` to auto-wire this into Claude Desktop etc.;
    /// or configure your client's `mcpServers` entry manually.
    Mcp(mcp::serve::ServeArgs),
    /// Serve the HTTP JSON API. Binds to loopback by default; set
    /// `--bind 0.0.0.0:9876` to expose (requires opt-in, see help).
    ///
    /// ```bash
    /// mnem http --bind 127.0.0.1:9876 --repo .  # HTTP API server
    /// ```
    Http(http::serve::ServeArgs),
}

fn main() {
    let cli = Cli::parse();
    // Track whether the failure was an deferred-stub so the
    // caller gets the BSD-sysexits EX_CONFIG exit code instead of the
    // generic 1. We distinguish by pattern-matching the verb BEFORE
    // dispatch, not by parsing the error string, so future refactors
    // of `deferred::ex_config` can't drift the exit code.
    let is_deferred = matches!(&cli.cmd, Some(Cmd::Revert(_) | Cmd::Fsck(_) | Cmd::Gc(_)));
    let result = match cli.cmd {
        None => wizard::run(cli.repo.as_deref()),
        Some(Cmd::Init(args)) => commands::init::run(cli.repo.as_deref(), args),
        Some(Cmd::Status) => commands::status::run(cli.repo.as_deref()),
        Some(Cmd::Log(args)) => commands::log::run(cli.repo.as_deref(), args),
        Some(Cmd::Show(args)) => commands::show::run(cli.repo.as_deref(), args),
        Some(Cmd::Add(sub)) => commands::add::run(cli.repo.as_deref(), sub),
        Some(Cmd::Query(args)) => commands::query::run(cli.repo.as_deref(), args),
        Some(Cmd::Retrieve(args)) => commands::retrieve::run(cli.repo.as_deref(), args),
        Some(Cmd::Embed(args)) => commands::embed_cmd::run(cli.repo.as_deref(), args),
        Some(Cmd::Reindex(args)) => commands::reindex::run(cli.repo.as_deref(), args),
        Some(Cmd::Embedder(sub)) => {
            commands::embed_cmd::run_embedder(commands::embed_cmd::EmbedderArgs { cmd: sub })
        }
        Some(Cmd::Diff(args)) => commands::diff::run(cli.repo.as_deref(), args),
        Some(Cmd::Ref(sub)) => commands::refs::run(cli.repo.as_deref(), sub),
        Some(Cmd::Config(args)) => commands::cfg_cmd::run(cli.repo.as_deref(), args),
        Some(Cmd::Stats) => commands::stats::run(cli.repo.as_deref()),
        Some(Cmd::Export(args)) => commands::export::run(cli.repo.as_deref(), args),
        Some(Cmd::Import(args)) => commands::import::run(cli.repo.as_deref(), args),
        Some(Cmd::Ingest(args)) => commands::ingest::run(cli.repo.as_deref(), args),
        Some(Cmd::Branch(sub)) => commands::branch::run(cli.repo.as_deref(), sub),
        Some(Cmd::Blame(args)) => commands::blame::run(cli.repo.as_deref(), args),
        Some(Cmd::Get(args)) => commands::get_node::run(cli.repo.as_deref(), args),
        Some(Cmd::Tombstone(args)) => commands::tombstone::run(cli.repo.as_deref(), args),
        Some(Cmd::Delete(args)) => commands::delete_node::run(cli.repo.as_deref(), args),
        Some(Cmd::CatFile(args)) => commands::cat_file::run(cli.repo.as_deref(), args),
        Some(Cmd::Repos(sub)) => commands::repos::run(cli.repo.as_deref(), sub),
        Some(Cmd::Global(sub)) => commands::global_cmd::run(cli.repo.as_deref(), sub),
        Some(Cmd::Remote(sub)) => commands::remote::run(cli.repo.as_deref(), sub),
        Some(Cmd::Clone(args)) => commands::clone::run(cli.repo.as_deref(), args),
        Some(Cmd::Fetch(a)) => commands::fetch::run(cli.repo.as_deref(), a),
        Some(Cmd::Push(a)) => commands::push::run(cli.repo.as_deref(), a),
        Some(Cmd::Pull(a)) => commands::pull::run(cli.repo.as_deref(), a),
        Some(Cmd::Merge(a)) => commands::merge::run(cli.repo.as_deref(), a),
        Some(Cmd::Revert(a)) => commands::deferred::run_revert(cli.repo.as_deref(), a),
        Some(Cmd::Fsck(a)) => commands::deferred::run_fsck(cli.repo.as_deref(), a),
        Some(Cmd::Gc(a)) => commands::deferred::run_gc(cli.repo.as_deref(), a),
        Some(Cmd::Integrate(args)) => integrate::run(args),
        Some(Cmd::Unintegrate(args)) => unintegrate::run(args),
        Some(Cmd::Doctor(args)) => doctor::run(cli.repo.as_deref(), args),
        Some(Cmd::Completions(args)) => commands::completions::run(args),
        Some(Cmd::Bench(args)) => commands::bench::run(args),
        Some(Cmd::Mcp(args)) => mcp::serve::run(args),
        Some(Cmd::Http(args)) => http::serve::run(args),
    };
    if let Err(e) = result {
        // audit-2026-04-25 R4 (Stage E re-fix): print the anyhow
        // error chain manually with consecutive-duplicate elision.
        // The previous `{e:#}` form printed each segment twice for
        // mnem-core errors that wrap their inner Display in the
        // outer variant's `#[error("repo: {0}")]` while ALSO
        // exposing the inner as `#[source]` via `#[from]` -- the
        // chain walker then visited both. Surfaced as e.g.
        // `error: repo: retrieve: ...: retrieve: ...`.
        let mut last: Option<String> = None;
        let mut parts: Vec<String> = Vec::new();
        for cause in e.chain() {
            let s = format!("{cause}");
            // Skip a segment if it is identical to, OR a strict
            // suffix of, the previous segment. Strict-suffix
            // catches `repo: retrieve: ...` followed by
            // `retrieve: ...`.
            if let Some(prev) = &last
                && (prev == &s || prev.ends_with(&s))
            {
                continue;
            }
            last = Some(s.clone());
            parts.push(s);
        }
        eprintln!("error: {}", parts.join(": "));
        // EX_CONFIG (78) per BSD sysexits for "user asked for
        // something the config doesn't allow yet" - this is the
        // shape of a deferred-stub failure.
        std::process::exit(if is_deferred { 78 } else { 1 });
    }
}