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(Parser)]
26#[command(name = "sqlite-graphrag")]
27#[command(version)]
28#[command(about = "Local GraphRAG memory for LLMs in a single SQLite file")]
29#[command(arg_required_else_help = true)]
30pub struct Cli {
31 #[arg(long, global = true, value_name = "N")]
36 pub max_concurrency: Option<usize>,
37
38 #[arg(long, global = true, value_name = "SECONDS")]
43 pub wait_lock: Option<u64>,
44
45 #[arg(long, global = true, hide = true, default_value_t = false)]
49 pub skip_memory_guard: bool,
50
51 #[arg(long, global = true, value_enum, value_name = "LANG")]
57 pub lang: Option<crate::i18n::Language>,
58
59 #[arg(long, global = true, value_name = "IANA")]
65 pub tz: Option<chrono_tz::Tz>,
66
67 #[arg(short = 'v', long, global = true, action = clap::ArgAction::Count)]
72 pub verbose: u8,
73
74 #[arg(long, global = true, value_name = "KIND", default_value = "llm")]
81 pub extraction_backend: Option<String>,
82
83 #[arg(long, global = true, value_name = "N", value_parser = clap::value_parser!(u64).range(8..=4096))]
91 pub embedding_dim: Option<u64>,
92
93 #[command(subcommand)]
94 pub command: Commands,
95}
96
97#[cfg(test)]
98mod json_only_format_tests {
99 use super::Cli;
100 use clap::Parser;
101
102 #[test]
103 fn restore_accepts_only_format_json() {
104 assert!(Cli::try_parse_from([
105 "sqlite-graphrag",
106 "restore",
107 "--name",
108 "mem",
109 "--version",
110 "1",
111 "--format",
112 "json",
113 ])
114 .is_ok());
115
116 assert!(Cli::try_parse_from([
117 "sqlite-graphrag",
118 "restore",
119 "--name",
120 "mem",
121 "--version",
122 "1",
123 "--format",
124 "text",
125 ])
126 .is_err());
127 }
128
129 #[test]
130 fn hybrid_search_accepts_only_format_json() {
131 assert!(Cli::try_parse_from([
132 "sqlite-graphrag",
133 "hybrid-search",
134 "query",
135 "--format",
136 "json",
137 ])
138 .is_ok());
139
140 assert!(Cli::try_parse_from([
141 "sqlite-graphrag",
142 "hybrid-search",
143 "query",
144 "--format",
145 "markdown",
146 ])
147 .is_err());
148 }
149
150 #[test]
151 fn remember_recall_rename_vacuum_json_only() {
152 assert!(Cli::try_parse_from([
153 "sqlite-graphrag",
154 "remember",
155 "--name",
156 "mem",
157 "--type",
158 "project",
159 "--description",
160 "desc",
161 "--format",
162 "json",
163 ])
164 .is_ok());
165 assert!(Cli::try_parse_from([
166 "sqlite-graphrag",
167 "remember",
168 "--name",
169 "mem",
170 "--type",
171 "project",
172 "--description",
173 "desc",
174 "--format",
175 "text",
176 ])
177 .is_err());
178
179 assert!(
180 Cli::try_parse_from(["sqlite-graphrag", "recall", "query", "--format", "json",])
181 .is_ok()
182 );
183 assert!(
184 Cli::try_parse_from(["sqlite-graphrag", "recall", "query", "--format", "text",])
185 .is_err()
186 );
187
188 assert!(Cli::try_parse_from([
189 "sqlite-graphrag",
190 "rename",
191 "--name",
192 "old",
193 "--new-name",
194 "new",
195 "--format",
196 "json",
197 ])
198 .is_ok());
199 assert!(Cli::try_parse_from([
200 "sqlite-graphrag",
201 "rename",
202 "--name",
203 "old",
204 "--new-name",
205 "new",
206 "--format",
207 "markdown",
208 ])
209 .is_err());
210
211 assert!(Cli::try_parse_from(["sqlite-graphrag", "vacuum", "--format", "json",]).is_ok());
212 assert!(Cli::try_parse_from(["sqlite-graphrag", "vacuum", "--format", "text",]).is_err());
213 }
214}
215
216impl Cli {
217 pub fn validate_flags(&self) -> Result<(), String> {
222 if let Some(n) = self.max_concurrency {
223 if n == 0 {
224 return Err(match current() {
225 Language::English => "--max-concurrency must be >= 1".to_string(),
226 Language::Portuguese => "--max-concurrency deve ser >= 1".to_string(),
227 });
228 }
229 let teto = max_concurrency_ceiling();
230 if n > teto {
231 return Err(match current() {
232 Language::English => format!(
233 "--max-concurrency {n} exceeds the ceiling of {teto} (2×nCPUs) on this system"
234 ),
235 Language::Portuguese => format!(
236 "--max-concurrency {n} excede o teto de {teto} (2×nCPUs) neste sistema"
237 ),
238 });
239 }
240 }
241 Ok(())
242 }
243}
244
245impl Commands {
246 pub fn is_embedding_heavy(&self) -> bool {
248 matches!(
249 self,
250 Self::Init(_)
251 | Self::Remember(_)
252 | Self::RememberBatch(_)
253 | Self::Recall(_)
254 | Self::HybridSearch(_)
255 | Self::DeepResearch(_)
256 )
257 }
258
259 pub fn uses_cli_slot(&self) -> bool {
260 true
261 }
262}
263
264#[derive(Subcommand)]
265pub enum Commands {
266 #[command(after_long_help = "EXAMPLES:\n \
268 # Initialize in current directory (default behavior)\n \
269 sqlite-graphrag init\n\n \
270 # Initialize at a specific path\n \
271 sqlite-graphrag init --db /path/to/graphrag.sqlite\n\n \
272 # Initialize using SQLITE_GRAPHRAG_HOME env var\n \
273 SQLITE_GRAPHRAG_HOME=/data sqlite-graphrag init\n\n\
274 NOTES:\n \
275 - `init` is OPTIONAL: any subsequent CRUD command auto-initializes graphrag.sqlite if missing.\n \
276 - As a side effect, `init` warms a smoke-test embedding via the LLM-only one-shot pipeline.")]
277 Init(init::InitArgs),
278 #[command(after_long_help = "EXAMPLES:\n \
280 # Inline body\n \
281 sqlite-graphrag remember --name onboarding --type user --description \"intro\" --body \"hello\"\n\n \
282 # Body from file\n \
283 sqlite-graphrag remember --name doc1 --type document --description \"...\" --body-file ./README.md\n\n \
284 # Body from stdin (pipe)\n \
285 cat README.md | sqlite-graphrag remember --name doc1 --type document --description \"...\" --body-stdin\n\n \
286 # Enable automatic URL extraction (URL-regex only since v1.0.79; GLiNER removed)\n \
287 sqlite-graphrag remember --name rich --type note --description \"...\" --body \"...\" --enable-ner")]
288 Remember(remember::RememberArgs),
289 #[command(after_long_help = "EXAMPLES:\n \
291 # Batch create from NDJSON\n \
292 cat memories.ndjson | sqlite-graphrag remember-batch --force-merge --json\n\n \
293 # Atomic batch\n \
294 cat memories.ndjson | sqlite-graphrag remember-batch --transaction --json")]
295 RememberBatch(remember_batch::RememberBatchArgs),
296 Ingest(ingest::IngestArgs),
298 #[command(after_long_help = "EXAMPLES:\n \
300 # Top 10 semantic matches (default)\n \
301 sqlite-graphrag recall \"agent memory\"\n\n \
302 # Top 3 only\n \
303 sqlite-graphrag recall \"agent memory\" -k 3\n\n \
304 # Search across all namespaces\n \
305 sqlite-graphrag recall \"agent memory\" --all-namespaces\n\n \
306 # Disable graph traversal (vector-only)\n \
307 sqlite-graphrag recall \"agent memory\" --no-graph")]
308 Recall(recall::RecallArgs),
309 Read(read::ReadArgs),
311 List(list::ListArgs),
313 Forget(forget::ForgetArgs),
315 Purge(purge::PurgeArgs),
317 Rename(rename::RenameArgs),
319 Edit(edit::EditArgs),
321 History(history::HistoryArgs),
323 Restore(restore::RestoreArgs),
325 #[command(after_long_help = "EXAMPLES:\n \
327 # Hybrid search combining KNN + FTS5 BM25 with RRF\n \
328 sqlite-graphrag hybrid-search \"agent memory architecture\"\n\n \
329 # Custom weights for vector vs full-text components\n \
330 sqlite-graphrag hybrid-search \"agent\" --weight-vec 0.7 --weight-fts 0.3")]
331 HybridSearch(hybrid_search::HybridSearchArgs),
332 Health(health::HealthArgs),
334 Migrate(migrate::MigrateArgs),
336 NamespaceDetect(namespace_detect::NamespaceDetectArgs),
338 Optimize(optimize::OptimizeArgs),
340 Stats(stats::StatsArgs),
342 SyncSafeCopy(sync_safe_copy::SyncSafeCopyArgs),
344 Backup(backup::BackupArgs),
346 Vacuum(vacuum::VacuumArgs),
348 Link(link::LinkArgs),
350 Unlink(unlink::UnlinkArgs),
352 #[command(name = "deep-research")]
354 DeepResearch(deep_research::DeepResearchArgs),
355 Related(related::RelatedArgs),
357 Graph(graph_export::GraphArgs),
359 Export(export::ExportArgs),
361 Fts(fts::FtsArgs),
363 Vec(vec::VecArgs),
365 #[command(name = "codex-models")]
367 CodexModels,
368 PruneRelations(prune_relations::PruneRelationsArgs),
370 #[command(name = "prune-ner")]
372 PruneNer(prune_ner::PruneNerArgs),
373 CleanupOrphans(cleanup_orphans::CleanupOrphansArgs),
375 MemoryEntities(memory_entities::MemoryEntitiesArgs),
377 Cache(cache::CacheArgs),
379 #[command(name = "delete-entity")]
381 DeleteEntity(delete_entity::DeleteEntityArgs),
382 Reclassify(reclassify::ReclassifyArgs),
384 #[command(name = "rename-entity")]
386 RenameEntity(rename_entity::RenameEntityArgs),
387 #[command(name = "merge-entities")]
389 MergeEntities(merge_entities::MergeEntitiesArgs),
390 Enrich(enrich::EnrichArgs),
392 #[command(name = "reclassify-relation")]
394 ReclassifyRelation(reclassify_relation::ReclassifyRelationArgs),
395 #[command(name = "normalize-entities")]
397 NormalizeEntities(normalize_entities::NormalizeEntitiesArgs),
398 Completions(completions::CompletionsArgs),
400 #[command(name = "debug-schema", hide = true)]
401 DebugSchema(debug_schema::DebugSchemaArgs),
402}
403
404#[derive(Copy, Clone, Debug, Default, clap::ValueEnum)]
405pub enum MemoryType {
406 User,
407 Feedback,
408 Project,
409 Reference,
410 Decision,
411 Incident,
412 Skill,
413 #[default]
414 Document,
415 Note,
416}
417
418#[cfg(test)]
419mod heavy_concurrency_tests {
420 use super::*;
421
422 #[test]
423 fn command_heavy_detects_init_and_embeddings() {
424 let init = Cli::try_parse_from(["sqlite-graphrag", "init"]).expect("parse init");
425 assert!(init.command.is_embedding_heavy());
426
427 let remember = Cli::try_parse_from([
428 "sqlite-graphrag",
429 "remember",
430 "--name",
431 "test-memory",
432 "--type",
433 "project",
434 "--description",
435 "desc",
436 ])
437 .expect("parse remember");
438 assert!(remember.command.is_embedding_heavy());
439
440 let recall =
441 Cli::try_parse_from(["sqlite-graphrag", "recall", "query"]).expect("parse recall");
442 assert!(recall.command.is_embedding_heavy());
443
444 let hybrid = Cli::try_parse_from(["sqlite-graphrag", "hybrid-search", "query"])
445 .expect("parse hybrid");
446 assert!(hybrid.command.is_embedding_heavy());
447 }
448
449 #[test]
450 fn command_light_does_not_mark_stats() {
451 let stats = Cli::try_parse_from(["sqlite-graphrag", "stats"]).expect("parse stats");
452 assert!(!stats.command.is_embedding_heavy());
453 }
454}
455
456impl MemoryType {
457 pub fn as_str(&self) -> &'static str {
458 match self {
459 Self::User => "user",
460 Self::Feedback => "feedback",
461 Self::Project => "project",
462 Self::Reference => "reference",
463 Self::Decision => "decision",
464 Self::Incident => "incident",
465 Self::Skill => "skill",
466 Self::Document => "document",
467 Self::Note => "note",
468 }
469 }
470}