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