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): LLM backend for embedding. Accepts `auto` (default —
26/// detects `codex` or `claude` on the PATH), `codex` (forces codex exec), `claude`
27/// (forces claude -p), or `none` (skips embedding; useful for tests).
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): converts the CLI choice into an ordered chain
73    /// of backends that `embedder::embed_with_fallback` iterates. The first
74    /// element of the chain is the preferred backend; subsequent elements
75    /// are fallbacks used when the preferred one fails with `LlmBackendError`.
76    ///
77    /// `Auto` produces `[Codex, Claude, None]` — codex is the default since v1.0.76+,
78    /// claude is the fallback if codex fails (OAuth contention, quota), and
79    /// `None` lets `embed_with_fallback` return an empty vector when
80    /// `skip_on_failure` is active.
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)]
140#[command(after_help = "DATABASE PATH (GAP-SG-32):\n  \
141    `--db` is a PER-SUBCOMMAND flag, so it must come AFTER the subcommand:\n    \
142    sqlite-graphrag remember --db ./graphrag.sqlite --name mem --type note ...\n  \
143    Placing it before the subcommand (e.g. `sqlite-graphrag --db x.sqlite remember`) is rejected.\n  \
144    For a position-independent path, set the canonical env var instead:\n    \
145    SQLITE_GRAPHRAG_DB_PATH=./graphrag.sqlite sqlite-graphrag remember --name mem ...")]
146pub struct Cli {
147    /// Maximum number of simultaneous CLI invocations allowed (default: 4).
148    ///
149    /// Caps the counting semaphore used for CLI concurrency slots. The value must
150    /// stay within [1, 2×nCPUs]. Values above the ceiling are rejected with exit 2.
151    #[arg(long, global = true, value_name = "N")]
152    pub max_concurrency: Option<usize>,
153
154    /// Wait up to SECONDS for a free concurrency slot before giving up (exit 75).
155    ///
156    /// Useful in retrying agent pipelines: the process polls every 500 ms until a
157    /// slot opens or the timeout expires. Default: 300s (5 minutes).
158    #[arg(long, global = true, value_name = "SECONDS")]
159    pub wait_lock: Option<u64>,
160
161    /// Skip the available-memory check before loading the model.
162    ///
163    /// Exclusive use in automated tests where real allocation does not occur.
164    #[arg(long, global = true, hide = true, default_value_t = false)]
165    pub skip_memory_guard: bool,
166
167    /// v1.0.83 (ADR-0041): strict env-clear mode for compliance environments.
168    ///
169    /// When enabled, the LLM subprocess receives ONLY `PATH` — no
170    /// `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_BASE_URL`, `OPENAI_BASE_URL`
171    /// or other custom-provider credentials are forwarded. Defaults to
172    /// the standard v1.0.83 whitelist that preserves custom-provider
173    /// credentials (ADR-0041). Honors env var
174    /// `SQLITE_GRAPHRAG_STRICT_ENV_CLEAR=1` when set.
175    #[arg(
176        long,
177        global = true,
178        hide = true,
179        default_value_t = false,
180        value_parser = clap::builder::BoolishValueParser::new(),
181        env = "SQLITE_GRAPHRAG_STRICT_ENV_CLEAR"
182    )]
183    pub strict_env_clear: bool,
184
185    /// v1.0.84 (ADR-0042 / GAP-002): resolve and print the LLM backend that
186    /// WOULD be invoked for embedding (binary path + model + flavour),
187    /// then exit 0 without executing the subprocess. Useful for CI
188    /// audit and sanity-check of `--llm-backend` before long sessions.
189    ///
190    /// Honors env var `SQLITE_GRAPHRAG_DRY_RUN_BACKEND=1` when set.
191    #[arg(
192        long,
193        global = true,
194        hide = true,
195        default_value_t = false,
196        value_parser = clap::builder::BoolishValueParser::new(),
197        env = "SQLITE_GRAPHRAG_DRY_RUN_BACKEND"
198    )]
199    pub dry_run_backend: bool,
200
201    /// Language for human-facing stderr messages. Accepts `en` or `pt`.
202    ///
203    /// Without the flag, detection falls back to `SQLITE_GRAPHRAG_LANG` and then
204    /// `LC_ALL`/`LANG`. JSON stdout stays deterministic and identical across
205    /// languages; only human-facing strings are affected.
206    #[arg(long, global = true, value_enum, value_name = "LANG")]
207    pub lang: Option<crate::i18n::Language>,
208
209    /// Time zone for `*_iso` fields in JSON output (for example `America/Sao_Paulo`).
210    ///
211    /// Accepts any IANA time zone name. Without the flag, it falls back to
212    /// `SQLITE_GRAPHRAG_DISPLAY_TZ`; if unset, UTC is used. Integer epoch fields
213    /// are not affected.
214    #[arg(long, global = true, value_name = "IANA")]
215    pub tz: Option<chrono_tz::Tz>,
216
217    /// Increase logging verbosity (-v=info, -vv=debug, -vvv=trace).
218    ///
219    /// Overrides `SQLITE_GRAPHRAG_LOG_LEVEL` env var when present. Logs are emitted
220    /// to stderr; JSON stdout is unaffected.
221    #[arg(short = 'v', long, global = true, action = clap::ArgAction::Count)]
222    pub verbose: u8,
223
224    /// Suppress non-error tracing on stderr (sets log level to `error`).
225    ///
226    /// Prefer this in pipelines that capture stdout JSON (`> out.json`).
227    /// Never combine stdout and stderr into the same file (`&>` / `2>&1`) —
228    /// that contaminates the JSON envelope (v1.1.05 Bug 2). Conflicts with
229    /// `-v` / `--verbose` only in spirit: quiet wins when both are present.
230    #[arg(short = 'q', long, global = true, default_value_t = false)]
231    pub quiet: bool,
232
233    /// v1.0.75 (G21 solution): extraction backend selector. Accepts
234    /// `llm` (default), `embedding` (legacy), `none`, or `both` (composite).
235    /// The `llm` backend invokes claude code / codex CLI headless to extract
236    /// entities and relationships; `embedding` is a permanent stub since
237    /// v1.0.79 (legacy fastembed pipeline removed) that returns a clear
238    /// migration error.
239    #[arg(long, global = true, value_name = "KIND", default_value = "llm")]
240    pub extraction_backend: Option<String>,
241
242    /// v1.0.79 (G42/S1): embedding dimensionality override (default 64).
243    ///
244    /// Precedence: this flag > `SQLITE_GRAPHRAG_EMBEDDING_DIM` env var >
245    /// the `dim` recorded in the database `schema_meta` > 64. Existing
246    /// databases keep their recorded dimensionality automatically; use
247    /// this flag only to migrate a corpus to a new dimensionality
248    /// (followed by `enrich --operation re-embed`). Range: [8, 4096].
249    #[arg(long, global = true, value_name = "N", value_parser = clap::value_parser!(u64).range(8..=4096))]
250    pub embedding_dim: Option<u64>,
251
252    /// v1.0.82 (GAP-003) / v1.0.84 (ADR-0042): LLM backend for embedding.
253    /// Accepts `auto` (detects via PATH, codex-first), `codex` (forces
254    /// `codex exec`), `claude` (forces `claude -p`; since v1.0.84 does NOT fall back to
255    /// codex — emits `AppError::Validation` if `claude` is absent),
256    /// `opencode` (forces `opencode run`), or `none`
257    /// (skips embedding; useful for tests). Honors the env var
258    /// `SQLITE_GRAPHRAG_LLM_BACKEND`.
259    #[arg(long, global = true, value_enum, default_value_t = LlmBackendChoice::Auto, env = "SQLITE_GRAPHRAG_LLM_BACKEND")]
260    pub llm_backend: LlmBackendChoice,
261
262    /// v1.0.82 (GAP-003): model to invoke on the chosen backend.
263    /// Honors the env var `SQLITE_GRAPHRAG_LLM_MODEL`. The default depends
264    /// on the backend (codex: `gpt-5.5`; claude: `claude-sonnet-4-6`).
265    #[arg(
266        long,
267        global = true,
268        value_name = "MODEL",
269        env = "SQLITE_GRAPHRAG_LLM_MODEL"
270    )]
271    pub llm_model: Option<String>,
272
273    /// v1.0.82 (GAP-003): path to the `claude` binary (overrides
274    /// PATH detection). Honors the env var `SQLITE_GRAPHRAG_CLAUDE_BINARY`.
275    #[arg(
276        long,
277        global = true,
278        value_name = "PATH",
279        env = "SQLITE_GRAPHRAG_CLAUDE_BINARY"
280    )]
281    pub claude_binary: Option<std::path::PathBuf>,
282
283    /// v1.0.89 (GAP-1): path to the `codex` binary (overrides
284    /// PATH detection). Honors the env var `SQLITE_GRAPHRAG_CODEX_BINARY`.
285    #[arg(
286        long,
287        global = true,
288        value_name = "PATH",
289        env = "SQLITE_GRAPHRAG_CODEX_BINARY"
290    )]
291    pub codex_binary: Option<std::path::PathBuf>,
292
293    /// v1.0.90 (GAP-OPENCODE-001): path to the `opencode` binary (overrides
294    /// PATH detection). Honors the env var `SQLITE_GRAPHRAG_OPENCODE_BINARY`.
295    #[arg(
296        long,
297        global = true,
298        value_name = "PATH",
299        env = "SQLITE_GRAPHRAG_OPENCODE_BINARY"
300    )]
301    pub opencode_binary: Option<std::path::PathBuf>,
302
303    /// v1.0.82 (GAP-005): chain of LLM backends tried in order
304    /// when the primary fails. Default `codex,claude,none`. Honors
305    /// the env var `SQLITE_GRAPHRAG_LLM_FALLBACK`.
306    #[arg(
307        long,
308        global = true,
309        default_value = "codex,claude,none",
310        env = "SQLITE_GRAPHRAG_LLM_FALLBACK"
311    )]
312    pub llm_fallback: String,
313
314    /// v1.0.82 (GAP-005): persists with a NULL embedding when all
315    /// backends in the chain fail. The memory stays in `pending_embeddings`
316    /// for reprocessing via `embedding retry`. Honors the env var
317    /// `SQLITE_GRAPHRAG_SKIP_EMBEDDING_ON_FAILURE`.
318    #[arg(
319        long,
320        global = true,
321        default_value_t = false,
322        value_parser = clap::builder::BoolishValueParser::new(),
323        env = "SQLITE_GRAPHRAG_SKIP_EMBEDDING_ON_FAILURE"
324    )]
325    pub skip_embedding_on_failure: bool,
326
327    /// v1.0.82 (GAP-004): host-wide limit of concurrent LLM
328    /// subprocesses. Default derived from `ncpus`. Honors the env var
329    /// `SQLITE_GRAPHRAG_LLM_MAX_HOST_CONCURRENCY`.
330    #[arg(
331        long,
332        global = true,
333        value_name = "N",
334        env = "SQLITE_GRAPHRAG_LLM_MAX_HOST_CONCURRENCY"
335    )]
336    pub llm_max_host_concurrency: Option<u32>,
337
338    /// v1.0.82 (GAP-004): seconds to wait for a free LLM slot
339    /// before failing with exit 75. Default 30s. Honors the env var
340    /// `SQLITE_GRAPHRAG_LLM_SLOT_WAIT_SECS`.
341    #[arg(
342        long,
343        global = true,
344        value_name = "SECONDS",
345        env = "SQLITE_GRAPHRAG_LLM_SLOT_WAIT_SECS"
346    )]
347    pub llm_slot_wait_secs: Option<u64>,
348
349    /// v1.0.82 (GAP-004): if set, fails immediately (exit 75)
350    /// when no LLM slot is free. Honors the env var
351    /// `SQLITE_GRAPHRAG_LLM_SLOT_NO_WAIT`.
352    #[arg(
353        long,
354        global = true,
355        default_value_t = false,
356        value_parser = clap::builder::BoolishValueParser::new(),
357        env = "SQLITE_GRAPHRAG_LLM_SLOT_NO_WAIT"
358    )]
359    pub llm_slot_no_wait: bool,
360
361    /// v1.0.93: embedding backend selector. `auto` tries OpenRouter API if key
362    /// available, falls back to LLM subprocess. `openrouter` requires API key.
363    /// `llm` forces subprocess. Honra env var `SQLITE_GRAPHRAG_EMBEDDING_BACKEND`.
364    #[arg(long, global = true, value_enum, default_value_t = EmbeddingBackendChoice::Auto, env = "SQLITE_GRAPHRAG_EMBEDDING_BACKEND")]
365    pub embedding_backend: EmbeddingBackendChoice,
366
367    /// v1.0.93: embedding model for the OpenRouter API. Required when
368    /// `--embedding-backend openrouter`. Honors env var `SQLITE_GRAPHRAG_EMBEDDING_MODEL`.
369    #[arg(
370        long,
371        global = true,
372        value_name = "MODEL",
373        env = "SQLITE_GRAPHRAG_EMBEDDING_MODEL"
374    )]
375    pub embedding_model: Option<String>,
376
377    /// v1.0.93: OpenRouter API key (prefer env var or config.toml over CLI flag
378    /// to avoid shell history exposure). Honra env var `OPENROUTER_API_KEY`.
379    #[arg(
380        long,
381        global = true,
382        value_name = "KEY",
383        hide = true,
384        env = "OPENROUTER_API_KEY",
385        hide_env_values = true
386    )]
387    pub openrouter_api_key: Option<String>,
388
389    #[command(subcommand)]
390    pub command: Option<Commands>,
391}
392
393#[cfg(test)]
394mod json_only_format_tests {
395    use super::Cli;
396    use clap::Parser;
397
398    #[test]
399    fn restore_accepts_only_format_json() {
400        assert!(Cli::try_parse_from([
401            "sqlite-graphrag",
402            "restore",
403            "--name",
404            "mem",
405            "--version",
406            "1",
407            "--format",
408            "json",
409        ])
410        .is_ok());
411
412        assert!(Cli::try_parse_from([
413            "sqlite-graphrag",
414            "restore",
415            "--name",
416            "mem",
417            "--version",
418            "1",
419            "--format",
420            "text",
421        ])
422        .is_err());
423    }
424
425    #[test]
426    fn hybrid_search_accepts_only_format_json() {
427        assert!(Cli::try_parse_from([
428            "sqlite-graphrag",
429            "hybrid-search",
430            "query",
431            "--format",
432            "json",
433        ])
434        .is_ok());
435
436        assert!(Cli::try_parse_from([
437            "sqlite-graphrag",
438            "hybrid-search",
439            "query",
440            "--format",
441            "markdown",
442        ])
443        .is_err());
444    }
445
446    #[test]
447    fn remember_recall_rename_vacuum_json_only() {
448        assert!(Cli::try_parse_from([
449            "sqlite-graphrag",
450            "remember",
451            "--name",
452            "mem",
453            "--type",
454            "project",
455            "--description",
456            "desc",
457            "--format",
458            "json",
459        ])
460        .is_ok());
461        assert!(Cli::try_parse_from([
462            "sqlite-graphrag",
463            "remember",
464            "--name",
465            "mem",
466            "--type",
467            "project",
468            "--description",
469            "desc",
470            "--format",
471            "text",
472        ])
473        .is_err());
474
475        assert!(
476            Cli::try_parse_from(["sqlite-graphrag", "recall", "query", "--format", "json",])
477                .is_ok()
478        );
479        assert!(
480            Cli::try_parse_from(["sqlite-graphrag", "recall", "query", "--format", "text",])
481                .is_err()
482        );
483
484        assert!(Cli::try_parse_from([
485            "sqlite-graphrag",
486            "rename",
487            "--name",
488            "old",
489            "--new-name",
490            "new",
491            "--format",
492            "json",
493        ])
494        .is_ok());
495        assert!(Cli::try_parse_from([
496            "sqlite-graphrag",
497            "rename",
498            "--name",
499            "old",
500            "--new-name",
501            "new",
502            "--format",
503            "markdown",
504        ])
505        .is_err());
506
507        assert!(Cli::try_parse_from(["sqlite-graphrag", "vacuum", "--format", "json",]).is_ok());
508        assert!(Cli::try_parse_from(["sqlite-graphrag", "vacuum", "--format", "text",]).is_err());
509    }
510}
511
512impl Cli {
513    /// Validates concurrency flags and returns a localised descriptive error if invalid.
514    ///
515    /// Requires that `crate::i18n::init()` has already been called (happens before this
516    /// function in the `main` flow). In English it emits EN messages; in Portuguese it emits PT.
517    pub fn validate_flags(&self) -> Result<(), String> {
518        if let Some(n) = self.max_concurrency {
519            if n == 0 {
520                return Err(match current() {
521                    Language::English => "--max-concurrency must be >= 1".to_string(),
522                    Language::Portuguese => "--max-concurrency deve ser >= 1".to_string(),
523                });
524            }
525            let teto = max_concurrency_ceiling();
526            if n > teto {
527                return Err(match current() {
528                    Language::English => format!(
529                        "--max-concurrency {n} exceeds the ceiling of {teto} (2×nCPUs) on this system"
530                    ),
531                    Language::Portuguese => format!(
532                        "--max-concurrency {n} excede o teto de {teto} (2×nCPUs) neste sistema"
533                    ),
534                });
535            }
536        }
537        Ok(())
538    }
539}
540
541impl Commands {
542    /// Returns true for subcommands that load the ONNX model locally.
543    pub fn is_embedding_heavy(&self) -> bool {
544        matches!(
545            self,
546            Self::Init(_)
547                | Self::Remember(_)
548                | Self::RememberBatch(_)
549                | Self::Recall(_)
550                | Self::HybridSearch(_)
551                | Self::DeepResearch(_)
552        )
553    }
554
555    pub fn uses_cli_slot(&self) -> bool {
556        true
557    }
558
559    /// Read-only / no-embedding subcommands that MUST run without an embedding
560    /// API key. `init` warms a best-effort smoke test internally and degrades to
561    /// `ok_no_embedding` when the backend is unreachable; the `enrich` queue
562    /// inspectors (`--status` / `--list-dead` / `--requeue-dead` /
563    /// `--prune-dead-orphans`) never embed and never call the LLM. The eager
564    /// OpenRouter key preflight in `main` must skip its hard-fail for these.
565    pub fn tolerates_missing_embedding_key(&self) -> bool {
566        match self {
567            Self::Init(_) => true,
568            Self::Enrich(args) => {
569                args.status
570                    || args.list_dead
571                    || args.requeue_dead
572                    || args.prune_dead_orphans
573                    || args.prune_dead_entity_orphans
574            }
575            _ => false,
576        }
577    }
578}
579
580/// GAP-E2E-010 (v1.0.89): `codex-models` accepts `--json` as a no-op so
581/// agents that append `--json` to every subcommand never see clap errors.
582/// The handler in `main.rs` always emits JSON on stdout; this flag is
583/// accepted and ignored for parity with the rest of the CLI surface.
584#[derive(Debug, clap::Args)]
585pub struct CodexModelsArgs {
586    /// No-op; JSON is always emitted on stdout by `codex-models`.
587    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
588    pub json: bool,
589}
590
591#[derive(Subcommand)]
592pub enum Commands {
593    /// Initialize database and download embedding model
594    #[command(after_long_help = "EXAMPLES:\n  \
595        # Initialize in current directory (default behavior)\n  \
596        sqlite-graphrag init\n\n  \
597        # Initialize at a specific path\n  \
598        sqlite-graphrag init --db /path/to/graphrag.sqlite\n\n  \
599        # Initialize using SQLITE_GRAPHRAG_HOME env var\n  \
600        SQLITE_GRAPHRAG_HOME=/data sqlite-graphrag init\n\n\
601        NOTES:\n  \
602        - `init` is OPTIONAL: any subsequent CRUD command auto-initializes graphrag.sqlite if missing.\n  \
603        - As a side effect, `init` warms a smoke-test embedding via the LLM-only one-shot pipeline.")]
604    Init(init::InitArgs),
605    /// Save a memory with optional entity graph
606    #[command(after_long_help = "EXAMPLES:\n  \
607        # Inline body\n  \
608        sqlite-graphrag remember --name onboarding --type user --description \"intro\" --body \"hello\"\n\n  \
609        # Body from file\n  \
610        sqlite-graphrag remember --name doc1 --type document --description \"...\" --body-file ./README.md\n\n  \
611        # Body from stdin (pipe)\n  \
612        cat README.md | sqlite-graphrag remember --name doc1 --type document --description \"...\" --body-stdin\n\n  \
613        # Enable automatic URL extraction (URL-regex only since v1.0.79)\n  \
614        sqlite-graphrag remember --name rich --type note --description \"...\" --body \"...\" --enable-ner")]
615    Remember(remember::RememberArgs),
616    /// Batch-create memories from NDJSON stdin (one invocation, one slot)
617    #[command(after_long_help = "EXAMPLES:\n  \
618        # Batch create from NDJSON\n  \
619        cat memories.ndjson | sqlite-graphrag remember-batch --force-merge --json\n\n  \
620        # Atomic batch\n  \
621        cat memories.ndjson | sqlite-graphrag remember-batch --transaction --json")]
622    RememberBatch(remember_batch::RememberBatchArgs),
623    /// Bulk-ingest every file under a directory as separate memories (NDJSON output)
624    Ingest(ingest::IngestArgs),
625    /// Search memories semantically
626    #[command(after_long_help = "EXAMPLES:\n  \
627        # Top 10 semantic matches (default)\n  \
628        sqlite-graphrag recall \"agent memory\"\n\n  \
629        # Top 3 only\n  \
630        sqlite-graphrag recall \"agent memory\" -k 3\n\n  \
631        # Search across all namespaces\n  \
632        sqlite-graphrag recall \"agent memory\" --all-namespaces\n\n  \
633        # Disable graph traversal (vector-only)\n  \
634        sqlite-graphrag recall \"agent memory\" --no-graph")]
635    Recall(recall::RecallArgs),
636    /// Read a memory by exact name
637    Read(read::ReadArgs),
638    /// List memories with filters
639    List(list::ListArgs),
640    /// Soft-delete a memory
641    Forget(forget::ForgetArgs),
642    /// Permanently delete soft-deleted memories
643    Purge(purge::PurgeArgs),
644    /// Rename a memory preserving history
645    Rename(rename::RenameArgs),
646    /// Split an oversized memory body into N child memories (v1.1.03, GAP-V8)
647    SplitBody(split_body::SplitBodyArgs),
648    /// Edit a memory's body or description
649    Edit(edit::EditArgs),
650    /// List all versions of a memory
651    History(history::HistoryArgs),
652    /// Restore a memory to a previous version
653    Restore(restore::RestoreArgs),
654    /// Search using hybrid vector + full-text search
655    #[command(after_long_help = "EXAMPLES:\n  \
656        # Hybrid search combining KNN + FTS5 BM25 with RRF\n  \
657        sqlite-graphrag hybrid-search \"agent memory architecture\"\n\n  \
658        # Custom weights for vector vs full-text components\n  \
659        sqlite-graphrag hybrid-search \"agent\" --weight-vec 0.7 --weight-fts 0.3")]
660    HybridSearch(hybrid_search::HybridSearchArgs),
661    /// Show database health
662    Health(health::HealthArgs),
663    /// Apply pending schema migrations
664    Migrate(migrate::MigrateArgs),
665    /// Resolve namespace precedence for the current invocation
666    NamespaceDetect(namespace_detect::NamespaceDetectArgs),
667    /// Run PRAGMA optimize on the database
668    Optimize(optimize::OptimizeArgs),
669    /// Show database statistics
670    Stats(stats::StatsArgs),
671    /// Create a checkpointed copy safe for file sync
672    SyncSafeCopy(sync_safe_copy::SyncSafeCopyArgs),
673    /// Back up the database using the SQLite Online Backup API
674    Backup(backup::BackupArgs),
675    /// Run VACUUM after checkpointing the WAL
676    Vacuum(vacuum::VacuumArgs),
677    /// Create an explicit relationship between two entities
678    Link(link::LinkArgs),
679    /// Remove a specific relationship between two entities
680    Unlink(unlink::UnlinkArgs),
681    /// Deep parallel multi-hop GraphRAG research
682    #[command(name = "deep-research")]
683    DeepResearch(deep_research::DeepResearchArgs),
684    /// List memories connected via the entity graph
685    Related(related::RelatedArgs),
686    /// Export a graph snapshot in json, dot or mermaid
687    Graph(graph_export::GraphArgs),
688    /// Export memories as NDJSON (one JSON line per memory, plus a summary line)
689    Export(export::ExportArgs),
690    /// FTS5 full-text search index management (rebuild or check)
691    Fts(fts::FtsArgs),
692    /// Vector index maintenance (orphan detection, purge, stats) — G39
693    Vec(vec::VecArgs),
694    /// List codex OAuth models accepted by ChatGPT Pro (G33).
695    ///
696    /// GAP-E2E-010 (v1.0.89): accepts `--json` as a no-op (JSON is always
697    /// emitted on stdout) so the flag never breaks agent pipelines that
698    /// append `--json` to every invocation.
699    #[command(name = "codex-models")]
700    CodexModels(CodexModelsArgs),
701    /// Bulk-delete all relationships of a given type (e.g. mentions)
702    PruneRelations(prune_relations::PruneRelationsArgs),
703    /// Remove NER bindings (memory_entities rows) for an entity or all entities
704    #[command(name = "prune-ner")]
705    PruneNer(prune_ner::PruneNerArgs),
706    /// Inspect and manage cross-process LLM slot semaphore (GAP-004, v1.0.82)
707    Slots(slots::SlotsArgs),
708    /// Inspect and manage the `remember` checkpoint queue (GAP-001, v1.0.82)
709    Pending(pending::PendingArgs),
710    /// Health and per-entry inspection of the pending-embeddings queue (GAP-005, v1.0.82)
711    Embedding(embedding::EmbeddingArgs),
712    /// Batch operations over the pending-embeddings queue (GAP-005, v1.0.82)
713    #[command(name = "pending-embeddings")]
714    PendingEmbeddings(pending_embeddings::PendingEmbeddingsArgs),
715    /// Remove entities that have no memories and no relationships
716    CleanupOrphans(cleanup_orphans::CleanupOrphansArgs),
717    /// List entities linked to a specific memory
718    MemoryEntities(memory_entities::MemoryEntitiesArgs),
719    /// Manage cached resources (embedding models, etc.)
720    Cache(cache::CacheArgs),
721    /// Delete an entity and all its relationships from the graph
722    #[command(name = "delete-entity")]
723    DeleteEntity(delete_entity::DeleteEntityArgs),
724    /// Reclassify one entity or a batch of entities to a new type
725    Reclassify(reclassify::ReclassifyArgs),
726    /// Rename an entity preserving all relationships and memory bindings
727    #[command(name = "rename-entity")]
728    RenameEntity(rename_entity::RenameEntityArgs),
729    /// Merge multiple source entities into a single target entity
730    #[command(name = "merge-entities")]
731    MergeEntities(merge_entities::MergeEntitiesArgs),
732    /// Enrich graph memories and entities using an LLM provider
733    Enrich(enrich::EnrichArgs),
734    /// Reclassify relationship types across the graph using rules or LLM judgment
735    #[command(name = "reclassify-relation")]
736    ReclassifyRelation(reclassify_relation::ReclassifyRelationArgs),
737    /// Normalize entity names (deduplicate, kebab-case, merge near-duplicates)
738    #[command(name = "normalize-entities")]
739    NormalizeEntities(normalize_entities::NormalizeEntitiesArgs),
740    /// Generate shell completions for Bash, Zsh, Fish, PowerShell, or Elvish
741    Completions(completions::CompletionsArgs),
742    #[command(name = "debug-schema", hide = true)]
743    DebugSchema(debug_schema::DebugSchemaArgs),
744    /// Manage API keys and diagnose provider configuration (v1.0.93)
745    Config(config_cmd::ConfigArgs),
746}
747// FIX-1 (v1.0.89): manual `Debug` impl so test panic messages that print
748// `{:?}` on a captured `Commands` variant compile without requiring every
749// contained subcommand arg struct to derive `Debug`. The Debug output is
750// only used in test assertions for diagnostic messages; we emit the variant
751// name only — arg payload is intentionally omitted.
752impl std::fmt::Debug for Commands {
753    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
754        let name = match self {
755            Self::Init(_) => "Init",
756            Self::Health(_) => "Health",
757            Self::Stats(_) => "Stats",
758            Self::List(_) => "List",
759            Self::Read(_) => "Read",
760            Self::Edit(_) => "Edit",
761            Self::Rename(_) => "Rename",
762            Self::SplitBody(_) => "SplitBody",
763            Self::Restore(_) => "Restore",
764            Self::History(_) => "History",
765            Self::Forget(_) => "Forget",
766            Self::Purge(_) => "Purge",
767            Self::Remember(_) => "Remember",
768            Self::RememberBatch(_) => "RememberBatch",
769            Self::Recall(_) => "Recall",
770            Self::HybridSearch(_) => "HybridSearch",
771            Self::Enrich(_) => "Enrich",
772            Self::Ingest(_) => "Ingest",
773            Self::Optimize(_) => "Optimize",
774            Self::Migrate(_) => "Migrate",
775            Self::SyncSafeCopy(_) => "SyncSafeCopy",
776            Self::Backup(_) => "Backup",
777            Self::Vacuum(_) => "Vacuum",
778            Self::Link(_) => "Link",
779            Self::Unlink(_) => "Unlink",
780            Self::DeepResearch(_) => "DeepResearch",
781            Self::Related(_) => "Related",
782            Self::Graph(_) => "Graph",
783            Self::Export(_) => "Export",
784            Self::Fts(_) => "Fts",
785            Self::Vec(_) => "Vec",
786            Self::CodexModels(_) => "CodexModels",
787            Self::PruneRelations(_) => "PruneRelations",
788            Self::PruneNer(_) => "PruneNer",
789            Self::Slots(_) => "Slots",
790            Self::Pending(_) => "Pending",
791            Self::Embedding(_) => "Embedding",
792            Self::PendingEmbeddings(_) => "PendingEmbeddings",
793            Self::CleanupOrphans(_) => "CleanupOrphans",
794            Self::MemoryEntities(_) => "MemoryEntities",
795            Self::Cache(_) => "Cache",
796            Self::DeleteEntity(_) => "DeleteEntity",
797            Self::Reclassify(_) => "Reclassify",
798            Self::RenameEntity(_) => "RenameEntity",
799            Self::ReclassifyRelation(_) => "ReclassifyRelation",
800            Self::NormalizeEntities(_) => "NormalizeEntities",
801            Self::MergeEntities(_) => "MergeEntities",
802            Self::NamespaceDetect(_) => "NamespaceDetect",
803            Self::Completions(_) => "Completions",
804            Self::DebugSchema(_) => "DebugSchema",
805            Self::Config(_) => "Config",
806        };
807        f.write_str(name)
808    }
809}
810
811#[derive(Copy, Clone, Debug, Default, clap::ValueEnum)]
812pub enum MemoryType {
813    User,
814    Feedback,
815    Project,
816    Reference,
817    Decision,
818    Incident,
819    Skill,
820    #[default]
821    Document,
822    Note,
823}
824
825#[cfg(test)]
826mod heavy_concurrency_tests {
827    use super::*;
828
829    #[test]
830    fn command_heavy_detects_init_and_embeddings() {
831        let init = Cli::try_parse_from(["sqlite-graphrag", "init"]).expect("parse init");
832        assert!(init
833            .command
834            .as_ref()
835            .is_some_and(|c| c.is_embedding_heavy()));
836
837        let remember = Cli::try_parse_from([
838            "sqlite-graphrag",
839            "remember",
840            "--name",
841            "test-memory",
842            "--type",
843            "project",
844            "--description",
845            "desc",
846        ])
847        .expect("parse remember");
848        assert!(remember
849            .command
850            .as_ref()
851            .is_some_and(|c| c.is_embedding_heavy()));
852
853        let recall =
854            Cli::try_parse_from(["sqlite-graphrag", "recall", "query"]).expect("parse recall");
855        assert!(recall
856            .command
857            .as_ref()
858            .is_some_and(|c| c.is_embedding_heavy()));
859
860        let hybrid = Cli::try_parse_from(["sqlite-graphrag", "hybrid-search", "query"])
861            .expect("parse hybrid");
862        assert!(hybrid
863            .command
864            .as_ref()
865            .is_some_and(|c| c.is_embedding_heavy()));
866    }
867
868    #[test]
869    fn command_light_does_not_mark_stats() {
870        let stats = Cli::try_parse_from(["sqlite-graphrag", "stats"]).expect("parse stats");
871        assert!(!stats
872            .command
873            .as_ref()
874            .is_some_and(|c| c.is_embedding_heavy()));
875    }
876}
877
878impl MemoryType {
879    pub fn as_str(&self) -> &'static str {
880        match self {
881            Self::User => "user",
882            Self::Feedback => "feedback",
883            Self::Project => "project",
884            Self::Reference => "reference",
885            Self::Decision => "decision",
886            Self::Incident => "incident",
887            Self::Skill => "skill",
888            Self::Document => "document",
889            Self::Note => "note",
890        }
891    }
892}
893
894/// GAP-SG-31/33/34/35/30: parse-time contracts for the Fase G clap fixes.
895#[cfg(test)]
896mod fase_g_parsing_tests {
897    use super::Cli;
898    use clap::Parser;
899
900    /// GAP-SG-31(b): `enrich --status` parses without --operation/--mode.
901    #[test]
902    fn enrich_status_optional_operation_and_mode() {
903        assert!(
904            Cli::try_parse_from(["sqlite-graphrag", "enrich", "--status"]).is_ok(),
905            "--status alone must not require --operation/--mode"
906        );
907        assert!(
908            Cli::try_parse_from(["sqlite-graphrag", "enrich", "--list-dead"]).is_ok(),
909            "--list-dead is read-only and must not require --operation/--mode"
910        );
911        // Write path still requires both: bare `enrich` is rejected.
912        assert!(
913            Cli::try_parse_from(["sqlite-graphrag", "enrich"]).is_err(),
914            "bare enrich (no status/list-dead/requeue-dead) must require --operation/--mode"
915        );
916        // Full write invocation still parses.
917        assert!(Cli::try_parse_from([
918            "sqlite-graphrag",
919            "enrich",
920            "--operation",
921            "memory-bindings",
922            "--mode",
923            "openrouter",
924        ])
925        .is_ok());
926    }
927
928    /// GAP-SG-34(c): `config doctor --json` parses (no-op flag accepted).
929    #[test]
930    fn config_doctor_accepts_json() {
931        assert!(Cli::try_parse_from(["sqlite-graphrag", "config", "doctor", "--json"]).is_ok());
932        assert!(Cli::try_parse_from(["sqlite-graphrag", "config", "list-keys", "--json"]).is_ok());
933    }
934
935    /// GAP-SG-33(d): a hyphen-led --description value is accepted, not parsed
936    /// as a flag.
937    #[test]
938    fn remember_description_allows_leading_hyphen() {
939        assert!(Cli::try_parse_from([
940            "sqlite-graphrag",
941            "remember",
942            "--name",
943            "mem",
944            "--type",
945            "note",
946            "--description",
947            "- bullet description",
948        ])
949        .is_ok());
950    }
951
952    /// GAP-SG-35(e): `remember-batch --llm-parallelism N` parses.
953    #[test]
954    fn remember_batch_accepts_llm_parallelism() {
955        assert!(Cli::try_parse_from([
956            "sqlite-graphrag",
957            "remember-batch",
958            "--llm-parallelism",
959            "4"
960        ])
961        .is_ok());
962    }
963
964    /// GAP-SG-30: --graph-file combines with a body source but conflicts with
965    /// the other graph-input flags.
966    #[test]
967    fn remember_graph_file_combines_with_body_but_conflicts_with_graph_stdin() {
968        assert!(
969            Cli::try_parse_from([
970                "sqlite-graphrag",
971                "remember",
972                "--name",
973                "mem",
974                "--type",
975                "note",
976                "--body",
977                "inline body",
978                "--graph-file",
979                "/tmp/graph.json",
980            ])
981            .is_ok(),
982            "--body + --graph-file must coexist"
983        );
984        assert!(
985            Cli::try_parse_from([
986                "sqlite-graphrag",
987                "remember",
988                "--name",
989                "mem",
990                "--type",
991                "note",
992                "--graph-file",
993                "/tmp/graph.json",
994                "--graph-stdin",
995            ])
996            .is_err(),
997            "--graph-file conflicts with --graph-stdin"
998        );
999    }
1000}