Skip to main content

sqlite_graphrag/cli/
commands.rs

1//! The subcommand surface: every `Commands` variant and its classification.
2//!
3//! Holds the `Commands` enum, the predicates `main` uses to route a variant,
4//! and the manual `Debug` impl.
5
6use crate::commands::*;
7use clap::Subcommand;
8
9/// Every subcommand the CLI dispatches, in the order `--help` renders them.
10#[derive(Subcommand)]
11pub enum Commands {
12    /// Initialize the database and write the schema (no model download, no subprocess)
13    #[command(after_long_help = "EXAMPLES:\n  \
14        # Initialize in current directory (default behavior)\n  \
15        sqlite-graphrag init\n\n  \
16        # Initialize at a specific path\n  \
17        sqlite-graphrag init --db /path/to/graphrag.sqlite\n\n  \
18        # Persist default db path via XDG config (no product env)\n  \
19        sqlite-graphrag config set db.path /data/graphrag.sqlite\n  \
20        sqlite-graphrag init\n\n\
21        NOTES:\n  \
22        - `init` is OPTIONAL: any subsequent CRUD command auto-initializes graphrag.sqlite if missing.\n  \
23        - As a side effect, `init` warms a smoke-test embedding via the LLM-only one-shot pipeline.")]
24    Init(init::InitArgs),
25    /// Save a memory with optional entity graph
26    #[command(after_long_help = "EXAMPLES:\n  \
27        # Inline body\n  \
28        sqlite-graphrag remember --name onboarding --type user --description \"intro\" --body \"hello\"\n\n  \
29        # Body from file\n  \
30        sqlite-graphrag remember --name doc1 --type document --description \"...\" --body-file ./README.md\n\n  \
31        # Body from stdin (pipe)\n  \
32        cat README.md | sqlite-graphrag remember --name doc1 --type document --description \"...\" --body-stdin\n\n  \
33        # Enable automatic URL extraction (URL-regex only since v1.0.79)\n  \
34        sqlite-graphrag remember --name rich --type note --description \"...\" --body \"...\" --enable-ner")]
35    Remember(remember::RememberArgs),
36    /// Batch-create memories from NDJSON stdin (one invocation, one slot)
37    #[command(after_long_help = "EXAMPLES:\n  \
38        # Batch create from NDJSON\n  \
39        cat memories.ndjson | sqlite-graphrag remember-batch --force-merge --json\n\n  \
40        # Atomic batch\n  \
41        cat memories.ndjson | sqlite-graphrag remember-batch --transaction --json")]
42    RememberBatch(remember_batch::RememberBatchArgs),
43    /// Bulk-ingest every file under a directory as separate memories (NDJSON output)
44    Ingest(Box<ingest::IngestArgs>),
45    /// Search memories semantically
46    #[command(after_long_help = "EXAMPLES:\n  \
47        # Top 10 semantic matches (default)\n  \
48        sqlite-graphrag recall \"agent memory\"\n\n  \
49        # Top 3 only\n  \
50        sqlite-graphrag recall \"agent memory\" -k 3\n\n  \
51        # Search across all namespaces\n  \
52        sqlite-graphrag recall \"agent memory\" --all-namespaces\n\n  \
53        # Disable graph traversal (vector-only)\n  \
54        sqlite-graphrag recall \"agent memory\" --no-graph")]
55    Recall(recall::RecallArgs),
56    /// Read a memory by exact name
57    Read(read::ReadArgs),
58    /// List memories with filters
59    List(list::ListArgs),
60    /// Soft-delete a memory
61    Forget(forget::ForgetArgs),
62    /// Permanently delete soft-deleted memories
63    Purge(purge::PurgeArgs),
64    /// Rename a memory preserving history
65    Rename(rename::RenameArgs),
66    /// Split an oversized memory body into N child memories (v1.1.03, GAP-V8)
67    SplitBody(split_body::SplitBodyArgs),
68    /// Edit a memory's body or description
69    Edit(edit::EditArgs),
70    /// List all versions of a memory
71    History(history::HistoryArgs),
72    /// Restore a memory to a previous version
73    Restore(restore::RestoreArgs),
74    /// Search using hybrid vector + full-text search
75    #[command(after_long_help = "EXAMPLES:\n  \
76        # Hybrid search combining KNN + FTS5 BM25 with RRF\n  \
77        sqlite-graphrag hybrid-search \"agent memory architecture\"\n\n  \
78        # Custom weights for vector vs full-text components\n  \
79        sqlite-graphrag hybrid-search \"agent\" --weight-vec 0.7 --weight-fts 0.3")]
80    HybridSearch(hybrid_search::HybridSearchArgs),
81    /// Show database health
82    Health(health::HealthArgs),
83    /// Apply pending schema migrations
84    Migrate(migrate::MigrateArgs),
85    /// Resolve namespace precedence for the current invocation
86    NamespaceDetect(namespace_detect::NamespaceDetectArgs),
87    /// Run PRAGMA optimize on the database
88    Optimize(optimize::OptimizeArgs),
89    /// Show database statistics
90    Stats(stats::StatsArgs),
91    /// Create a checkpointed copy safe for file sync
92    SyncSafeCopy(sync_safe_copy::SyncSafeCopyArgs),
93    /// Back up the database using the SQLite Online Backup API
94    Backup(backup::BackupArgs),
95    /// Run VACUUM after checkpointing the WAL
96    Vacuum(vacuum::VacuumArgs),
97    /// Create an explicit relationship between two entities
98    Link(link::LinkArgs),
99    /// Remove a specific relationship between two entities
100    Unlink(unlink::UnlinkArgs),
101    /// Deep parallel multi-hop GraphRAG research
102    #[command(name = "deep-research")]
103    DeepResearch(deep_research::DeepResearchArgs),
104    /// List memories connected via the entity graph
105    Related(related::RelatedArgs),
106    /// Export a graph snapshot in json, dot or mermaid
107    Graph(graph_export::GraphArgs),
108    /// Export memories as NDJSON (one JSON line per memory, plus a summary line)
109    Export(export::ExportArgs),
110    /// FTS5 full-text search index management (rebuild or check)
111    Fts(fts::FtsArgs),
112    /// Vector index maintenance (orphan detection, purge, stats) — G39
113    Vec(vec::VecArgs),
114    /// Bulk-delete all relationships of a given type (e.g. mentions)
115    PruneRelations(prune_relations::PruneRelationsArgs),
116    /// Remove NER bindings (memory_entities rows) for an entity or all entities
117    #[command(name = "prune-ner")]
118    PruneNer(prune_ner::PruneNerArgs),
119    /// Inspect and manage cross-process LLM slot semaphore (GAP-004, v1.0.82)
120    Slots(slots::SlotsArgs),
121    /// Inspect and manage the `remember` checkpoint queue (GAP-001, v1.0.82)
122    Pending(pending::PendingArgs),
123    /// Health and per-entry inspection of the pending-embeddings queue (GAP-005, v1.0.82)
124    Embedding(embedding::EmbeddingArgs),
125    /// Batch operations over the pending-embeddings queue (GAP-005, v1.0.82)
126    #[command(name = "pending-embeddings")]
127    PendingEmbeddings(pending_embeddings::PendingEmbeddingsArgs),
128    /// Remove entities that have no memories and no relationships
129    CleanupOrphans(cleanup_orphans::CleanupOrphansArgs),
130    /// List entities linked to a specific memory
131    MemoryEntities(memory_entities::MemoryEntitiesArgs),
132    /// Manage cached resources (embedding models, etc.)
133    Cache(cache::CacheArgs),
134    /// Delete an entity and all its relationships from the graph
135    #[command(name = "delete-entity")]
136    DeleteEntity(delete_entity::DeleteEntityArgs),
137    /// Reclassify one entity or a batch of entities to a new type
138    Reclassify(reclassify::ReclassifyArgs),
139    /// Rename an entity preserving all relationships and memory bindings
140    #[command(name = "rename-entity")]
141    RenameEntity(rename_entity::RenameEntityArgs),
142    /// Merge multiple source entities into a single target entity
143    #[command(name = "merge-entities")]
144    MergeEntities(merge_entities::MergeEntitiesArgs),
145    /// Enrich graph memories and entities using an LLM provider
146    Enrich(Box<enrich::EnrichArgs>),
147    /// Reclassify relationship types across the graph using rules or LLM judgment
148    #[command(name = "reclassify-relation")]
149    ReclassifyRelation(reclassify_relation::ReclassifyRelationArgs),
150    /// Normalize entity names (deduplicate, kebab-case, merge near-duplicates)
151    #[command(name = "normalize-entities")]
152    NormalizeEntities(normalize_entities::NormalizeEntitiesArgs),
153    /// Generate shell completions for Bash, Zsh, Fish, PowerShell, or Elvish
154    Completions(completions::CompletionsArgs),
155    /// List every shipped JSON Schema, or emit one by id (`--name <ID>`)
156    #[command(after_long_help = "EXAMPLES:\n  \
157        # Catalogue: one NDJSON record per contract\n  \
158        sqlite-graphrag schema\n\n  \
159        # One contract by id\n  \
160        sqlite-graphrag schema --name recall\n\n\
161        NOTES:\n  \
162        - Never opens the database and never requires an embedding API key.\n  \
163        - The per-subcommand `--print-schema` flags keep working unchanged.")]
164    Schema(crate::print_schema::SchemaArgs),
165    /// `debug-schema` subcommand.
166    #[command(name = "debug-schema", hide = true)]
167    DebugSchema(debug_schema::DebugSchemaArgs),
168    /// Manage API keys and diagnose provider configuration (v1.0.93)
169    Config(config_cmd::ConfigArgs),
170}
171
172impl Commands {
173    /// Names the subcommand for [`crate::agent_surface`] alias suppression.
174    ///
175    /// The suppression table used to match on the KEY alone, so `results` meant
176    /// the same thing everywhere. It does not: in `recall`, `results` really is
177    /// the concatenation of `direct_matches` and `graph_matches`, so dropping
178    /// the halves loses nothing. In `hybrid-search` the two arrays are DISJOINT
179    /// by construction — the graph expansion skips every id already fused — and
180    /// they do not even hold the same type. Suppressing there deleted unique
181    /// rows and then labelled them redundant, which is worse than losing them
182    /// silently: the envelope asserted the removal was safe.
183    ///
184    /// `None` for every subcommand that declares no alias, which makes the
185    /// default fail-safe: a new command is never suppressed until someone adds
186    /// it to the table deliberately.
187    #[must_use]
188    pub fn agent_surface_slug(&self) -> Option<&'static str> {
189        match self {
190            Self::List(_) => Some("list"),
191            Self::Graph(_) => Some("graph"),
192            Self::Recall(_) => Some("recall"),
193            Self::Related(_) => Some("related"),
194            _ => None,
195        }
196    }
197
198    /// `true` when this subcommand can change durable state.
199    ///
200    /// GAP-SG-205 reads it to decide whether the target database may be
201    /// inherited from ambient configuration; [`crate::agent_surface::gate`]
202    /// reads it to decide whether a refusal is still safe.
203    ///
204    /// The refusal question is the sharper one. The agent-native surface runs at
205    /// OUTPUT time, after the handler has already done its work, so refusing
206    /// there would hand the caller a non-zero exit for an operation that
207    /// succeeded — and a caller that retries a succeeded `remember` writes the
208    /// memory twice. The gate therefore stays silent on anything this reports as
209    /// mutating.
210    ///
211    /// Read-only variants are listed EXPLICITLY and everything else answers
212    /// `true`. The default has to be the conservative one: a subcommand added
213    /// later and forgotten here loses a refusal it might have wanted, which
214    /// costs a diagnostic, while the opposite default would let the gate fire
215    /// after an unlisted write, which costs data.
216    pub fn mutates(&self) -> bool {
217        match self {
218            Self::Recall(_)
219            | Self::Read(_)
220            | Self::List(_)
221            | Self::History(_)
222            | Self::HybridSearch(_)
223            | Self::Health(_)
224            | Self::NamespaceDetect(_)
225            | Self::Stats(_)
226            | Self::DeepResearch(_)
227            | Self::Related(_)
228            | Self::Export(_)
229            | Self::MemoryEntities(_)
230            | Self::Schema(_)
231            | Self::DebugSchema(_)
232            | Self::Completions(_) => false,
233            // `graph` is read-only in three of its four forms; `recompute-degree`
234            // rewrites the cached degree column.
235            Self::Graph(args) => matches!(
236                args.subcommand,
237                Some(crate::commands::graph_export::GraphSubcommand::RecomputeDegree(_))
238            ),
239            _ => true,
240        }
241    }
242
243    /// Whether this subcommand may resolve its target from ambient configuration.
244    ///
245    /// GAP-SG-207. [`Self::mutates`] answers "does this change durable state";
246    /// this answers "is naming the target nonetheless optional for THIS
247    /// invocation". The two differ, and reusing `mutates` alone would have been
248    /// a defect: it lists the read-only variants explicitly and answers `true`
249    /// for everything else, which is the right conservative default for the
250    /// output-time refusal fence and the WRONG one here. For the fence a
251    /// mistaken `true` costs a diagnostic; here it would cost a false refusal on
252    /// a command that has no side effect to protect — `fts check`, `vec stats`,
253    /// `pending list` and `embedding status` all read and write nothing.
254    ///
255    /// So the families whose subcommands split between reading and writing are
256    /// classified at the SUBCOMMAND level. The Explicit Target Designation rule
257    /// governs side effects, and a read inherits no authority it could misuse.
258    ///
259    /// Enforcement lives in [`crate::paths::AppPaths::resolve`]. That placement
260    /// keeps this list short: a subcommand that never resolves a database —
261    /// `config`, `completions`, `locale`, `slots`, `cache` — is exempt by
262    /// construction and needs no entry here at all.
263    pub fn may_inherit_target(&self) -> bool {
264        use crate::commands::embedding::EmbeddingCmd;
265        use crate::commands::fts::FtsSubcommand;
266        use crate::commands::pending::PendingCmd;
267        use crate::commands::pending_embeddings::PendingEmbeddingsCmd;
268        use crate::commands::vec::VecSubcommand;
269
270        match self {
271            // Creating the XDG database when no `--db` is given IS the command,
272            // so requiring the flag would invert `init` rather than protect it.
273            Self::Init(_) => true,
274            // Host leaves. GAP-SG-139 fixed these to accept `--db` as a no-op
275            // precisely because they touch no database — but they still call
276            // `AppPaths::resolve` to locate the MODELS directory, which shares
277            // that resolver. Without this arm the target policy fired on
278            // `cache list`, a command that reads a cache and nothing else.
279            Self::Config(_) | Self::Cache(_) | Self::Slots(_) | Self::Completions(_) => true,
280            Self::Fts(args) => matches!(
281                args.command,
282                FtsSubcommand::Check(_) | FtsSubcommand::Stats(_)
283            ),
284            Self::Vec(args) => matches!(
285                args.command,
286                VecSubcommand::OrphanList(_) | VecSubcommand::Stats(_)
287            ),
288            Self::Pending(args) => {
289                matches!(args.cmd, PendingCmd::List(_) | PendingCmd::Show(_))
290            }
291            Self::Embedding(args) => {
292                matches!(args.cmd, EmbeddingCmd::List(_) | EmbeddingCmd::Status(_))
293            }
294            Self::PendingEmbeddings(args) => matches!(
295                args.cmd,
296                PendingEmbeddingsCmd::List(_) | PendingEmbeddingsCmd::Status(_)
297            ),
298            _ => false,
299        }
300    }
301
302    /// Returns true for subcommands that load the ONNX model locally.
303    pub fn is_embedding_heavy(&self) -> bool {
304        matches!(
305            self,
306            Self::Init(_)
307                | Self::Remember(_)
308                | Self::RememberBatch(_)
309                | Self::Recall(_)
310                | Self::HybridSearch(_)
311                | Self::DeepResearch(_)
312        )
313    }
314
315    /// Return whether this command occupies a CLI concurrency slot.
316    pub fn uses_cli_slot(&self) -> bool {
317        true
318    }
319
320    /// Read-only / no-embedding subcommands that MUST run without an embedding
321    /// API key. `init` warms a best-effort smoke test internally and degrades to
322    /// `ok_no_embedding` when the backend is unreachable; the `enrich` queue
323    /// inspectors (`--status` / `--list-dead` / `--requeue-dead` /
324    /// `--prune-dead-orphans`) never embed and never call the LLM. The eager
325    /// OpenRouter key preflight in `main` must skip its hard-fail for these.
326    pub fn tolerates_missing_embedding_key(&self) -> bool {
327        match self {
328            Self::Init(_) => true,
329            // `schema` only writes embedded documents: no database, no key.
330            Self::Schema(_) => true,
331            // The host leaves must never be gated by embedding configuration:
332            // they are the only way to REPAIR that configuration. Registering
333            // `embedding.backend` (v1.2.5, GAP-SG-198) made the omission
334            // load-bearing — `config set embedding.backend openrouter` with no
335            // model stored started failing every later invocation at the
336            // preflight, `config unset` included, leaving hand-editing the TOML
337            // as the only exit. `cache`, `slots` and `completions` never embed
338            // either, so the same reasoning covers them.
339            Self::Config(_) | Self::Cache(_) | Self::Slots(_) | Self::Completions(_) => true,
340            Self::Enrich(args) => {
341                args.status
342                    || args.list_dead
343                    || args.requeue_dead
344                    || args.list_skipped
345                    || args.requeue_skipped
346                    || args.prune_dead_orphans
347                    || args.prune_dead_entity_orphans
348                    || args.print_schema
349            }
350            _ => false,
351        }
352    }
353}
354
355// FIX-1 (v1.0.89): manual `Debug` impl so test panic messages that print
356// `{:?}` on a captured `Commands` variant compile without requiring every
357// contained subcommand arg struct to derive `Debug`. The Debug output is
358// only used in test assertions for diagnostic messages; we emit the variant
359// name only — arg payload is intentionally omitted.
360impl std::fmt::Debug for Commands {
361    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
362        let name = match self {
363            Self::Init(_) => "Init",
364            Self::Health(_) => "Health",
365            Self::Stats(_) => "Stats",
366            Self::List(_) => "List",
367            Self::Read(_) => "Read",
368            Self::Edit(_) => "Edit",
369            Self::Rename(_) => "Rename",
370            Self::SplitBody(_) => "SplitBody",
371            Self::Restore(_) => "Restore",
372            Self::History(_) => "History",
373            Self::Forget(_) => "Forget",
374            Self::Purge(_) => "Purge",
375            Self::Remember(_) => "Remember",
376            Self::RememberBatch(_) => "RememberBatch",
377            Self::Recall(_) => "Recall",
378            Self::HybridSearch(_) => "HybridSearch",
379            Self::Enrich(_) => "Enrich",
380            Self::Ingest(_) => "Ingest",
381            Self::Optimize(_) => "Optimize",
382            Self::Migrate(_) => "Migrate",
383            Self::SyncSafeCopy(_) => "SyncSafeCopy",
384            Self::Backup(_) => "Backup",
385            Self::Vacuum(_) => "Vacuum",
386            Self::Link(_) => "Link",
387            Self::Unlink(_) => "Unlink",
388            Self::DeepResearch(_) => "DeepResearch",
389            Self::Related(_) => "Related",
390            Self::Graph(_) => "Graph",
391            Self::Export(_) => "Export",
392            Self::Fts(_) => "Fts",
393            Self::Vec(_) => "Vec",
394            Self::PruneRelations(_) => "PruneRelations",
395            Self::PruneNer(_) => "PruneNer",
396            Self::Slots(_) => "Slots",
397            Self::Pending(_) => "Pending",
398            Self::Embedding(_) => "Embedding",
399            Self::PendingEmbeddings(_) => "PendingEmbeddings",
400            Self::CleanupOrphans(_) => "CleanupOrphans",
401            Self::MemoryEntities(_) => "MemoryEntities",
402            Self::Cache(_) => "Cache",
403            Self::DeleteEntity(_) => "DeleteEntity",
404            Self::Reclassify(_) => "Reclassify",
405            Self::RenameEntity(_) => "RenameEntity",
406            Self::ReclassifyRelation(_) => "ReclassifyRelation",
407            Self::NormalizeEntities(_) => "NormalizeEntities",
408            Self::MergeEntities(_) => "MergeEntities",
409            Self::NamespaceDetect(_) => "NamespaceDetect",
410            Self::Completions(_) => "Completions",
411            Self::Schema(_) => "Schema",
412            Self::DebugSchema(_) => "DebugSchema",
413            Self::Config(_) => "Config",
414        };
415        f.write_str(name)
416    }
417}