Skip to main content

sqlite_graphrag/
cli.rs

1//! CLI argument structs and command surface (clap-based).
2//!
3//! Defines `Cli` and all subcommand enums; contains no business logic.
4
5use crate::commands::*;
6use crate::i18n::{current, Language};
7use clap::{Parser, Subcommand};
8
9/// Returns the maximum simultaneous invocations allowed by the CPU heuristic.
10fn max_concurrency_ceiling() -> usize {
11    std::thread::available_parallelism()
12        .map(|n| n.get() * 2)
13        .unwrap_or(8)
14}
15
16#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
17pub enum GraphExportFormat {
18    Json,
19    Dot,
20    Mermaid,
21    /// Stream one JSON object per entity, then one per edge, then a summary line.
22    Ndjson,
23}
24
25/// v1.0.82 (GAP-003): backend LLM para embedding. Aceita `auto` (default —
26/// detecta `codex` ou `claude` no PATH), `codex` (força codex exec), `claude`
27/// (força claude -p), ou `none` (skip-a embedding; útil para testes).
28#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
29pub enum LlmBackendChoice {
30    Auto,
31    Claude,
32    Codex,
33    None,
34}
35
36impl LlmBackendChoice {
37    /// v1.0.82 (GAP-003): converte a escolha do CLI em uma chain ordenada
38    /// de backends que `embedder::embed_with_fallback` itera. O primeiro
39    /// elemento da chain é o backend preferido; elementos subsequentes
40    /// são fallbacks quando o preferido falha com `LlmBackendError`.
41    ///
42    /// `Auto` produz `[Codex, Claude, None]` — codex é o default da v1.0.76+,
43    /// claude é o fallback se codex falhar (OAuth contention, quota), e
44    /// `None` permite `embed_with_fallback` retornar vetor vazio quando
45    /// `skip_on_failure` está ativo.
46    pub fn to_chain(self) -> Vec<crate::embedder::LlmBackendKind> {
47        use crate::embedder::LlmBackendKind;
48        match self {
49            LlmBackendChoice::Codex => vec![LlmBackendKind::Codex, LlmBackendKind::None],
50            LlmBackendChoice::Claude => vec![LlmBackendKind::Claude, LlmBackendKind::None],
51            LlmBackendChoice::None => vec![LlmBackendKind::None],
52            LlmBackendChoice::Auto => parse_fallback_chain(
53                &std::env::var("SQLITE_GRAPHRAG_LLM_FALLBACK")
54                    .unwrap_or_else(|_| "codex,claude,none".to_string()),
55            ),
56        }
57    }
58}
59
60fn parse_fallback_chain(s: &str) -> Vec<crate::embedder::LlmBackendKind> {
61    use crate::embedder::LlmBackendKind;
62    let mut chain: Vec<LlmBackendKind> = s
63        .split(',')
64        .filter_map(|tok| match tok.trim().to_ascii_lowercase().as_str() {
65            "codex" => Some(LlmBackendKind::Codex),
66            "claude" | "claude-code" => Some(LlmBackendKind::Claude),
67            "none" => Some(LlmBackendKind::None),
68            _ => {
69                tracing::warn!(token = tok.trim(), "unknown backend in --llm-fallback, skipping");
70                Option::None
71            }
72        })
73        .collect();
74    if chain.is_empty() {
75        chain = vec![LlmBackendKind::Codex, LlmBackendKind::Claude, LlmBackendKind::None];
76    }
77    chain
78}
79
80#[derive(Parser)]
81#[command(name = "sqlite-graphrag")]
82#[command(version)]
83#[command(about = "Local GraphRAG memory for LLMs in a single SQLite file")]
84#[command(arg_required_else_help = true)]
85pub struct Cli {
86    /// Maximum number of simultaneous CLI invocations allowed (default: 4).
87    ///
88    /// Caps the counting semaphore used for CLI concurrency slots. The value must
89    /// stay within [1, 2×nCPUs]. Values above the ceiling are rejected with exit 2.
90    #[arg(long, global = true, value_name = "N")]
91    pub max_concurrency: Option<usize>,
92
93    /// Wait up to SECONDS for a free concurrency slot before giving up (exit 75).
94    ///
95    /// Useful in retrying agent pipelines: the process polls every 500 ms until a
96    /// slot opens or the timeout expires. Default: 300s (5 minutes).
97    #[arg(long, global = true, value_name = "SECONDS")]
98    pub wait_lock: Option<u64>,
99
100    /// Skip the available-memory check before loading the model.
101    ///
102    /// Exclusive use in automated tests where real allocation does not occur.
103    #[arg(long, global = true, hide = true, default_value_t = false)]
104    pub skip_memory_guard: bool,
105
106    /// v1.0.83 (ADR-0041): strict env-clear mode for compliance environments.
107    ///
108    /// When enabled, the LLM subprocess receives ONLY `PATH` — no
109    /// `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_BASE_URL`, `OPENAI_BASE_URL`
110    /// or other custom-provider credentials are forwarded. Defaults to
111    /// the standard v1.0.83 whitelist that preserves custom-provider
112    /// credentials (ADR-0041). Honors env var
113    /// `SQLITE_GRAPHRAG_STRICT_ENV_CLEAR=1` when set.
114    #[arg(
115        long,
116        global = true,
117        hide = true,
118        default_value_t = false,
119        value_parser = clap::builder::BoolishValueParser::new(),
120        env = "SQLITE_GRAPHRAG_STRICT_ENV_CLEAR"
121    )]
122    pub strict_env_clear: bool,
123
124    /// v1.0.84 (ADR-0042 / GAP-002): resolve and print the LLM backend that
125    /// WOULD be invoked for embedding (binary path + model + flavour),
126    /// then exit 0 without executing the subprocess. Useful for CI
127    /// audit and sanity-check of `--llm-backend` before long sessions.
128    ///
129    /// Honors env var `SQLITE_GRAPHRAG_DRY_RUN_BACKEND=1` when set.
130    #[arg(
131        long,
132        global = true,
133        hide = true,
134        default_value_t = false,
135        value_parser = clap::builder::BoolishValueParser::new(),
136        env = "SQLITE_GRAPHRAG_DRY_RUN_BACKEND"
137    )]
138    pub dry_run_backend: bool,
139
140    /// Language for human-facing stderr messages. Accepts `en` or `pt`.
141    ///
142    /// Without the flag, detection falls back to `SQLITE_GRAPHRAG_LANG` and then
143    /// `LC_ALL`/`LANG`. JSON stdout stays deterministic and identical across
144    /// languages; only human-facing strings are affected.
145    #[arg(long, global = true, value_enum, value_name = "LANG")]
146    pub lang: Option<crate::i18n::Language>,
147
148    /// Time zone for `*_iso` fields in JSON output (for example `America/Sao_Paulo`).
149    ///
150    /// Accepts any IANA time zone name. Without the flag, it falls back to
151    /// `SQLITE_GRAPHRAG_DISPLAY_TZ`; if unset, UTC is used. Integer epoch fields
152    /// are not affected.
153    #[arg(long, global = true, value_name = "IANA")]
154    pub tz: Option<chrono_tz::Tz>,
155
156    /// Increase logging verbosity (-v=info, -vv=debug, -vvv=trace).
157    ///
158    /// Overrides `SQLITE_GRAPHRAG_LOG_LEVEL` env var when present. Logs are emitted
159    /// to stderr; JSON stdout is unaffected.
160    #[arg(short = 'v', long, global = true, action = clap::ArgAction::Count)]
161    pub verbose: u8,
162
163    /// v1.0.75 (G21 solution): extraction backend selector. Accepts
164    /// `llm` (default), `embedding` (legacy), `none`, or `both` (composite).
165    /// The `llm` backend invokes claude code / codex CLI headless to extract
166    /// entities and relationships; `embedding` is a permanent stub since
167    /// v1.0.79 (legacy fastembed pipeline removed) that returns a clear
168    /// migration error.
169    #[arg(long, global = true, value_name = "KIND", default_value = "llm")]
170    pub extraction_backend: Option<String>,
171
172    /// v1.0.79 (G42/S1): embedding dimensionality override (default 64).
173    ///
174    /// Precedence: this flag > `SQLITE_GRAPHRAG_EMBEDDING_DIM` env var >
175    /// the `dim` recorded in the database `schema_meta` > 64. Existing
176    /// databases keep their recorded dimensionality automatically; use
177    /// this flag only to migrate a corpus to a new dimensionality
178    /// (followed by `enrich --operation re-embed`). Range: [8, 4096].
179    #[arg(long, global = true, value_name = "N", value_parser = clap::value_parser!(u64).range(8..=4096))]
180    pub embedding_dim: Option<u64>,
181
182    /// v1.0.82 (GAP-003) / v1.0.84 (ADR-0042): backend LLM para embedding.
183    /// Aceita `auto` (detecta via PATH, codex-first), `codex` (força
184    /// `codex exec`), `claude` (força `claude -p`; desde v1.0.84 NÃO cai em
185    /// codex — emite `AppError::Validation` se `claude` ausente), ou `none`
186    /// (skip-a embedding; útil para testes). Honra env var
187    /// `SQLITE_GRAPHRAG_LLM_BACKEND`.
188    #[arg(long, global = true, value_enum, default_value_t = LlmBackendChoice::Auto, env = "SQLITE_GRAPHRAG_LLM_BACKEND")]
189    pub llm_backend: LlmBackendChoice,
190
191    /// v1.0.82 (GAP-003): modelo a invocar no backend escolhido.
192    /// Honra env var `SQLITE_GRAPHRAG_LLM_MODEL`. Default depende
193    /// do backend (codex: `gpt-5.5`; claude: `claude-sonnet-4-6`).
194    #[arg(
195        long,
196        global = true,
197        value_name = "MODEL",
198        env = "SQLITE_GRAPHRAG_LLM_MODEL"
199    )]
200    pub llm_model: Option<String>,
201
202    /// v1.0.82 (GAP-003): path para o binário `claude` (override de
203    /// detecção via PATH). Honra env var `SQLITE_GRAPHRAG_CLAUDE_BINARY`.
204    #[arg(
205        long,
206        global = true,
207        value_name = "PATH",
208        env = "SQLITE_GRAPHRAG_CLAUDE_BINARY"
209    )]
210    pub claude_binary: Option<std::path::PathBuf>,
211
212    /// v1.0.89 (GAP-1): path para o binário `codex` (override de
213    /// detecção via PATH). Honra env var `SQLITE_GRAPHRAG_CODEX_BINARY`.
214    #[arg(
215        long,
216        global = true,
217        value_name = "PATH",
218        env = "SQLITE_GRAPHRAG_CODEX_BINARY"
219    )]
220    pub codex_binary: Option<std::path::PathBuf>,
221
222    /// v1.0.82 (GAP-005): cadeia de backends LLM tentados em ordem
223    /// quando o primário falha. Default `codex,claude,none`. Honra
224    /// env var `SQLITE_GRAPHRAG_LLM_FALLBACK`.
225    #[arg(
226        long,
227        global = true,
228        default_value = "codex,claude,none",
229        env = "SQLITE_GRAPHRAG_LLM_FALLBACK"
230    )]
231    pub llm_fallback: String,
232
233    /// v1.0.82 (GAP-005): persiste com embedding NULL quando todos
234    /// os backends da cadeia falham. Memória fica em `pending_embeddings`
235    /// para reprocessamento via `embedding retry`. Honra env var
236    /// `SQLITE_GRAPHRAG_SKIP_EMBEDDING_ON_FAILURE`.
237    #[arg(
238        long,
239        global = true,
240        default_value_t = false,
241        value_parser = clap::builder::BoolishValueParser::new(),
242        env = "SQLITE_GRAPHRAG_SKIP_EMBEDDING_ON_FAILURE"
243    )]
244    pub skip_embedding_on_failure: bool,
245
246    /// v1.0.82 (GAP-004): limite host-wide de subprocessos LLM
247    /// simultâneos. Default derivado de `ncpus`. Honra env var
248    /// `SQLITE_GRAPHRAG_LLM_MAX_HOST_CONCURRENCY`.
249    #[arg(
250        long,
251        global = true,
252        value_name = "N",
253        env = "SQLITE_GRAPHRAG_LLM_MAX_HOST_CONCURRENCY"
254    )]
255    pub llm_max_host_concurrency: Option<u32>,
256
257    /// v1.0.82 (GAP-004): segundos para aguardar slot LLM livre
258    /// antes de falhar com exit 75. Default 30s. Honra env var
259    /// `SQLITE_GRAPHRAG_LLM_SLOT_WAIT_SECS`.
260    #[arg(
261        long,
262        global = true,
263        value_name = "SECONDS",
264        env = "SQLITE_GRAPHRAG_LLM_SLOT_WAIT_SECS"
265    )]
266    pub llm_slot_wait_secs: Option<u64>,
267
268    /// v1.0.82 (GAP-004): se setado, falha imediatamente (exit 75)
269    /// quando nenhum slot LLM está livre. Honra env var
270    /// `SQLITE_GRAPHRAG_LLM_SLOT_NO_WAIT`.
271    #[arg(
272        long,
273        global = true,
274        default_value_t = false,
275        value_parser = clap::builder::BoolishValueParser::new(),
276        env = "SQLITE_GRAPHRAG_LLM_SLOT_NO_WAIT"
277    )]
278    pub llm_slot_no_wait: bool,
279
280    #[command(subcommand)]
281    pub command: Option<Commands>,
282}
283
284#[cfg(test)]
285mod json_only_format_tests {
286    use super::Cli;
287    use clap::Parser;
288
289    #[test]
290    fn restore_accepts_only_format_json() {
291        assert!(Cli::try_parse_from([
292            "sqlite-graphrag",
293            "restore",
294            "--name",
295            "mem",
296            "--version",
297            "1",
298            "--format",
299            "json",
300        ])
301        .is_ok());
302
303        assert!(Cli::try_parse_from([
304            "sqlite-graphrag",
305            "restore",
306            "--name",
307            "mem",
308            "--version",
309            "1",
310            "--format",
311            "text",
312        ])
313        .is_err());
314    }
315
316    #[test]
317    fn hybrid_search_accepts_only_format_json() {
318        assert!(Cli::try_parse_from([
319            "sqlite-graphrag",
320            "hybrid-search",
321            "query",
322            "--format",
323            "json",
324        ])
325        .is_ok());
326
327        assert!(Cli::try_parse_from([
328            "sqlite-graphrag",
329            "hybrid-search",
330            "query",
331            "--format",
332            "markdown",
333        ])
334        .is_err());
335    }
336
337    #[test]
338    fn remember_recall_rename_vacuum_json_only() {
339        assert!(Cli::try_parse_from([
340            "sqlite-graphrag",
341            "remember",
342            "--name",
343            "mem",
344            "--type",
345            "project",
346            "--description",
347            "desc",
348            "--format",
349            "json",
350        ])
351        .is_ok());
352        assert!(Cli::try_parse_from([
353            "sqlite-graphrag",
354            "remember",
355            "--name",
356            "mem",
357            "--type",
358            "project",
359            "--description",
360            "desc",
361            "--format",
362            "text",
363        ])
364        .is_err());
365
366        assert!(
367            Cli::try_parse_from(["sqlite-graphrag", "recall", "query", "--format", "json",])
368                .is_ok()
369        );
370        assert!(
371            Cli::try_parse_from(["sqlite-graphrag", "recall", "query", "--format", "text",])
372                .is_err()
373        );
374
375        assert!(Cli::try_parse_from([
376            "sqlite-graphrag",
377            "rename",
378            "--name",
379            "old",
380            "--new-name",
381            "new",
382            "--format",
383            "json",
384        ])
385        .is_ok());
386        assert!(Cli::try_parse_from([
387            "sqlite-graphrag",
388            "rename",
389            "--name",
390            "old",
391            "--new-name",
392            "new",
393            "--format",
394            "markdown",
395        ])
396        .is_err());
397
398        assert!(Cli::try_parse_from(["sqlite-graphrag", "vacuum", "--format", "json",]).is_ok());
399        assert!(Cli::try_parse_from(["sqlite-graphrag", "vacuum", "--format", "text",]).is_err());
400    }
401}
402
403impl Cli {
404    /// Validates concurrency flags and returns a localised descriptive error if invalid.
405    ///
406    /// Requires that `crate::i18n::init()` has already been called (happens before this
407    /// function in the `main` flow). In English it emits EN messages; in Portuguese it emits PT.
408    pub fn validate_flags(&self) -> Result<(), String> {
409        if let Some(n) = self.max_concurrency {
410            if n == 0 {
411                return Err(match current() {
412                    Language::English => "--max-concurrency must be >= 1".to_string(),
413                    Language::Portuguese => "--max-concurrency deve ser >= 1".to_string(),
414                });
415            }
416            let teto = max_concurrency_ceiling();
417            if n > teto {
418                return Err(match current() {
419                    Language::English => format!(
420                        "--max-concurrency {n} exceeds the ceiling of {teto} (2×nCPUs) on this system"
421                    ),
422                    Language::Portuguese => format!(
423                        "--max-concurrency {n} excede o teto de {teto} (2×nCPUs) neste sistema"
424                    ),
425                });
426            }
427        }
428        Ok(())
429    }
430}
431
432impl Commands {
433    /// Returns true for subcommands that load the ONNX model locally.
434    pub fn is_embedding_heavy(&self) -> bool {
435        matches!(
436            self,
437            Self::Init(_)
438                | Self::Remember(_)
439                | Self::RememberBatch(_)
440                | Self::Recall(_)
441                | Self::HybridSearch(_)
442                | Self::DeepResearch(_)
443        )
444    }
445
446    pub fn uses_cli_slot(&self) -> bool {
447        true
448    }
449}
450
451/// GAP-E2E-010 (v1.0.89): `codex-models` accepts `--json` as a no-op so
452/// agents that append `--json` to every subcommand never see clap errors.
453/// The handler in `main.rs` always emits JSON on stdout; this flag is
454/// accepted and ignored for parity with the rest of the CLI surface.
455#[derive(Debug, clap::Args)]
456pub struct CodexModelsArgs {
457    /// No-op; JSON is always emitted on stdout by `codex-models`.
458    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
459    pub json: bool,
460}
461
462#[derive(Subcommand)]
463pub enum Commands {
464    /// Initialize database and download embedding model
465    #[command(after_long_help = "EXAMPLES:\n  \
466        # Initialize in current directory (default behavior)\n  \
467        sqlite-graphrag init\n\n  \
468        # Initialize at a specific path\n  \
469        sqlite-graphrag init --db /path/to/graphrag.sqlite\n\n  \
470        # Initialize using SQLITE_GRAPHRAG_HOME env var\n  \
471        SQLITE_GRAPHRAG_HOME=/data sqlite-graphrag init\n\n\
472        NOTES:\n  \
473        - `init` is OPTIONAL: any subsequent CRUD command auto-initializes graphrag.sqlite if missing.\n  \
474        - As a side effect, `init` warms a smoke-test embedding via the LLM-only one-shot pipeline.")]
475    Init(init::InitArgs),
476    /// Save a memory with optional entity graph
477    #[command(after_long_help = "EXAMPLES:\n  \
478        # Inline body\n  \
479        sqlite-graphrag remember --name onboarding --type user --description \"intro\" --body \"hello\"\n\n  \
480        # Body from file\n  \
481        sqlite-graphrag remember --name doc1 --type document --description \"...\" --body-file ./README.md\n\n  \
482        # Body from stdin (pipe)\n  \
483        cat README.md | sqlite-graphrag remember --name doc1 --type document --description \"...\" --body-stdin\n\n  \
484        # Enable automatic URL extraction (URL-regex only since v1.0.79; GLiNER removed)\n  \
485        sqlite-graphrag remember --name rich --type note --description \"...\" --body \"...\" --enable-ner")]
486    Remember(remember::RememberArgs),
487    /// Batch-create memories from NDJSON stdin (one invocation, one slot)
488    #[command(after_long_help = "EXAMPLES:\n  \
489        # Batch create from NDJSON\n  \
490        cat memories.ndjson | sqlite-graphrag remember-batch --force-merge --json\n\n  \
491        # Atomic batch\n  \
492        cat memories.ndjson | sqlite-graphrag remember-batch --transaction --json")]
493    RememberBatch(remember_batch::RememberBatchArgs),
494    /// Bulk-ingest every file under a directory as separate memories (NDJSON output)
495    Ingest(ingest::IngestArgs),
496    /// Search memories semantically
497    #[command(after_long_help = "EXAMPLES:\n  \
498        # Top 10 semantic matches (default)\n  \
499        sqlite-graphrag recall \"agent memory\"\n\n  \
500        # Top 3 only\n  \
501        sqlite-graphrag recall \"agent memory\" -k 3\n\n  \
502        # Search across all namespaces\n  \
503        sqlite-graphrag recall \"agent memory\" --all-namespaces\n\n  \
504        # Disable graph traversal (vector-only)\n  \
505        sqlite-graphrag recall \"agent memory\" --no-graph")]
506    Recall(recall::RecallArgs),
507    /// Read a memory by exact name
508    Read(read::ReadArgs),
509    /// List memories with filters
510    List(list::ListArgs),
511    /// Soft-delete a memory
512    Forget(forget::ForgetArgs),
513    /// Permanently delete soft-deleted memories
514    Purge(purge::PurgeArgs),
515    /// Rename a memory preserving history
516    Rename(rename::RenameArgs),
517    /// Edit a memory's body or description
518    Edit(edit::EditArgs),
519    /// List all versions of a memory
520    History(history::HistoryArgs),
521    /// Restore a memory to a previous version
522    Restore(restore::RestoreArgs),
523    /// Search using hybrid vector + full-text search
524    #[command(after_long_help = "EXAMPLES:\n  \
525        # Hybrid search combining KNN + FTS5 BM25 with RRF\n  \
526        sqlite-graphrag hybrid-search \"agent memory architecture\"\n\n  \
527        # Custom weights for vector vs full-text components\n  \
528        sqlite-graphrag hybrid-search \"agent\" --weight-vec 0.7 --weight-fts 0.3")]
529    HybridSearch(hybrid_search::HybridSearchArgs),
530    /// Show database health
531    Health(health::HealthArgs),
532    /// Apply pending schema migrations
533    Migrate(migrate::MigrateArgs),
534    /// Resolve namespace precedence for the current invocation
535    NamespaceDetect(namespace_detect::NamespaceDetectArgs),
536    /// Run PRAGMA optimize on the database
537    Optimize(optimize::OptimizeArgs),
538    /// Show database statistics
539    Stats(stats::StatsArgs),
540    /// Create a checkpointed copy safe for file sync
541    SyncSafeCopy(sync_safe_copy::SyncSafeCopyArgs),
542    /// Back up the database using the SQLite Online Backup API
543    Backup(backup::BackupArgs),
544    /// Run VACUUM after checkpointing the WAL
545    Vacuum(vacuum::VacuumArgs),
546    /// Create an explicit relationship between two entities
547    Link(link::LinkArgs),
548    /// Remove a specific relationship between two entities
549    Unlink(unlink::UnlinkArgs),
550    /// Deep parallel multi-hop GraphRAG research
551    #[command(name = "deep-research")]
552    DeepResearch(deep_research::DeepResearchArgs),
553    /// List memories connected via the entity graph
554    Related(related::RelatedArgs),
555    /// Export a graph snapshot in json, dot or mermaid
556    Graph(graph_export::GraphArgs),
557    /// Export memories as NDJSON (one JSON line per memory, plus a summary line)
558    Export(export::ExportArgs),
559    /// FTS5 full-text search index management (rebuild or check)
560    Fts(fts::FtsArgs),
561    /// Vector index maintenance (orphan detection, purge, stats) — G39
562    Vec(vec::VecArgs),
563    /// List codex OAuth models accepted by ChatGPT Pro (G33).
564    ///
565    /// GAP-E2E-010 (v1.0.89): accepts `--json` as a no-op (JSON is always
566    /// emitted on stdout) so the flag never breaks agent pipelines that
567    /// append `--json` to every invocation.
568    #[command(name = "codex-models")]
569    CodexModels(CodexModelsArgs),
570    /// Bulk-delete all relationships of a given type (e.g. mentions)
571    PruneRelations(prune_relations::PruneRelationsArgs),
572    /// Remove NER bindings (memory_entities rows) for an entity or all entities
573    #[command(name = "prune-ner")]
574    PruneNer(prune_ner::PruneNerArgs),
575    /// Inspect and manage cross-process LLM slot semaphore (GAP-004, v1.0.82)
576    Slots(slots::SlotsArgs),
577    /// Inspect and manage the `remember` checkpoint queue (GAP-001, v1.0.82)
578    Pending(pending::PendingArgs),
579    /// Health and per-entry inspection of the pending-embeddings queue (GAP-005, v1.0.82)
580    Embedding(embedding::EmbeddingArgs),
581    /// Batch operations over the pending-embeddings queue (GAP-005, v1.0.82)
582    #[command(name = "pending-embeddings")]
583    PendingEmbeddings(pending_embeddings::PendingEmbeddingsArgs),
584    /// Remove entities that have no memories and no relationships
585    CleanupOrphans(cleanup_orphans::CleanupOrphansArgs),
586    /// List entities linked to a specific memory
587    MemoryEntities(memory_entities::MemoryEntitiesArgs),
588    /// Manage cached resources (embedding models, etc.)
589    Cache(cache::CacheArgs),
590    /// Delete an entity and all its relationships from the graph
591    #[command(name = "delete-entity")]
592    DeleteEntity(delete_entity::DeleteEntityArgs),
593    /// Reclassify one entity or a batch of entities to a new type
594    Reclassify(reclassify::ReclassifyArgs),
595    /// Rename an entity preserving all relationships and memory bindings
596    #[command(name = "rename-entity")]
597    RenameEntity(rename_entity::RenameEntityArgs),
598    /// Merge multiple source entities into a single target entity
599    #[command(name = "merge-entities")]
600    MergeEntities(merge_entities::MergeEntitiesArgs),
601    /// Enrich graph memories and entities using an LLM provider
602    Enrich(enrich::EnrichArgs),
603    /// Reclassify relationship types across the graph using rules or LLM judgment
604    #[command(name = "reclassify-relation")]
605    ReclassifyRelation(reclassify_relation::ReclassifyRelationArgs),
606    /// Normalize entity names (deduplicate, kebab-case, merge near-duplicates)
607    #[command(name = "normalize-entities")]
608    NormalizeEntities(normalize_entities::NormalizeEntitiesArgs),
609    /// Generate shell completions for Bash, Zsh, Fish, PowerShell, or Elvish
610    Completions(completions::CompletionsArgs),
611    #[command(name = "debug-schema", hide = true)]
612    DebugSchema(debug_schema::DebugSchemaArgs),
613}
614// FIX-1 (v1.0.89): manual `Debug` impl so test panic messages that print
615// `{:?}` on a captured `Commands` variant compile without requiring every
616// contained subcommand arg struct to derive `Debug`. The Debug output is
617// only used in test assertions for diagnostic messages; we emit the variant
618// name only — arg payload is intentionally omitted.
619impl std::fmt::Debug for Commands {
620    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
621        let name = match self {
622            Self::Init(_) => "Init",
623            Self::Health(_) => "Health",
624            Self::Stats(_) => "Stats",
625            Self::List(_) => "List",
626            Self::Read(_) => "Read",
627            Self::Edit(_) => "Edit",
628            Self::Rename(_) => "Rename",
629            Self::Restore(_) => "Restore",
630            Self::History(_) => "History",
631            Self::Forget(_) => "Forget",
632            Self::Purge(_) => "Purge",
633            Self::Remember(_) => "Remember",
634            Self::RememberBatch(_) => "RememberBatch",
635            Self::Recall(_) => "Recall",
636            Self::HybridSearch(_) => "HybridSearch",
637            Self::Enrich(_) => "Enrich",
638            Self::Ingest(_) => "Ingest",
639            Self::Optimize(_) => "Optimize",
640            Self::Migrate(_) => "Migrate",
641            Self::SyncSafeCopy(_) => "SyncSafeCopy",
642            Self::Backup(_) => "Backup",
643            Self::Vacuum(_) => "Vacuum",
644            Self::Link(_) => "Link",
645            Self::Unlink(_) => "Unlink",
646            Self::DeepResearch(_) => "DeepResearch",
647            Self::Related(_) => "Related",
648            Self::Graph(_) => "Graph",
649            Self::Export(_) => "Export",
650            Self::Fts(_) => "Fts",
651            Self::Vec(_) => "Vec",
652            Self::CodexModels(_) => "CodexModels",
653            Self::PruneRelations(_) => "PruneRelations",
654            Self::PruneNer(_) => "PruneNer",
655            Self::Slots(_) => "Slots",
656            Self::Pending(_) => "Pending",
657            Self::Embedding(_) => "Embedding",
658            Self::PendingEmbeddings(_) => "PendingEmbeddings",
659            Self::CleanupOrphans(_) => "CleanupOrphans",
660            Self::MemoryEntities(_) => "MemoryEntities",
661            Self::Cache(_) => "Cache",
662            Self::DeleteEntity(_) => "DeleteEntity",
663            Self::Reclassify(_) => "Reclassify",
664            Self::RenameEntity(_) => "RenameEntity",
665            Self::ReclassifyRelation(_) => "ReclassifyRelation",
666            Self::NormalizeEntities(_) => "NormalizeEntities",
667            Self::MergeEntities(_) => "MergeEntities",
668            Self::NamespaceDetect(_) => "NamespaceDetect",
669            Self::Completions(_) => "Completions",
670            Self::DebugSchema(_) => "DebugSchema",
671        };
672        f.write_str(name)
673    }
674}
675
676#[derive(Copy, Clone, Debug, Default, clap::ValueEnum)]
677pub enum MemoryType {
678    User,
679    Feedback,
680    Project,
681    Reference,
682    Decision,
683    Incident,
684    Skill,
685    #[default]
686    Document,
687    Note,
688}
689
690#[cfg(test)]
691mod heavy_concurrency_tests {
692    use super::*;
693
694    #[test]
695    fn command_heavy_detects_init_and_embeddings() {
696        let init = Cli::try_parse_from(["sqlite-graphrag", "init"]).expect("parse init");
697        assert!(init
698            .command
699            .as_ref()
700            .is_some_and(|c| c.is_embedding_heavy()));
701
702        let remember = Cli::try_parse_from([
703            "sqlite-graphrag",
704            "remember",
705            "--name",
706            "test-memory",
707            "--type",
708            "project",
709            "--description",
710            "desc",
711        ])
712        .expect("parse remember");
713        assert!(remember
714            .command
715            .as_ref()
716            .is_some_and(|c| c.is_embedding_heavy()));
717
718        let recall =
719            Cli::try_parse_from(["sqlite-graphrag", "recall", "query"]).expect("parse recall");
720        assert!(recall
721            .command
722            .as_ref()
723            .is_some_and(|c| c.is_embedding_heavy()));
724
725        let hybrid = Cli::try_parse_from(["sqlite-graphrag", "hybrid-search", "query"])
726            .expect("parse hybrid");
727        assert!(hybrid
728            .command
729            .as_ref()
730            .is_some_and(|c| c.is_embedding_heavy()));
731    }
732
733    #[test]
734    fn command_light_does_not_mark_stats() {
735        let stats = Cli::try_parse_from(["sqlite-graphrag", "stats"]).expect("parse stats");
736        assert!(!stats
737            .command
738            .as_ref()
739            .is_some_and(|c| c.is_embedding_heavy()));
740    }
741}
742
743impl MemoryType {
744    pub fn as_str(&self) -> &'static str {
745        match self {
746            Self::User => "user",
747            Self::Feedback => "feedback",
748            Self::Project => "project",
749            Self::Reference => "reference",
750            Self::Decision => "decision",
751            Self::Incident => "incident",
752            Self::Skill => "skill",
753            Self::Document => "document",
754            Self::Note => "note",
755        }
756    }
757}