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(_) | Self::Remember(_) | Self::Recall(_) | Self::HybridSearch(_)
243 )
244 }
245
246 pub fn uses_cli_slot(&self) -> bool {
247 !matches!(self, Self::Daemon(_))
248 }
249}
250
251#[derive(Subcommand)]
252pub enum Commands {
253 #[command(after_long_help = "EXAMPLES:\n \
255 # Initialize in current directory (default behavior)\n \
256 sqlite-graphrag init\n\n \
257 # Initialize at a specific path\n \
258 sqlite-graphrag init --db /path/to/graphrag.sqlite\n\n \
259 # Initialize using SQLITE_GRAPHRAG_HOME env var\n \
260 SQLITE_GRAPHRAG_HOME=/data sqlite-graphrag init\n\n\
261 NOTES:\n \
262 - `init` is OPTIONAL: any subsequent CRUD command auto-initializes graphrag.sqlite if missing.\n \
263 - As a side effect, `init` warms a smoke-test embedding which auto-spawns the persistent daemon (~600s idle timeout).")]
264 Init(init::InitArgs),
265 Daemon(daemon::DaemonArgs),
267 #[command(after_long_help = "EXAMPLES:\n \
269 # Inline body\n \
270 sqlite-graphrag remember --name onboarding --type user --description \"intro\" --body \"hello\"\n\n \
271 # Body from file\n \
272 sqlite-graphrag remember --name doc1 --type document --description \"...\" --body-file ./README.md\n\n \
273 # Body from stdin (pipe)\n \
274 cat README.md | sqlite-graphrag remember --name doc1 --type document --description \"...\" --body-stdin\n\n \
275 # Enable GLiNER entity extraction (disabled by default)\n \
276 sqlite-graphrag remember --name rich --type note --description \"...\" --body \"...\" --enable-ner")]
277 Remember(remember::RememberArgs),
278 Ingest(ingest::IngestArgs),
280 #[command(after_long_help = "EXAMPLES:\n \
282 # Top 10 semantic matches (default)\n \
283 sqlite-graphrag recall \"agent memory\"\n\n \
284 # Top 3 only\n \
285 sqlite-graphrag recall \"agent memory\" -k 3\n\n \
286 # Search across all namespaces\n \
287 sqlite-graphrag recall \"agent memory\" --all-namespaces\n\n \
288 # Disable graph traversal (vector-only)\n \
289 sqlite-graphrag recall \"agent memory\" --no-graph")]
290 Recall(recall::RecallArgs),
291 Read(read::ReadArgs),
293 List(list::ListArgs),
295 Forget(forget::ForgetArgs),
297 Purge(purge::PurgeArgs),
299 Rename(rename::RenameArgs),
301 Edit(edit::EditArgs),
303 History(history::HistoryArgs),
305 Restore(restore::RestoreArgs),
307 #[command(after_long_help = "EXAMPLES:\n \
309 # Hybrid search combining KNN + FTS5 BM25 with RRF\n \
310 sqlite-graphrag hybrid-search \"agent memory architecture\"\n\n \
311 # Custom weights for vector vs full-text components\n \
312 sqlite-graphrag hybrid-search \"agent\" --weight-vec 0.7 --weight-fts 0.3")]
313 HybridSearch(hybrid_search::HybridSearchArgs),
314 Health(health::HealthArgs),
316 Migrate(migrate::MigrateArgs),
318 NamespaceDetect(namespace_detect::NamespaceDetectArgs),
320 Optimize(optimize::OptimizeArgs),
322 Stats(stats::StatsArgs),
324 SyncSafeCopy(sync_safe_copy::SyncSafeCopyArgs),
326 Backup(backup::BackupArgs),
328 Vacuum(vacuum::VacuumArgs),
330 Link(link::LinkArgs),
332 Unlink(unlink::UnlinkArgs),
334 Related(related::RelatedArgs),
336 Graph(graph_export::GraphArgs),
338 Export(export::ExportArgs),
340 Fts(fts::FtsArgs),
342 PruneRelations(prune_relations::PruneRelationsArgs),
344 #[command(name = "prune-ner")]
346 PruneNer(prune_ner::PruneNerArgs),
347 CleanupOrphans(cleanup_orphans::CleanupOrphansArgs),
349 MemoryEntities(memory_entities::MemoryEntitiesArgs),
351 Cache(cache::CacheArgs),
353 #[command(name = "delete-entity")]
355 DeleteEntity(delete_entity::DeleteEntityArgs),
356 Reclassify(reclassify::ReclassifyArgs),
358 #[command(name = "rename-entity")]
360 RenameEntity(rename_entity::RenameEntityArgs),
361 #[command(name = "merge-entities")]
363 MergeEntities(merge_entities::MergeEntitiesArgs),
364 #[command(name = "__debug_schema", hide = true)]
365 DebugSchema(debug_schema::DebugSchemaArgs),
366}
367
368#[derive(Copy, Clone, Debug, Default, clap::ValueEnum)]
369pub enum MemoryType {
370 User,
371 Feedback,
372 Project,
373 Reference,
374 Decision,
375 Incident,
376 Skill,
377 #[default]
378 Document,
379 Note,
380}
381
382#[cfg(test)]
383mod heavy_concurrency_tests {
384 use super::*;
385
386 #[test]
387 fn command_heavy_detects_init_and_embeddings() {
388 let init = Cli::try_parse_from(["sqlite-graphrag", "init"]).expect("parse init");
389 assert!(init.command.is_embedding_heavy());
390
391 let remember = Cli::try_parse_from([
392 "sqlite-graphrag",
393 "remember",
394 "--name",
395 "test-memory",
396 "--type",
397 "project",
398 "--description",
399 "desc",
400 ])
401 .expect("parse remember");
402 assert!(remember.command.is_embedding_heavy());
403
404 let recall =
405 Cli::try_parse_from(["sqlite-graphrag", "recall", "query"]).expect("parse recall");
406 assert!(recall.command.is_embedding_heavy());
407
408 let hybrid = Cli::try_parse_from(["sqlite-graphrag", "hybrid-search", "query"])
409 .expect("parse hybrid");
410 assert!(hybrid.command.is_embedding_heavy());
411 }
412
413 #[test]
414 fn command_light_does_not_mark_stats() {
415 let stats = Cli::try_parse_from(["sqlite-graphrag", "stats"]).expect("parse stats");
416 assert!(!stats.command.is_embedding_heavy());
417 }
418}
419
420impl MemoryType {
421 pub fn as_str(&self) -> &'static str {
422 match self {
423 Self::User => "user",
424 Self::Feedback => "feedback",
425 Self::Project => "project",
426 Self::Reference => "reference",
427 Self::Decision => "decision",
428 Self::Incident => "incident",
429 Self::Skill => "skill",
430 Self::Document => "document",
431 Self::Note => "note",
432 }
433 }
434}