Skip to main content

sqlite_graphrag/
cli.rs

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