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