1use crate::commands::*;
6use crate::i18n::{current, Language};
7use clap::{Parser, Subcommand};
8
9#[derive(clap::Args, Debug, Clone)]
11pub struct DaemonOpts {
12 #[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
17 pub autostart_daemon: bool,
18}
19
20fn max_concurrency_ceiling() -> usize {
22 std::thread::available_parallelism()
23 .map(|n| n.get() * 2)
24 .unwrap_or(8)
25}
26
27#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
28pub enum GraphExportFormat {
29 Json,
30 Dot,
31 Mermaid,
32 Ndjson,
34}
35
36#[derive(Parser)]
37#[command(name = "sqlite-graphrag")]
38#[command(version)]
39#[command(about = "Local GraphRAG memory for LLMs in a single SQLite file")]
40#[command(arg_required_else_help = true)]
41pub struct Cli {
42 #[arg(long, global = true, value_name = "N")]
47 pub max_concurrency: Option<usize>,
48
49 #[arg(long, global = true, value_name = "SECONDS")]
54 pub wait_lock: Option<u64>,
55
56 #[arg(long, global = true, hide = true, default_value_t = false)]
60 pub skip_memory_guard: bool,
61
62 #[arg(long, global = true, value_enum, value_name = "LANG")]
68 pub lang: Option<crate::i18n::Language>,
69
70 #[arg(long, global = true, value_name = "IANA")]
76 pub tz: Option<chrono_tz::Tz>,
77
78 #[arg(short = 'v', long, global = true, action = clap::ArgAction::Count)]
83 pub verbose: u8,
84
85 #[command(subcommand)]
86 pub command: Commands,
87}
88
89#[cfg(test)]
90mod json_only_format_tests {
91 use super::Cli;
92 use clap::Parser;
93
94 #[test]
95 fn restore_accepts_only_format_json() {
96 assert!(Cli::try_parse_from([
97 "sqlite-graphrag",
98 "restore",
99 "--name",
100 "mem",
101 "--version",
102 "1",
103 "--format",
104 "json",
105 ])
106 .is_ok());
107
108 assert!(Cli::try_parse_from([
109 "sqlite-graphrag",
110 "restore",
111 "--name",
112 "mem",
113 "--version",
114 "1",
115 "--format",
116 "text",
117 ])
118 .is_err());
119 }
120
121 #[test]
122 fn hybrid_search_accepts_only_format_json() {
123 assert!(Cli::try_parse_from([
124 "sqlite-graphrag",
125 "hybrid-search",
126 "query",
127 "--format",
128 "json",
129 ])
130 .is_ok());
131
132 assert!(Cli::try_parse_from([
133 "sqlite-graphrag",
134 "hybrid-search",
135 "query",
136 "--format",
137 "markdown",
138 ])
139 .is_err());
140 }
141
142 #[test]
143 fn remember_recall_rename_vacuum_json_only() {
144 assert!(Cli::try_parse_from([
145 "sqlite-graphrag",
146 "remember",
147 "--name",
148 "mem",
149 "--type",
150 "project",
151 "--description",
152 "desc",
153 "--format",
154 "json",
155 ])
156 .is_ok());
157 assert!(Cli::try_parse_from([
158 "sqlite-graphrag",
159 "remember",
160 "--name",
161 "mem",
162 "--type",
163 "project",
164 "--description",
165 "desc",
166 "--format",
167 "text",
168 ])
169 .is_err());
170
171 assert!(
172 Cli::try_parse_from(["sqlite-graphrag", "recall", "query", "--format", "json",])
173 .is_ok()
174 );
175 assert!(
176 Cli::try_parse_from(["sqlite-graphrag", "recall", "query", "--format", "text",])
177 .is_err()
178 );
179
180 assert!(Cli::try_parse_from([
181 "sqlite-graphrag",
182 "rename",
183 "--name",
184 "old",
185 "--new-name",
186 "new",
187 "--format",
188 "json",
189 ])
190 .is_ok());
191 assert!(Cli::try_parse_from([
192 "sqlite-graphrag",
193 "rename",
194 "--name",
195 "old",
196 "--new-name",
197 "new",
198 "--format",
199 "markdown",
200 ])
201 .is_err());
202
203 assert!(Cli::try_parse_from(["sqlite-graphrag", "vacuum", "--format", "json",]).is_ok());
204 assert!(Cli::try_parse_from(["sqlite-graphrag", "vacuum", "--format", "text",]).is_err());
205 }
206}
207
208impl Cli {
209 pub fn validate_flags(&self) -> Result<(), String> {
214 if let Some(n) = self.max_concurrency {
215 if n == 0 {
216 return Err(match current() {
217 Language::English => "--max-concurrency must be >= 1".to_string(),
218 Language::Portuguese => "--max-concurrency deve ser >= 1".to_string(),
219 });
220 }
221 let teto = max_concurrency_ceiling();
222 if n > teto {
223 return Err(match current() {
224 Language::English => format!(
225 "--max-concurrency {n} exceeds the ceiling of {teto} (2×nCPUs) on this system"
226 ),
227 Language::Portuguese => format!(
228 "--max-concurrency {n} excede o teto de {teto} (2×nCPUs) neste sistema"
229 ),
230 });
231 }
232 }
233 Ok(())
234 }
235}
236
237impl Commands {
238 pub fn is_embedding_heavy(&self) -> bool {
240 matches!(
241 self,
242 Self::Init(_)
243 | Self::Remember(_)
244 | Self::Recall(_)
245 | Self::HybridSearch(_)
246 | Self::DeepResearch(_)
247 )
248 }
249
250 pub fn uses_cli_slot(&self) -> bool {
251 !matches!(self, Self::Daemon(_))
252 }
253}
254
255#[derive(Subcommand)]
256pub enum Commands {
257 #[command(after_long_help = "EXAMPLES:\n \
259 # Initialize in current directory (default behavior)\n \
260 sqlite-graphrag init\n\n \
261 # Initialize at a specific path\n \
262 sqlite-graphrag init --db /path/to/graphrag.sqlite\n\n \
263 # Initialize using SQLITE_GRAPHRAG_HOME env var\n \
264 SQLITE_GRAPHRAG_HOME=/data sqlite-graphrag init\n\n\
265 NOTES:\n \
266 - `init` is OPTIONAL: any subsequent CRUD command auto-initializes graphrag.sqlite if missing.\n \
267 - As a side effect, `init` warms a smoke-test embedding which auto-spawns the persistent daemon (~600s idle timeout).")]
268 Init(init::InitArgs),
269 Daemon(daemon::DaemonArgs),
271 #[command(after_long_help = "EXAMPLES:\n \
273 # Inline body\n \
274 sqlite-graphrag remember --name onboarding --type user --description \"intro\" --body \"hello\"\n\n \
275 # Body from file\n \
276 sqlite-graphrag remember --name doc1 --type document --description \"...\" --body-file ./README.md\n\n \
277 # Body from stdin (pipe)\n \
278 cat README.md | sqlite-graphrag remember --name doc1 --type document --description \"...\" --body-stdin\n\n \
279 # Enable GLiNER entity extraction (disabled by default)\n \
280 sqlite-graphrag remember --name rich --type note --description \"...\" --body \"...\" --enable-ner")]
281 Remember(remember::RememberArgs),
282 Ingest(ingest::IngestArgs),
284 #[command(after_long_help = "EXAMPLES:\n \
286 # Top 10 semantic matches (default)\n \
287 sqlite-graphrag recall \"agent memory\"\n\n \
288 # Top 3 only\n \
289 sqlite-graphrag recall \"agent memory\" -k 3\n\n \
290 # Search across all namespaces\n \
291 sqlite-graphrag recall \"agent memory\" --all-namespaces\n\n \
292 # Disable graph traversal (vector-only)\n \
293 sqlite-graphrag recall \"agent memory\" --no-graph")]
294 Recall(recall::RecallArgs),
295 Read(read::ReadArgs),
297 List(list::ListArgs),
299 Forget(forget::ForgetArgs),
301 Purge(purge::PurgeArgs),
303 Rename(rename::RenameArgs),
305 Edit(edit::EditArgs),
307 History(history::HistoryArgs),
309 Restore(restore::RestoreArgs),
311 #[command(after_long_help = "EXAMPLES:\n \
313 # Hybrid search combining KNN + FTS5 BM25 with RRF\n \
314 sqlite-graphrag hybrid-search \"agent memory architecture\"\n\n \
315 # Custom weights for vector vs full-text components\n \
316 sqlite-graphrag hybrid-search \"agent\" --weight-vec 0.7 --weight-fts 0.3")]
317 HybridSearch(hybrid_search::HybridSearchArgs),
318 Health(health::HealthArgs),
320 Migrate(migrate::MigrateArgs),
322 NamespaceDetect(namespace_detect::NamespaceDetectArgs),
324 Optimize(optimize::OptimizeArgs),
326 Stats(stats::StatsArgs),
328 SyncSafeCopy(sync_safe_copy::SyncSafeCopyArgs),
330 Backup(backup::BackupArgs),
332 Vacuum(vacuum::VacuumArgs),
334 Link(link::LinkArgs),
336 Unlink(unlink::UnlinkArgs),
338 #[command(name = "deep-research")]
340 DeepResearch(deep_research::DeepResearchArgs),
341 Related(related::RelatedArgs),
343 Graph(graph_export::GraphArgs),
345 Export(export::ExportArgs),
347 Fts(fts::FtsArgs),
349 PruneRelations(prune_relations::PruneRelationsArgs),
351 #[command(name = "prune-ner")]
353 PruneNer(prune_ner::PruneNerArgs),
354 CleanupOrphans(cleanup_orphans::CleanupOrphansArgs),
356 MemoryEntities(memory_entities::MemoryEntitiesArgs),
358 Cache(cache::CacheArgs),
360 #[command(name = "delete-entity")]
362 DeleteEntity(delete_entity::DeleteEntityArgs),
363 Reclassify(reclassify::ReclassifyArgs),
365 #[command(name = "rename-entity")]
367 RenameEntity(rename_entity::RenameEntityArgs),
368 #[command(name = "merge-entities")]
370 MergeEntities(merge_entities::MergeEntitiesArgs),
371 Enrich(enrich::EnrichArgs),
373 #[command(name = "reclassify-relation")]
375 ReclassifyRelation(reclassify_relation::ReclassifyRelationArgs),
376 #[command(name = "normalize-entities")]
378 NormalizeEntities(normalize_entities::NormalizeEntitiesArgs),
379 #[command(name = "__debug_schema", hide = true)]
380 DebugSchema(debug_schema::DebugSchemaArgs),
381}
382
383#[derive(Copy, Clone, Debug, Default, clap::ValueEnum)]
384pub enum MemoryType {
385 User,
386 Feedback,
387 Project,
388 Reference,
389 Decision,
390 Incident,
391 Skill,
392 #[default]
393 Document,
394 Note,
395}
396
397#[cfg(test)]
398mod heavy_concurrency_tests {
399 use super::*;
400
401 #[test]
402 fn command_heavy_detects_init_and_embeddings() {
403 let init = Cli::try_parse_from(["sqlite-graphrag", "init"]).expect("parse init");
404 assert!(init.command.is_embedding_heavy());
405
406 let remember = Cli::try_parse_from([
407 "sqlite-graphrag",
408 "remember",
409 "--name",
410 "test-memory",
411 "--type",
412 "project",
413 "--description",
414 "desc",
415 ])
416 .expect("parse remember");
417 assert!(remember.command.is_embedding_heavy());
418
419 let recall =
420 Cli::try_parse_from(["sqlite-graphrag", "recall", "query"]).expect("parse recall");
421 assert!(recall.command.is_embedding_heavy());
422
423 let hybrid = Cli::try_parse_from(["sqlite-graphrag", "hybrid-search", "query"])
424 .expect("parse hybrid");
425 assert!(hybrid.command.is_embedding_heavy());
426 }
427
428 #[test]
429 fn command_light_does_not_mark_stats() {
430 let stats = Cli::try_parse_from(["sqlite-graphrag", "stats"]).expect("parse stats");
431 assert!(!stats.command.is_embedding_heavy());
432 }
433}
434
435impl MemoryType {
436 pub fn as_str(&self) -> &'static str {
437 match self {
438 Self::User => "user",
439 Self::Feedback => "feedback",
440 Self::Project => "project",
441 Self::Reference => "reference",
442 Self::Decision => "decision",
443 Self::Incident => "incident",
444 Self::Skill => "skill",
445 Self::Document => "document",
446 Self::Note => "note",
447 }
448 }
449}