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 => 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 #[arg(long, global = true, value_name = "N")]
72 pub max_concurrency: Option<usize>,
73
74 #[arg(long, global = true, value_name = "SECONDS")]
79 pub wait_lock: Option<u64>,
80
81 #[arg(long, global = true, hide = true, default_value_t = false)]
85 pub skip_memory_guard: bool,
86
87 #[arg(
96 long,
97 global = true,
98 hide = true,
99 default_value_t = false,
100 env = "SQLITE_GRAPHRAG_STRICT_ENV_CLEAR"
101 )]
102 pub strict_env_clear: bool,
103
104 #[arg(
111 long,
112 global = true,
113 hide = true,
114 default_value_t = false,
115 env = "SQLITE_GRAPHRAG_DRY_RUN_BACKEND"
116 )]
117 pub dry_run_backend: bool,
118
119 #[arg(long, global = true, value_enum, value_name = "LANG")]
125 pub lang: Option<crate::i18n::Language>,
126
127 #[arg(long, global = true, value_name = "IANA")]
133 pub tz: Option<chrono_tz::Tz>,
134
135 #[arg(short = 'v', long, global = true, action = clap::ArgAction::Count)]
140 pub verbose: u8,
141
142 #[arg(long, global = true, value_name = "KIND", default_value = "llm")]
149 pub extraction_backend: Option<String>,
150
151 #[arg(long, global = true, value_name = "N", value_parser = clap::value_parser!(u64).range(8..=4096))]
159 pub embedding_dim: Option<u64>,
160
161 #[arg(long, global = true, value_enum, default_value_t = LlmBackendChoice::Auto, env = "SQLITE_GRAPHRAG_LLM_BACKEND")]
168 pub llm_backend: LlmBackendChoice,
169
170 #[arg(
174 long,
175 global = true,
176 value_name = "MODEL",
177 env = "SQLITE_GRAPHRAG_LLM_MODEL"
178 )]
179 pub llm_model: Option<String>,
180
181 #[arg(
184 long,
185 global = true,
186 value_name = "PATH",
187 env = "SQLITE_GRAPHRAG_CLAUDE_BINARY"
188 )]
189 pub claude_binary: Option<std::path::PathBuf>,
190
191 #[arg(
195 long,
196 global = true,
197 default_value = "codex,claude,none",
198 env = "SQLITE_GRAPHRAG_LLM_FALLBACK"
199 )]
200 pub llm_fallback: String,
201
202 #[arg(
207 long,
208 global = true,
209 default_value_t = false,
210 env = "SQLITE_GRAPHRAG_SKIP_EMBEDDING_ON_FAILURE"
211 )]
212 pub skip_embedding_on_failure: bool,
213
214 #[arg(
218 long,
219 global = true,
220 value_name = "N",
221 env = "SQLITE_GRAPHRAG_LLM_MAX_HOST_CONCURRENCY"
222 )]
223 pub llm_max_host_concurrency: Option<u32>,
224
225 #[arg(
229 long,
230 global = true,
231 value_name = "SECONDS",
232 env = "SQLITE_GRAPHRAG_LLM_SLOT_WAIT_SECS"
233 )]
234 pub llm_slot_wait_secs: Option<u64>,
235
236 #[arg(
240 long,
241 global = true,
242 default_value_t = false,
243 env = "SQLITE_GRAPHRAG_LLM_SLOT_NO_WAIT"
244 )]
245 pub llm_slot_no_wait: bool,
246
247 #[command(subcommand)]
248 pub command: Option<Commands>,
249}
250
251#[cfg(test)]
252mod json_only_format_tests {
253 use super::Cli;
254 use clap::Parser;
255
256 #[test]
257 fn restore_accepts_only_format_json() {
258 assert!(Cli::try_parse_from([
259 "sqlite-graphrag",
260 "restore",
261 "--name",
262 "mem",
263 "--version",
264 "1",
265 "--format",
266 "json",
267 ])
268 .is_ok());
269
270 assert!(Cli::try_parse_from([
271 "sqlite-graphrag",
272 "restore",
273 "--name",
274 "mem",
275 "--version",
276 "1",
277 "--format",
278 "text",
279 ])
280 .is_err());
281 }
282
283 #[test]
284 fn hybrid_search_accepts_only_format_json() {
285 assert!(Cli::try_parse_from([
286 "sqlite-graphrag",
287 "hybrid-search",
288 "query",
289 "--format",
290 "json",
291 ])
292 .is_ok());
293
294 assert!(Cli::try_parse_from([
295 "sqlite-graphrag",
296 "hybrid-search",
297 "query",
298 "--format",
299 "markdown",
300 ])
301 .is_err());
302 }
303
304 #[test]
305 fn remember_recall_rename_vacuum_json_only() {
306 assert!(Cli::try_parse_from([
307 "sqlite-graphrag",
308 "remember",
309 "--name",
310 "mem",
311 "--type",
312 "project",
313 "--description",
314 "desc",
315 "--format",
316 "json",
317 ])
318 .is_ok());
319 assert!(Cli::try_parse_from([
320 "sqlite-graphrag",
321 "remember",
322 "--name",
323 "mem",
324 "--type",
325 "project",
326 "--description",
327 "desc",
328 "--format",
329 "text",
330 ])
331 .is_err());
332
333 assert!(
334 Cli::try_parse_from(["sqlite-graphrag", "recall", "query", "--format", "json",])
335 .is_ok()
336 );
337 assert!(
338 Cli::try_parse_from(["sqlite-graphrag", "recall", "query", "--format", "text",])
339 .is_err()
340 );
341
342 assert!(Cli::try_parse_from([
343 "sqlite-graphrag",
344 "rename",
345 "--name",
346 "old",
347 "--new-name",
348 "new",
349 "--format",
350 "json",
351 ])
352 .is_ok());
353 assert!(Cli::try_parse_from([
354 "sqlite-graphrag",
355 "rename",
356 "--name",
357 "old",
358 "--new-name",
359 "new",
360 "--format",
361 "markdown",
362 ])
363 .is_err());
364
365 assert!(Cli::try_parse_from(["sqlite-graphrag", "vacuum", "--format", "json",]).is_ok());
366 assert!(Cli::try_parse_from(["sqlite-graphrag", "vacuum", "--format", "text",]).is_err());
367 }
368}
369
370impl Cli {
371 pub fn validate_flags(&self) -> Result<(), String> {
376 if let Some(n) = self.max_concurrency {
377 if n == 0 {
378 return Err(match current() {
379 Language::English => "--max-concurrency must be >= 1".to_string(),
380 Language::Portuguese => "--max-concurrency deve ser >= 1".to_string(),
381 });
382 }
383 let teto = max_concurrency_ceiling();
384 if n > teto {
385 return Err(match current() {
386 Language::English => format!(
387 "--max-concurrency {n} exceeds the ceiling of {teto} (2×nCPUs) on this system"
388 ),
389 Language::Portuguese => format!(
390 "--max-concurrency {n} excede o teto de {teto} (2×nCPUs) neste sistema"
391 ),
392 });
393 }
394 }
395 Ok(())
396 }
397}
398
399impl Commands {
400 pub fn is_embedding_heavy(&self) -> bool {
402 matches!(
403 self,
404 Self::Init(_)
405 | Self::Remember(_)
406 | Self::RememberBatch(_)
407 | Self::Recall(_)
408 | Self::HybridSearch(_)
409 | Self::DeepResearch(_)
410 )
411 }
412
413 pub fn uses_cli_slot(&self) -> bool {
414 true
415 }
416}
417
418#[derive(Subcommand)]
419pub enum Commands {
420 #[command(after_long_help = "EXAMPLES:\n \
422 # Initialize in current directory (default behavior)\n \
423 sqlite-graphrag init\n\n \
424 # Initialize at a specific path\n \
425 sqlite-graphrag init --db /path/to/graphrag.sqlite\n\n \
426 # Initialize using SQLITE_GRAPHRAG_HOME env var\n \
427 SQLITE_GRAPHRAG_HOME=/data sqlite-graphrag init\n\n\
428 NOTES:\n \
429 - `init` is OPTIONAL: any subsequent CRUD command auto-initializes graphrag.sqlite if missing.\n \
430 - As a side effect, `init` warms a smoke-test embedding via the LLM-only one-shot pipeline.")]
431 Init(init::InitArgs),
432 #[command(after_long_help = "EXAMPLES:\n \
434 # Inline body\n \
435 sqlite-graphrag remember --name onboarding --type user --description \"intro\" --body \"hello\"\n\n \
436 # Body from file\n \
437 sqlite-graphrag remember --name doc1 --type document --description \"...\" --body-file ./README.md\n\n \
438 # Body from stdin (pipe)\n \
439 cat README.md | sqlite-graphrag remember --name doc1 --type document --description \"...\" --body-stdin\n\n \
440 # Enable automatic URL extraction (URL-regex only since v1.0.79; GLiNER removed)\n \
441 sqlite-graphrag remember --name rich --type note --description \"...\" --body \"...\" --enable-ner")]
442 Remember(remember::RememberArgs),
443 #[command(after_long_help = "EXAMPLES:\n \
445 # Batch create from NDJSON\n \
446 cat memories.ndjson | sqlite-graphrag remember-batch --force-merge --json\n\n \
447 # Atomic batch\n \
448 cat memories.ndjson | sqlite-graphrag remember-batch --transaction --json")]
449 RememberBatch(remember_batch::RememberBatchArgs),
450 Ingest(ingest::IngestArgs),
452 #[command(after_long_help = "EXAMPLES:\n \
454 # Top 10 semantic matches (default)\n \
455 sqlite-graphrag recall \"agent memory\"\n\n \
456 # Top 3 only\n \
457 sqlite-graphrag recall \"agent memory\" -k 3\n\n \
458 # Search across all namespaces\n \
459 sqlite-graphrag recall \"agent memory\" --all-namespaces\n\n \
460 # Disable graph traversal (vector-only)\n \
461 sqlite-graphrag recall \"agent memory\" --no-graph")]
462 Recall(recall::RecallArgs),
463 Read(read::ReadArgs),
465 List(list::ListArgs),
467 Forget(forget::ForgetArgs),
469 Purge(purge::PurgeArgs),
471 Rename(rename::RenameArgs),
473 Edit(edit::EditArgs),
475 History(history::HistoryArgs),
477 Restore(restore::RestoreArgs),
479 #[command(after_long_help = "EXAMPLES:\n \
481 # Hybrid search combining KNN + FTS5 BM25 with RRF\n \
482 sqlite-graphrag hybrid-search \"agent memory architecture\"\n\n \
483 # Custom weights for vector vs full-text components\n \
484 sqlite-graphrag hybrid-search \"agent\" --weight-vec 0.7 --weight-fts 0.3")]
485 HybridSearch(hybrid_search::HybridSearchArgs),
486 Health(health::HealthArgs),
488 Migrate(migrate::MigrateArgs),
490 NamespaceDetect(namespace_detect::NamespaceDetectArgs),
492 Optimize(optimize::OptimizeArgs),
494 Stats(stats::StatsArgs),
496 SyncSafeCopy(sync_safe_copy::SyncSafeCopyArgs),
498 Backup(backup::BackupArgs),
500 Vacuum(vacuum::VacuumArgs),
502 Link(link::LinkArgs),
504 Unlink(unlink::UnlinkArgs),
506 #[command(name = "deep-research")]
508 DeepResearch(deep_research::DeepResearchArgs),
509 Related(related::RelatedArgs),
511 Graph(graph_export::GraphArgs),
513 Export(export::ExportArgs),
515 Fts(fts::FtsArgs),
517 Vec(vec::VecArgs),
519 #[command(name = "codex-models")]
521 CodexModels,
522 PruneRelations(prune_relations::PruneRelationsArgs),
524 #[command(name = "prune-ner")]
526 PruneNer(prune_ner::PruneNerArgs),
527 Slots(slots::SlotsArgs),
529 Pending(pending::PendingArgs),
531 Embedding(embedding::EmbeddingArgs),
533 #[command(name = "pending-embeddings")]
535 PendingEmbeddings(pending_embeddings::PendingEmbeddingsArgs),
536 CleanupOrphans(cleanup_orphans::CleanupOrphansArgs),
538 MemoryEntities(memory_entities::MemoryEntitiesArgs),
540 Cache(cache::CacheArgs),
542 #[command(name = "delete-entity")]
544 DeleteEntity(delete_entity::DeleteEntityArgs),
545 Reclassify(reclassify::ReclassifyArgs),
547 #[command(name = "rename-entity")]
549 RenameEntity(rename_entity::RenameEntityArgs),
550 #[command(name = "merge-entities")]
552 MergeEntities(merge_entities::MergeEntitiesArgs),
553 Enrich(enrich::EnrichArgs),
555 #[command(name = "reclassify-relation")]
557 ReclassifyRelation(reclassify_relation::ReclassifyRelationArgs),
558 #[command(name = "normalize-entities")]
560 NormalizeEntities(normalize_entities::NormalizeEntitiesArgs),
561 Completions(completions::CompletionsArgs),
563 #[command(name = "debug-schema", hide = true)]
564 DebugSchema(debug_schema::DebugSchemaArgs),
565}
566
567#[derive(Copy, Clone, Debug, Default, clap::ValueEnum)]
568pub enum MemoryType {
569 User,
570 Feedback,
571 Project,
572 Reference,
573 Decision,
574 Incident,
575 Skill,
576 #[default]
577 Document,
578 Note,
579}
580
581#[cfg(test)]
582mod heavy_concurrency_tests {
583 use super::*;
584
585 #[test]
586 fn command_heavy_detects_init_and_embeddings() {
587 let init = Cli::try_parse_from(["sqlite-graphrag", "init"]).expect("parse init");
588 assert!(init
589 .command
590 .as_ref()
591 .is_some_and(|c| c.is_embedding_heavy()));
592
593 let remember = Cli::try_parse_from([
594 "sqlite-graphrag",
595 "remember",
596 "--name",
597 "test-memory",
598 "--type",
599 "project",
600 "--description",
601 "desc",
602 ])
603 .expect("parse remember");
604 assert!(remember
605 .command
606 .as_ref()
607 .is_some_and(|c| c.is_embedding_heavy()));
608
609 let recall =
610 Cli::try_parse_from(["sqlite-graphrag", "recall", "query"]).expect("parse recall");
611 assert!(recall
612 .command
613 .as_ref()
614 .is_some_and(|c| c.is_embedding_heavy()));
615
616 let hybrid = Cli::try_parse_from(["sqlite-graphrag", "hybrid-search", "query"])
617 .expect("parse hybrid");
618 assert!(hybrid
619 .command
620 .as_ref()
621 .is_some_and(|c| c.is_embedding_heavy()));
622 }
623
624 #[test]
625 fn command_light_does_not_mark_stats() {
626 let stats = Cli::try_parse_from(["sqlite-graphrag", "stats"]).expect("parse stats");
627 assert!(!stats
628 .command
629 .as_ref()
630 .is_some_and(|c| c.is_embedding_heavy()));
631 }
632}
633
634impl MemoryType {
635 pub fn as_str(&self) -> &'static str {
636 match self {
637 Self::User => "user",
638 Self::Feedback => "feedback",
639 Self::Project => "project",
640 Self::Reference => "reference",
641 Self::Decision => "decision",
642 Self::Incident => "incident",
643 Self::Skill => "skill",
644 Self::Document => "document",
645 Self::Note => "note",
646 }
647 }
648}