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}