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