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(long, global = true, value_enum, value_name = "LANG")]
93 pub lang: Option<crate::i18n::Language>,
94
95 #[arg(long, global = true, value_name = "IANA")]
101 pub tz: Option<chrono_tz::Tz>,
102
103 #[arg(short = 'v', long, global = true, action = clap::ArgAction::Count)]
108 pub verbose: u8,
109
110 #[arg(long, global = true, value_name = "KIND", default_value = "llm")]
117 pub extraction_backend: Option<String>,
118
119 #[arg(long, global = true, value_name = "N", value_parser = clap::value_parser!(u64).range(8..=4096))]
127 pub embedding_dim: Option<u64>,
128
129 #[arg(long, global = true, value_enum, default_value_t = LlmBackendChoice::Auto, env = "SQLITE_GRAPHRAG_LLM_BACKEND")]
133 pub llm_backend: LlmBackendChoice,
134
135 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 #[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 #[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 #[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 Ingest(ingest::IngestArgs),
417 #[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(read::ReadArgs),
430 List(list::ListArgs),
432 Forget(forget::ForgetArgs),
434 Purge(purge::PurgeArgs),
436 Rename(rename::RenameArgs),
438 Edit(edit::EditArgs),
440 History(history::HistoryArgs),
442 Restore(restore::RestoreArgs),
444 #[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 Health(health::HealthArgs),
453 Migrate(migrate::MigrateArgs),
455 NamespaceDetect(namespace_detect::NamespaceDetectArgs),
457 Optimize(optimize::OptimizeArgs),
459 Stats(stats::StatsArgs),
461 SyncSafeCopy(sync_safe_copy::SyncSafeCopyArgs),
463 Backup(backup::BackupArgs),
465 Vacuum(vacuum::VacuumArgs),
467 Link(link::LinkArgs),
469 Unlink(unlink::UnlinkArgs),
471 #[command(name = "deep-research")]
473 DeepResearch(deep_research::DeepResearchArgs),
474 Related(related::RelatedArgs),
476 Graph(graph_export::GraphArgs),
478 Export(export::ExportArgs),
480 Fts(fts::FtsArgs),
482 Vec(vec::VecArgs),
484 #[command(name = "codex-models")]
486 CodexModels,
487 PruneRelations(prune_relations::PruneRelationsArgs),
489 #[command(name = "prune-ner")]
491 PruneNer(prune_ner::PruneNerArgs),
492 Slots(slots::SlotsArgs),
494 Pending(pending::PendingArgs),
496 Embedding(embedding::EmbeddingArgs),
498 #[command(name = "pending-embeddings")]
500 PendingEmbeddings(pending_embeddings::PendingEmbeddingsArgs),
501 CleanupOrphans(cleanup_orphans::CleanupOrphansArgs),
503 MemoryEntities(memory_entities::MemoryEntitiesArgs),
505 Cache(cache::CacheArgs),
507 #[command(name = "delete-entity")]
509 DeleteEntity(delete_entity::DeleteEntityArgs),
510 Reclassify(reclassify::ReclassifyArgs),
512 #[command(name = "rename-entity")]
514 RenameEntity(rename_entity::RenameEntityArgs),
515 #[command(name = "merge-entities")]
517 MergeEntities(merge_entities::MergeEntitiesArgs),
518 Enrich(enrich::EnrichArgs),
520 #[command(name = "reclassify-relation")]
522 ReclassifyRelation(reclassify_relation::ReclassifyRelationArgs),
523 #[command(name = "normalize-entities")]
525 NormalizeEntities(normalize_entities::NormalizeEntitiesArgs),
526 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}