1use crate::commands::*;
6use crate::i18n::{current, Language};
7use clap::{Parser, Subcommand};
8
9fn 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 Ndjson,
23}
24
25#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
29pub enum LlmBackendChoice {
30 Auto,
31 Claude,
32 Codex,
33 None,
34}
35
36impl LlmBackendChoice {
37 pub fn to_chain(self) -> Vec<crate::embedder::LlmBackendKind> {
47 use crate::embedder::LlmBackendKind;
48 match self {
49 LlmBackendChoice::Codex => vec![LlmBackendKind::Codex, LlmBackendKind::None],
50 LlmBackendChoice::Claude => vec![LlmBackendKind::Claude, LlmBackendKind::None],
51 LlmBackendChoice::None => vec![LlmBackendKind::None],
52 LlmBackendChoice::Auto => parse_fallback_chain(
53 &std::env::var("SQLITE_GRAPHRAG_LLM_FALLBACK")
54 .unwrap_or_else(|_| "codex,claude,none".to_string()),
55 ),
56 }
57 }
58}
59
60fn parse_fallback_chain(s: &str) -> Vec<crate::embedder::LlmBackendKind> {
61 use crate::embedder::LlmBackendKind;
62 let mut chain: Vec<LlmBackendKind> = s
63 .split(',')
64 .filter_map(|tok| match tok.trim().to_ascii_lowercase().as_str() {
65 "codex" => Some(LlmBackendKind::Codex),
66 "claude" | "claude-code" => Some(LlmBackendKind::Claude),
67 "none" => Some(LlmBackendKind::None),
68 _ => {
69 tracing::warn!(token = tok.trim(), "unknown backend in --llm-fallback, skipping");
70 Option::None
71 }
72 })
73 .collect();
74 if chain.is_empty() {
75 chain = vec![LlmBackendKind::Codex, LlmBackendKind::Claude, LlmBackendKind::None];
76 }
77 chain
78}
79
80#[derive(Parser)]
81#[command(name = "sqlite-graphrag")]
82#[command(version)]
83#[command(about = "Local GraphRAG memory for LLMs in a single SQLite file")]
84#[command(arg_required_else_help = true)]
85pub struct Cli {
86 #[arg(long, global = true, value_name = "N")]
91 pub max_concurrency: Option<usize>,
92
93 #[arg(long, global = true, value_name = "SECONDS")]
98 pub wait_lock: Option<u64>,
99
100 #[arg(long, global = true, hide = true, default_value_t = false)]
104 pub skip_memory_guard: bool,
105
106 #[arg(
115 long,
116 global = true,
117 hide = true,
118 default_value_t = false,
119 value_parser = clap::builder::BoolishValueParser::new(),
120 env = "SQLITE_GRAPHRAG_STRICT_ENV_CLEAR"
121 )]
122 pub strict_env_clear: bool,
123
124 #[arg(
131 long,
132 global = true,
133 hide = true,
134 default_value_t = false,
135 value_parser = clap::builder::BoolishValueParser::new(),
136 env = "SQLITE_GRAPHRAG_DRY_RUN_BACKEND"
137 )]
138 pub dry_run_backend: bool,
139
140 #[arg(long, global = true, value_enum, value_name = "LANG")]
146 pub lang: Option<crate::i18n::Language>,
147
148 #[arg(long, global = true, value_name = "IANA")]
154 pub tz: Option<chrono_tz::Tz>,
155
156 #[arg(short = 'v', long, global = true, action = clap::ArgAction::Count)]
161 pub verbose: u8,
162
163 #[arg(long, global = true, value_name = "KIND", default_value = "llm")]
170 pub extraction_backend: Option<String>,
171
172 #[arg(long, global = true, value_name = "N", value_parser = clap::value_parser!(u64).range(8..=4096))]
180 pub embedding_dim: Option<u64>,
181
182 #[arg(long, global = true, value_enum, default_value_t = LlmBackendChoice::Auto, env = "SQLITE_GRAPHRAG_LLM_BACKEND")]
189 pub llm_backend: LlmBackendChoice,
190
191 #[arg(
195 long,
196 global = true,
197 value_name = "MODEL",
198 env = "SQLITE_GRAPHRAG_LLM_MODEL"
199 )]
200 pub llm_model: Option<String>,
201
202 #[arg(
205 long,
206 global = true,
207 value_name = "PATH",
208 env = "SQLITE_GRAPHRAG_CLAUDE_BINARY"
209 )]
210 pub claude_binary: Option<std::path::PathBuf>,
211
212 #[arg(
215 long,
216 global = true,
217 value_name = "PATH",
218 env = "SQLITE_GRAPHRAG_CODEX_BINARY"
219 )]
220 pub codex_binary: Option<std::path::PathBuf>,
221
222 #[arg(
226 long,
227 global = true,
228 default_value = "codex,claude,none",
229 env = "SQLITE_GRAPHRAG_LLM_FALLBACK"
230 )]
231 pub llm_fallback: String,
232
233 #[arg(
238 long,
239 global = true,
240 default_value_t = false,
241 value_parser = clap::builder::BoolishValueParser::new(),
242 env = "SQLITE_GRAPHRAG_SKIP_EMBEDDING_ON_FAILURE"
243 )]
244 pub skip_embedding_on_failure: bool,
245
246 #[arg(
250 long,
251 global = true,
252 value_name = "N",
253 env = "SQLITE_GRAPHRAG_LLM_MAX_HOST_CONCURRENCY"
254 )]
255 pub llm_max_host_concurrency: Option<u32>,
256
257 #[arg(
261 long,
262 global = true,
263 value_name = "SECONDS",
264 env = "SQLITE_GRAPHRAG_LLM_SLOT_WAIT_SECS"
265 )]
266 pub llm_slot_wait_secs: Option<u64>,
267
268 #[arg(
272 long,
273 global = true,
274 default_value_t = false,
275 value_parser = clap::builder::BoolishValueParser::new(),
276 env = "SQLITE_GRAPHRAG_LLM_SLOT_NO_WAIT"
277 )]
278 pub llm_slot_no_wait: bool,
279
280 #[command(subcommand)]
281 pub command: Option<Commands>,
282}
283
284#[cfg(test)]
285mod json_only_format_tests {
286 use super::Cli;
287 use clap::Parser;
288
289 #[test]
290 fn restore_accepts_only_format_json() {
291 assert!(Cli::try_parse_from([
292 "sqlite-graphrag",
293 "restore",
294 "--name",
295 "mem",
296 "--version",
297 "1",
298 "--format",
299 "json",
300 ])
301 .is_ok());
302
303 assert!(Cli::try_parse_from([
304 "sqlite-graphrag",
305 "restore",
306 "--name",
307 "mem",
308 "--version",
309 "1",
310 "--format",
311 "text",
312 ])
313 .is_err());
314 }
315
316 #[test]
317 fn hybrid_search_accepts_only_format_json() {
318 assert!(Cli::try_parse_from([
319 "sqlite-graphrag",
320 "hybrid-search",
321 "query",
322 "--format",
323 "json",
324 ])
325 .is_ok());
326
327 assert!(Cli::try_parse_from([
328 "sqlite-graphrag",
329 "hybrid-search",
330 "query",
331 "--format",
332 "markdown",
333 ])
334 .is_err());
335 }
336
337 #[test]
338 fn remember_recall_rename_vacuum_json_only() {
339 assert!(Cli::try_parse_from([
340 "sqlite-graphrag",
341 "remember",
342 "--name",
343 "mem",
344 "--type",
345 "project",
346 "--description",
347 "desc",
348 "--format",
349 "json",
350 ])
351 .is_ok());
352 assert!(Cli::try_parse_from([
353 "sqlite-graphrag",
354 "remember",
355 "--name",
356 "mem",
357 "--type",
358 "project",
359 "--description",
360 "desc",
361 "--format",
362 "text",
363 ])
364 .is_err());
365
366 assert!(
367 Cli::try_parse_from(["sqlite-graphrag", "recall", "query", "--format", "json",])
368 .is_ok()
369 );
370 assert!(
371 Cli::try_parse_from(["sqlite-graphrag", "recall", "query", "--format", "text",])
372 .is_err()
373 );
374
375 assert!(Cli::try_parse_from([
376 "sqlite-graphrag",
377 "rename",
378 "--name",
379 "old",
380 "--new-name",
381 "new",
382 "--format",
383 "json",
384 ])
385 .is_ok());
386 assert!(Cli::try_parse_from([
387 "sqlite-graphrag",
388 "rename",
389 "--name",
390 "old",
391 "--new-name",
392 "new",
393 "--format",
394 "markdown",
395 ])
396 .is_err());
397
398 assert!(Cli::try_parse_from(["sqlite-graphrag", "vacuum", "--format", "json",]).is_ok());
399 assert!(Cli::try_parse_from(["sqlite-graphrag", "vacuum", "--format", "text",]).is_err());
400 }
401}
402
403impl Cli {
404 pub fn validate_flags(&self) -> Result<(), String> {
409 if let Some(n) = self.max_concurrency {
410 if n == 0 {
411 return Err(match current() {
412 Language::English => "--max-concurrency must be >= 1".to_string(),
413 Language::Portuguese => "--max-concurrency deve ser >= 1".to_string(),
414 });
415 }
416 let teto = max_concurrency_ceiling();
417 if n > teto {
418 return Err(match current() {
419 Language::English => format!(
420 "--max-concurrency {n} exceeds the ceiling of {teto} (2×nCPUs) on this system"
421 ),
422 Language::Portuguese => format!(
423 "--max-concurrency {n} excede o teto de {teto} (2×nCPUs) neste sistema"
424 ),
425 });
426 }
427 }
428 Ok(())
429 }
430}
431
432impl Commands {
433 pub fn is_embedding_heavy(&self) -> bool {
435 matches!(
436 self,
437 Self::Init(_)
438 | Self::Remember(_)
439 | Self::RememberBatch(_)
440 | Self::Recall(_)
441 | Self::HybridSearch(_)
442 | Self::DeepResearch(_)
443 )
444 }
445
446 pub fn uses_cli_slot(&self) -> bool {
447 true
448 }
449}
450
451#[derive(Debug, clap::Args)]
456pub struct CodexModelsArgs {
457 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
459 pub json: bool,
460}
461
462#[derive(Subcommand)]
463pub enum Commands {
464 #[command(after_long_help = "EXAMPLES:\n \
466 # Initialize in current directory (default behavior)\n \
467 sqlite-graphrag init\n\n \
468 # Initialize at a specific path\n \
469 sqlite-graphrag init --db /path/to/graphrag.sqlite\n\n \
470 # Initialize using SQLITE_GRAPHRAG_HOME env var\n \
471 SQLITE_GRAPHRAG_HOME=/data sqlite-graphrag init\n\n\
472 NOTES:\n \
473 - `init` is OPTIONAL: any subsequent CRUD command auto-initializes graphrag.sqlite if missing.\n \
474 - As a side effect, `init` warms a smoke-test embedding via the LLM-only one-shot pipeline.")]
475 Init(init::InitArgs),
476 #[command(after_long_help = "EXAMPLES:\n \
478 # Inline body\n \
479 sqlite-graphrag remember --name onboarding --type user --description \"intro\" --body \"hello\"\n\n \
480 # Body from file\n \
481 sqlite-graphrag remember --name doc1 --type document --description \"...\" --body-file ./README.md\n\n \
482 # Body from stdin (pipe)\n \
483 cat README.md | sqlite-graphrag remember --name doc1 --type document --description \"...\" --body-stdin\n\n \
484 # Enable automatic URL extraction (URL-regex only since v1.0.79; GLiNER removed)\n \
485 sqlite-graphrag remember --name rich --type note --description \"...\" --body \"...\" --enable-ner")]
486 Remember(remember::RememberArgs),
487 #[command(after_long_help = "EXAMPLES:\n \
489 # Batch create from NDJSON\n \
490 cat memories.ndjson | sqlite-graphrag remember-batch --force-merge --json\n\n \
491 # Atomic batch\n \
492 cat memories.ndjson | sqlite-graphrag remember-batch --transaction --json")]
493 RememberBatch(remember_batch::RememberBatchArgs),
494 Ingest(ingest::IngestArgs),
496 #[command(after_long_help = "EXAMPLES:\n \
498 # Top 10 semantic matches (default)\n \
499 sqlite-graphrag recall \"agent memory\"\n\n \
500 # Top 3 only\n \
501 sqlite-graphrag recall \"agent memory\" -k 3\n\n \
502 # Search across all namespaces\n \
503 sqlite-graphrag recall \"agent memory\" --all-namespaces\n\n \
504 # Disable graph traversal (vector-only)\n \
505 sqlite-graphrag recall \"agent memory\" --no-graph")]
506 Recall(recall::RecallArgs),
507 Read(read::ReadArgs),
509 List(list::ListArgs),
511 Forget(forget::ForgetArgs),
513 Purge(purge::PurgeArgs),
515 Rename(rename::RenameArgs),
517 Edit(edit::EditArgs),
519 History(history::HistoryArgs),
521 Restore(restore::RestoreArgs),
523 #[command(after_long_help = "EXAMPLES:\n \
525 # Hybrid search combining KNN + FTS5 BM25 with RRF\n \
526 sqlite-graphrag hybrid-search \"agent memory architecture\"\n\n \
527 # Custom weights for vector vs full-text components\n \
528 sqlite-graphrag hybrid-search \"agent\" --weight-vec 0.7 --weight-fts 0.3")]
529 HybridSearch(hybrid_search::HybridSearchArgs),
530 Health(health::HealthArgs),
532 Migrate(migrate::MigrateArgs),
534 NamespaceDetect(namespace_detect::NamespaceDetectArgs),
536 Optimize(optimize::OptimizeArgs),
538 Stats(stats::StatsArgs),
540 SyncSafeCopy(sync_safe_copy::SyncSafeCopyArgs),
542 Backup(backup::BackupArgs),
544 Vacuum(vacuum::VacuumArgs),
546 Link(link::LinkArgs),
548 Unlink(unlink::UnlinkArgs),
550 #[command(name = "deep-research")]
552 DeepResearch(deep_research::DeepResearchArgs),
553 Related(related::RelatedArgs),
555 Graph(graph_export::GraphArgs),
557 Export(export::ExportArgs),
559 Fts(fts::FtsArgs),
561 Vec(vec::VecArgs),
563 #[command(name = "codex-models")]
569 CodexModels(CodexModelsArgs),
570 PruneRelations(prune_relations::PruneRelationsArgs),
572 #[command(name = "prune-ner")]
574 PruneNer(prune_ner::PruneNerArgs),
575 Slots(slots::SlotsArgs),
577 Pending(pending::PendingArgs),
579 Embedding(embedding::EmbeddingArgs),
581 #[command(name = "pending-embeddings")]
583 PendingEmbeddings(pending_embeddings::PendingEmbeddingsArgs),
584 CleanupOrphans(cleanup_orphans::CleanupOrphansArgs),
586 MemoryEntities(memory_entities::MemoryEntitiesArgs),
588 Cache(cache::CacheArgs),
590 #[command(name = "delete-entity")]
592 DeleteEntity(delete_entity::DeleteEntityArgs),
593 Reclassify(reclassify::ReclassifyArgs),
595 #[command(name = "rename-entity")]
597 RenameEntity(rename_entity::RenameEntityArgs),
598 #[command(name = "merge-entities")]
600 MergeEntities(merge_entities::MergeEntitiesArgs),
601 Enrich(enrich::EnrichArgs),
603 #[command(name = "reclassify-relation")]
605 ReclassifyRelation(reclassify_relation::ReclassifyRelationArgs),
606 #[command(name = "normalize-entities")]
608 NormalizeEntities(normalize_entities::NormalizeEntitiesArgs),
609 Completions(completions::CompletionsArgs),
611 #[command(name = "debug-schema", hide = true)]
612 DebugSchema(debug_schema::DebugSchemaArgs),
613}
614impl std::fmt::Debug for Commands {
620 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
621 let name = match self {
622 Self::Init(_) => "Init",
623 Self::Health(_) => "Health",
624 Self::Stats(_) => "Stats",
625 Self::List(_) => "List",
626 Self::Read(_) => "Read",
627 Self::Edit(_) => "Edit",
628 Self::Rename(_) => "Rename",
629 Self::Restore(_) => "Restore",
630 Self::History(_) => "History",
631 Self::Forget(_) => "Forget",
632 Self::Purge(_) => "Purge",
633 Self::Remember(_) => "Remember",
634 Self::RememberBatch(_) => "RememberBatch",
635 Self::Recall(_) => "Recall",
636 Self::HybridSearch(_) => "HybridSearch",
637 Self::Enrich(_) => "Enrich",
638 Self::Ingest(_) => "Ingest",
639 Self::Optimize(_) => "Optimize",
640 Self::Migrate(_) => "Migrate",
641 Self::SyncSafeCopy(_) => "SyncSafeCopy",
642 Self::Backup(_) => "Backup",
643 Self::Vacuum(_) => "Vacuum",
644 Self::Link(_) => "Link",
645 Self::Unlink(_) => "Unlink",
646 Self::DeepResearch(_) => "DeepResearch",
647 Self::Related(_) => "Related",
648 Self::Graph(_) => "Graph",
649 Self::Export(_) => "Export",
650 Self::Fts(_) => "Fts",
651 Self::Vec(_) => "Vec",
652 Self::CodexModels(_) => "CodexModels",
653 Self::PruneRelations(_) => "PruneRelations",
654 Self::PruneNer(_) => "PruneNer",
655 Self::Slots(_) => "Slots",
656 Self::Pending(_) => "Pending",
657 Self::Embedding(_) => "Embedding",
658 Self::PendingEmbeddings(_) => "PendingEmbeddings",
659 Self::CleanupOrphans(_) => "CleanupOrphans",
660 Self::MemoryEntities(_) => "MemoryEntities",
661 Self::Cache(_) => "Cache",
662 Self::DeleteEntity(_) => "DeleteEntity",
663 Self::Reclassify(_) => "Reclassify",
664 Self::RenameEntity(_) => "RenameEntity",
665 Self::ReclassifyRelation(_) => "ReclassifyRelation",
666 Self::NormalizeEntities(_) => "NormalizeEntities",
667 Self::MergeEntities(_) => "MergeEntities",
668 Self::NamespaceDetect(_) => "NamespaceDetect",
669 Self::Completions(_) => "Completions",
670 Self::DebugSchema(_) => "DebugSchema",
671 };
672 f.write_str(name)
673 }
674}
675
676#[derive(Copy, Clone, Debug, Default, clap::ValueEnum)]
677pub enum MemoryType {
678 User,
679 Feedback,
680 Project,
681 Reference,
682 Decision,
683 Incident,
684 Skill,
685 #[default]
686 Document,
687 Note,
688}
689
690#[cfg(test)]
691mod heavy_concurrency_tests {
692 use super::*;
693
694 #[test]
695 fn command_heavy_detects_init_and_embeddings() {
696 let init = Cli::try_parse_from(["sqlite-graphrag", "init"]).expect("parse init");
697 assert!(init
698 .command
699 .as_ref()
700 .is_some_and(|c| c.is_embedding_heavy()));
701
702 let remember = Cli::try_parse_from([
703 "sqlite-graphrag",
704 "remember",
705 "--name",
706 "test-memory",
707 "--type",
708 "project",
709 "--description",
710 "desc",
711 ])
712 .expect("parse remember");
713 assert!(remember
714 .command
715 .as_ref()
716 .is_some_and(|c| c.is_embedding_heavy()));
717
718 let recall =
719 Cli::try_parse_from(["sqlite-graphrag", "recall", "query"]).expect("parse recall");
720 assert!(recall
721 .command
722 .as_ref()
723 .is_some_and(|c| c.is_embedding_heavy()));
724
725 let hybrid = Cli::try_parse_from(["sqlite-graphrag", "hybrid-search", "query"])
726 .expect("parse hybrid");
727 assert!(hybrid
728 .command
729 .as_ref()
730 .is_some_and(|c| c.is_embedding_heavy()));
731 }
732
733 #[test]
734 fn command_light_does_not_mark_stats() {
735 let stats = Cli::try_parse_from(["sqlite-graphrag", "stats"]).expect("parse stats");
736 assert!(!stats
737 .command
738 .as_ref()
739 .is_some_and(|c| c.is_embedding_heavy()));
740 }
741}
742
743impl MemoryType {
744 pub fn as_str(&self) -> &'static str {
745 match self {
746 Self::User => "user",
747 Self::Feedback => "feedback",
748 Self::Project => "project",
749 Self::Reference => "reference",
750 Self::Decision => "decision",
751 Self::Incident => "incident",
752 Self::Skill => "skill",
753 Self::Document => "document",
754 Self::Note => "note",
755 }
756 }
757}