Skip to main content

sqlite_graphrag/
cli.rs

1//! CLI argument structs and command surface (clap-based).
2//!
3//! Defines `Cli` and all subcommand enums; contains no business logic.
4
5use crate::commands::*;
6use crate::i18n::{current, Language};
7use clap::{Parser, Subcommand};
8
9/// Returns the maximum simultaneous invocations allowed by the CPU heuristic.
10fn max_concurrency_ceiling() -> usize {
11    std::thread::available_parallelism()
12        .map(|n| n.get() * 2)
13        .unwrap_or(8)
14}
15
16/// Graph export format.
17#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
18pub enum GraphExportFormat {
19    /// JSON variant.
20    Json,
21    /// DOT variant.
22    Dot,
23    /// Mermaid variant.
24    Mermaid,
25    /// Stream one JSON object per entity, then one per edge, then a summary line.
26    Ndjson,
27}
28
29// Backend choice enums live in `backend_choice` (Wave C1).
30pub use crate::backend_choice::{EmbeddingBackendChoice, LlmBackendChoice};
31
32#[derive(Parser)]
33#[command(name = "sqlite-graphrag")]
34#[command(version)]
35#[command(about = "Local GraphRAG memory for LLMs in a single SQLite file")]
36#[command(arg_required_else_help = true)]
37#[command(after_help = "DATABASE PATH (GAP-SG-32):\n  \
38    `--db` is a PER-SUBCOMMAND flag, so it must come AFTER the subcommand:\n    \
39    sqlite-graphrag remember --db ./graphrag.sqlite --name mem --type note ...\n  \
40    Placing it before the subcommand (e.g. `sqlite-graphrag --db x.sqlite remember`) is rejected.\n  \
41    Prefer `--db` on every invocation (one-shot agents). Optional XDG defaults:\n    \
42    `sqlite-graphrag config set db.path ./graphrag.sqlite`\n  \
43    Product environment variables are not read at runtime; use flags + `config set/get`.")]
44/// CLI.
45pub struct Cli {
46    /// Maximum number of simultaneous CLI invocations allowed (default: 4).
47    ///
48    /// Caps the counting semaphore used for CLI concurrency slots. The value must
49    /// stay within [1, 2×nCPUs]. Values above the ceiling are rejected with exit 2.
50    #[arg(long, global = true, value_name = "N")]
51    pub max_concurrency: Option<usize>,
52
53    /// Wait up to SECONDS for a free concurrency slot before giving up (exit 75).
54    ///
55    /// Useful in retrying agent pipelines: the process polls every 500 ms until a
56    /// slot opens or the timeout expires. Default: 300s (5 minutes).
57    #[arg(long, global = true, value_name = "SECONDS")]
58    pub wait_lock: Option<u64>,
59
60    /// Skip the available-memory check before loading the model.
61    ///
62    /// Exclusive use in automated tests where real allocation does not occur.
63    #[arg(long, global = true, hide = true, default_value_t = false)]
64    pub skip_memory_guard: bool,
65
66    /// v1.0.83 (ADR-0041): strict env-clear mode for compliance environments.
67    ///
68    /// When enabled, the LLM subprocess receives ONLY `PATH` — no
69    /// `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_BASE_URL`, `OPENAI_BASE_URL`
70    /// or other custom-provider credentials are forwarded. Defaults to
71    /// the standard v1.0.83 whitelist that preserves custom-provider
72    /// credentials (ADR-0041). Prefer the flag; optional XDG
73    /// `spawn.strict_env_clear=1` via `config set`.
74    #[arg(
75        long,
76        global = true,
77        hide = true,
78        default_value_t = false,
79        value_parser = clap::builder::BoolishValueParser::new(),
80            )]
81    pub strict_env_clear: bool,
82
83    /// v1.0.84 (ADR-0042 / GAP-002): resolve and print the LLM backend that
84    /// WOULD be invoked for embedding (binary path + model + flavour),
85    /// then exit 0 without executing the subprocess. Useful for CI
86    /// audit and sanity-check of `--llm-backend` before long sessions.
87    ///
88    /// Prefer the flag; optional XDG `llm.dry_run_backend=1` via `config set`.
89    #[arg(
90        long,
91        global = true,
92        hide = true,
93        default_value_t = false,
94        value_parser = clap::builder::BoolishValueParser::new(),
95            )]
96    pub dry_run_backend: bool,
97
98    /// Language for human-facing stderr messages. Accepts `en` or `pt`.
99    ///
100    /// Without the flag, detection uses XDG `i18n.lang` then OS locale
101    /// (`LC_ALL`/`LC_MESSAGES`/`LANG`). JSON stdout stays deterministic and
102    /// identical across languages; only human-facing strings are affected.
103    #[arg(long, global = true, value_enum, value_name = "LANG")]
104    pub lang: Option<crate::i18n::Language>,
105
106    /// Time zone for `*_iso` fields in JSON output (for example `America/Sao_Paulo`).
107    ///
108    /// Accepts any IANA time zone name. Without the flag, it falls back to
109    /// XDG `display.tz`; if unset, UTC is used. Integer epoch fields
110    /// are not affected.
111    #[arg(long, global = true, value_name = "IANA")]
112    pub tz: Option<chrono_tz::Tz>,
113
114    /// Directory holding `config.toml`. Overrides the OS config directory.
115    ///
116    /// Precedence (G-T-XDG-04): this flag > OS default. It deliberately does
117    /// NOT consult a `config set` key, because the config file itself lives in
118    /// this directory and reading it to find itself would be circular.
119    /// Hidden: it exists for hermetic test isolation and sandboxed hosts.
120    #[arg(long, global = true, hide = true, value_name = "DIR")]
121    pub config_dir: Option<std::path::PathBuf>,
122
123    /// Directory for lock files, model files and other cache artifacts.
124    ///
125    /// Precedence (G-T-XDG-04): this flag > XDG `cache.dir` > OS default.
126    /// Hidden for the same reason as `--config-dir`.
127    #[arg(long, global = true, hide = true, value_name = "DIR")]
128    pub cache_dir: Option<std::path::PathBuf>,
129
130    /// Increase logging verbosity (-v=info, -vv=debug, -vvv=trace).
131    ///
132    /// Overrides XDG `log.level` when present. Logs are emitted
133    /// to stderr; JSON stdout is unaffected.
134    #[arg(short = 'v', long, global = true, action = clap::ArgAction::Count)]
135    pub verbose: u8,
136
137    /// Suppress non-error tracing on stderr (sets log level to `error`).
138    ///
139    /// Prefer this in pipelines that capture stdout JSON (`> out.json`).
140    /// Never combine stdout and stderr into the same file (`&>` / `2>&1`) —
141    /// that contaminates the JSON envelope (v1.1.05 Bug 2). Conflicts with
142    /// `-v` / `--verbose` only in spirit: quiet wins when both are present.
143    #[arg(short = 'q', long, global = true, default_value_t = false)]
144    pub quiet: bool,
145
146    /// v1.0.75 (G21 solution): extraction backend selector. Accepts
147    /// `llm` (default), `embedding` (legacy), `none`, or `both` (composite).
148    /// The `llm` backend invokes claude code / codex CLI headless to extract
149    /// entities and relationships; `embedding` is a permanent stub since
150    /// v1.0.79 (legacy fastembed pipeline removed) that returns a clear
151    /// migration error.
152    #[arg(long, global = true, value_name = "KIND", default_value = "llm")]
153    pub extraction_backend: Option<String>,
154
155    /// Embedding dimensionality override (default 1024 since v1.2.0).
156    ///
157    /// Precedence: this flag > XDG `embedding.dim` >
158    /// the `dim` recorded in the database `schema_meta` > 1024. Existing
159    /// databases keep their recorded dimensionality automatically; use
160    /// this flag only to migrate a corpus to a new dimensionality
161    /// (followed by `enrich --operation re-embed`). Range: [8, 4096].
162    #[arg(long, global = true, value_name = "N", value_parser = clap::value_parser!(u64).range(8..=4096))]
163    pub embedding_dim: Option<u64>,
164
165    /// v1.0.82 (GAP-003) / v1.0.84 (ADR-0042): LLM backend for embedding.
166    /// Accepts `auto` (detects via PATH, codex-first), `codex` (forces
167    /// `codex exec`), `claude` (forces `claude -p`; since v1.0.84 does NOT fall back to
168    /// codex — emits `AppError::Validation` if `claude` is absent),
169    /// `opencode` (forces `opencode run`), or `none`
170    /// (skips embedding; useful for tests). Prefer the flag; optional XDG
171    /// XDG `llm.backend` via `config set`.
172    #[arg(long, global = true, value_enum, default_value_t = LlmBackendChoice::Auto)]
173    pub llm_backend: LlmBackendChoice,
174
175    /// v1.0.82 (GAP-003): model to invoke on the chosen backend.
176    /// Prefer the flag; optional XDG `llm.model`. The default depends
177    /// on the backend (codex: `gpt-5.5`; claude: `claude-sonnet-4-6`).
178    #[arg(
179        long,
180        global = true,
181        value_name = "MODEL",
182            )]
183    pub llm_model: Option<String>,
184
185    /// v1.0.82 (GAP-003): path to the `claude` binary (overrides
186    /// PATH detection). Prefer the flag; optional XDG `llm.claude_binary`.
187    #[arg(
188        long,
189        global = true,
190        value_name = "PATH",
191            )]
192    pub claude_binary: Option<std::path::PathBuf>,
193
194    /// v1.0.89 (GAP-1): path to the `codex` binary (overrides
195    /// PATH detection). Prefer the flag; optional XDG `llm.codex_binary`.
196    #[arg(
197        long,
198        global = true,
199        value_name = "PATH",
200            )]
201    pub codex_binary: Option<std::path::PathBuf>,
202
203    /// v1.0.90 (GAP-OPENCODE-001): path to the `opencode` binary (overrides
204    /// PATH detection). Prefer the flag; optional XDG `llm.opencode_binary`.
205    #[arg(
206        long,
207        global = true,
208        value_name = "PATH",
209            )]
210    pub opencode_binary: Option<std::path::PathBuf>,
211
212    /// v1.0.82 (GAP-005): chain of LLM backends tried in order
213    /// when the primary fails. Default `codex,claude,none`. Prefer the
214    /// flag; optional XDG `llm.fallback`.
215    #[arg(
216        long,
217        global = true,
218        default_value = "codex,claude,none",
219            )]
220    pub llm_fallback: String,
221
222    /// v1.0.82 (GAP-005): persists with a NULL embedding when all
223    /// backends in the chain fail. The memory stays in `pending_embeddings`
224    /// for reprocessing via `embedding retry`. Prefer the flag; optional XDG
225    /// XDG `llm.skip_embedding_on_failure`.
226    #[arg(
227        long,
228        global = true,
229        default_value_t = false,
230        value_parser = clap::builder::BoolishValueParser::new(),
231            )]
232    pub skip_embedding_on_failure: bool,
233
234    /// v1.0.82 (GAP-004): host-wide limit of concurrent LLM
235    /// subprocesses. Default derived from `ncpus`. Prefer the flag; optional XDG
236    /// XDG `llm.max_host_concurrency`.
237    #[arg(
238        long,
239        global = true,
240        value_name = "N",
241            )]
242    pub llm_max_host_concurrency: Option<u32>,
243
244    /// v1.0.82 (GAP-004): seconds to wait for a free LLM slot
245    /// before failing with exit 75. Default 30s. Prefer the flag; optional XDG
246    /// XDG `llm.slot_wait_secs`.
247    #[arg(
248        long,
249        global = true,
250        value_name = "SECONDS",
251            )]
252    pub llm_slot_wait_secs: Option<u64>,
253
254    /// v1.0.82 (GAP-004): if set, fails immediately (exit 75)
255    /// when no LLM slot is free. Prefer the flag; optional XDG
256    /// XDG `llm.slot_no_wait`.
257    #[arg(
258        long,
259        global = true,
260        default_value_t = false,
261        value_parser = clap::builder::BoolishValueParser::new(),
262            )]
263    pub llm_slot_no_wait: bool,
264
265    /// v1.0.93: embedding backend selector. `auto` tries OpenRouter API if key
266    /// available, falls back to LLM subprocess. `openrouter` requires API key.
267    /// `llm` forces subprocess. Prefer the flag; optional XDG `embedding.backend`.
268    #[arg(long, global = true, value_enum, default_value_t = EmbeddingBackendChoice::Auto)]
269    pub embedding_backend: EmbeddingBackendChoice,
270
271    /// v1.0.93: embedding model for the OpenRouter API. Required when
272    /// `--embedding-backend openrouter`. Prefer the flag; optional XDG `embedding.model`.
273    #[arg(
274        long,
275        global = true,
276        value_name = "MODEL",
277            )]
278    pub embedding_model: Option<String>,
279
280    /// v1.0.93: OpenRouter API key (prefer env var or config.toml over CLI flag
281    /// to avoid shell history exposure). Prefer `config set-key openrouter`.
282    #[arg(
283        long,
284        global = true,
285        value_name = "KEY",
286        hide = true,
287                hide_env_values = true
288    )]
289    pub openrouter_api_key: Option<String>,
290
291    /// Subcommand to execute.
292    #[command(subcommand)]
293    pub command: Option<Commands>,
294}
295
296#[cfg(test)]
297#[path = "cli_json_only_format_tests.rs"]
298mod json_only_format_tests;
299
300
301impl Cli {
302    /// Validates concurrency flags and returns a localised descriptive error if invalid.
303    ///
304    /// Requires that `crate::i18n::init()` has already been called (happens before this
305    /// function in the `main` flow). In English it emits EN messages; in Portuguese it emits PT.
306    pub fn validate_flags(&self) -> Result<(), String> {
307        if let Some(n) = self.max_concurrency {
308            if n == 0 {
309                return Err(match current() {
310                    Language::English => "--max-concurrency must be >= 1".to_string(),
311                    Language::Portuguese => "--max-concurrency deve ser >= 1".to_string(),
312                });
313            }
314            let teto = max_concurrency_ceiling();
315            if n > teto {
316                return Err(match current() {
317                    Language::English => format!(
318                        "--max-concurrency {n} exceeds the ceiling of {teto} (2×nCPUs) on this system"
319                    ),
320                    Language::Portuguese => format!(
321                        "--max-concurrency {n} excede o teto de {teto} (2×nCPUs) neste sistema"
322                    ),
323                });
324            }
325        }
326        Ok(())
327    }
328}
329
330impl Commands {
331    /// Returns true for subcommands that load the ONNX model locally.
332    pub fn is_embedding_heavy(&self) -> bool {
333        matches!(
334            self,
335            Self::Init(_)
336                | Self::Remember(_)
337                | Self::RememberBatch(_)
338                | Self::Recall(_)
339                | Self::HybridSearch(_)
340                | Self::DeepResearch(_)
341        )
342    }
343
344    /// Return whether this command occupies a CLI concurrency slot.
345    pub fn uses_cli_slot(&self) -> bool {
346        true
347    }
348
349    /// Read-only / no-embedding subcommands that MUST run without an embedding
350    /// API key. `init` warms a best-effort smoke test internally and degrades to
351    /// `ok_no_embedding` when the backend is unreachable; the `enrich` queue
352    /// inspectors (`--status` / `--list-dead` / `--requeue-dead` /
353    /// `--prune-dead-orphans`) never embed and never call the LLM. The eager
354    /// OpenRouter key preflight in `main` must skip its hard-fail for these.
355    pub fn tolerates_missing_embedding_key(&self) -> bool {
356        match self {
357            Self::Init(_) => true,
358            Self::Enrich(args) => {
359                args.status
360                    || args.list_dead
361                    || args.requeue_dead
362                    || args.list_skipped
363                    || args.requeue_skipped
364                    || args.prune_dead_orphans
365                    || args.prune_dead_entity_orphans
366                    || args.print_schema
367            }
368            _ => false,
369        }
370    }
371}
372
373/// GAP-E2E-010 (v1.0.89): `codex-models` accepts `--json` as a no-op so
374/// agents that append `--json` to every subcommand never see clap errors.
375/// The handler in `main.rs` always emits JSON on stdout; this flag is
376/// accepted and ignored for parity with the rest of the CLI surface.
377///
378/// GAP-SG-139: also accepts `--db` as a no-op for agent uniformity (host surface).
379#[derive(Debug, clap::Args)]
380pub struct CodexModelsArgs {
381    /// No-op; JSON is always emitted on stdout by `codex-models`.
382    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
383    pub json: bool,
384    /// GAP-SG-139: accepted as a no-op for agent uniformity (no graph I/O).
385    #[command(flatten)]
386    pub db_noop: crate::cli_db_noop::DbNoopArgs,
387}
388
389/// Commands.
390#[derive(Subcommand)]
391pub enum Commands {
392    /// Initialize database and download embedding model
393    #[command(after_long_help = "EXAMPLES:\n  \
394        # Initialize in current directory (default behavior)\n  \
395        sqlite-graphrag init\n\n  \
396        # Initialize at a specific path\n  \
397        sqlite-graphrag init --db /path/to/graphrag.sqlite\n\n  \
398        # Persist default db path via XDG config (no product env)\n  \
399        sqlite-graphrag config set db.path /data/graphrag.sqlite\n  \
400        sqlite-graphrag init\n\n\
401        NOTES:\n  \
402        - `init` is OPTIONAL: any subsequent CRUD command auto-initializes graphrag.sqlite if missing.\n  \
403        - As a side effect, `init` warms a smoke-test embedding via the LLM-only one-shot pipeline.")]
404    Init(init::InitArgs),
405    /// Save a memory with optional entity graph
406    #[command(after_long_help = "EXAMPLES:\n  \
407        # Inline body\n  \
408        sqlite-graphrag remember --name onboarding --type user --description \"intro\" --body \"hello\"\n\n  \
409        # Body from file\n  \
410        sqlite-graphrag remember --name doc1 --type document --description \"...\" --body-file ./README.md\n\n  \
411        # Body from stdin (pipe)\n  \
412        cat README.md | sqlite-graphrag remember --name doc1 --type document --description \"...\" --body-stdin\n\n  \
413        # Enable automatic URL extraction (URL-regex only since v1.0.79)\n  \
414        sqlite-graphrag remember --name rich --type note --description \"...\" --body \"...\" --enable-ner")]
415    Remember(remember::RememberArgs),
416    /// Batch-create memories from NDJSON stdin (one invocation, one slot)
417    #[command(after_long_help = "EXAMPLES:\n  \
418        # Batch create from NDJSON\n  \
419        cat memories.ndjson | sqlite-graphrag remember-batch --force-merge --json\n\n  \
420        # Atomic batch\n  \
421        cat memories.ndjson | sqlite-graphrag remember-batch --transaction --json")]
422    RememberBatch(remember_batch::RememberBatchArgs),
423    /// Bulk-ingest every file under a directory as separate memories (NDJSON output)
424    Ingest(Box<ingest::IngestArgs>),
425    /// Search memories semantically
426    #[command(after_long_help = "EXAMPLES:\n  \
427        # Top 10 semantic matches (default)\n  \
428        sqlite-graphrag recall \"agent memory\"\n\n  \
429        # Top 3 only\n  \
430        sqlite-graphrag recall \"agent memory\" -k 3\n\n  \
431        # Search across all namespaces\n  \
432        sqlite-graphrag recall \"agent memory\" --all-namespaces\n\n  \
433        # Disable graph traversal (vector-only)\n  \
434        sqlite-graphrag recall \"agent memory\" --no-graph")]
435    Recall(recall::RecallArgs),
436    /// Read a memory by exact name
437    Read(read::ReadArgs),
438    /// List memories with filters
439    List(list::ListArgs),
440    /// Soft-delete a memory
441    Forget(forget::ForgetArgs),
442    /// Permanently delete soft-deleted memories
443    Purge(purge::PurgeArgs),
444    /// Rename a memory preserving history
445    Rename(rename::RenameArgs),
446    /// Split an oversized memory body into N child memories (v1.1.03, GAP-V8)
447    SplitBody(split_body::SplitBodyArgs),
448    /// Edit a memory's body or description
449    Edit(edit::EditArgs),
450    /// List all versions of a memory
451    History(history::HistoryArgs),
452    /// Restore a memory to a previous version
453    Restore(restore::RestoreArgs),
454    /// Search using hybrid vector + full-text search
455    #[command(after_long_help = "EXAMPLES:\n  \
456        # Hybrid search combining KNN + FTS5 BM25 with RRF\n  \
457        sqlite-graphrag hybrid-search \"agent memory architecture\"\n\n  \
458        # Custom weights for vector vs full-text components\n  \
459        sqlite-graphrag hybrid-search \"agent\" --weight-vec 0.7 --weight-fts 0.3")]
460    HybridSearch(hybrid_search::HybridSearchArgs),
461    /// Show database health
462    Health(health::HealthArgs),
463    /// Apply pending schema migrations
464    Migrate(migrate::MigrateArgs),
465    /// Resolve namespace precedence for the current invocation
466    NamespaceDetect(namespace_detect::NamespaceDetectArgs),
467    /// Run PRAGMA optimize on the database
468    Optimize(optimize::OptimizeArgs),
469    /// Show database statistics
470    Stats(stats::StatsArgs),
471    /// Create a checkpointed copy safe for file sync
472    SyncSafeCopy(sync_safe_copy::SyncSafeCopyArgs),
473    /// Back up the database using the SQLite Online Backup API
474    Backup(backup::BackupArgs),
475    /// Run VACUUM after checkpointing the WAL
476    Vacuum(vacuum::VacuumArgs),
477    /// Create an explicit relationship between two entities
478    Link(link::LinkArgs),
479    /// Remove a specific relationship between two entities
480    Unlink(unlink::UnlinkArgs),
481    /// Deep parallel multi-hop GraphRAG research
482    #[command(name = "deep-research")]
483    DeepResearch(deep_research::DeepResearchArgs),
484    /// List memories connected via the entity graph
485    Related(related::RelatedArgs),
486    /// Export a graph snapshot in json, dot or mermaid
487    Graph(graph_export::GraphArgs),
488    /// Export memories as NDJSON (one JSON line per memory, plus a summary line)
489    Export(export::ExportArgs),
490    /// FTS5 full-text search index management (rebuild or check)
491    Fts(fts::FtsArgs),
492    /// Vector index maintenance (orphan detection, purge, stats) — G39
493    Vec(vec::VecArgs),
494    /// List codex OAuth models accepted by ChatGPT Pro (G33).
495    ///
496    /// GAP-E2E-010 (v1.0.89): accepts `--json` as a no-op (JSON is always
497    /// emitted on stdout) so the flag never breaks agent pipelines that
498    /// append `--json` to every invocation.
499    #[command(name = "codex-models")]
500    CodexModels(CodexModelsArgs),
501    /// Bulk-delete all relationships of a given type (e.g. mentions)
502    PruneRelations(prune_relations::PruneRelationsArgs),
503    /// Remove NER bindings (memory_entities rows) for an entity or all entities
504    #[command(name = "prune-ner")]
505    PruneNer(prune_ner::PruneNerArgs),
506    /// Inspect and manage cross-process LLM slot semaphore (GAP-004, v1.0.82)
507    Slots(slots::SlotsArgs),
508    /// Inspect and manage the `remember` checkpoint queue (GAP-001, v1.0.82)
509    Pending(pending::PendingArgs),
510    /// Health and per-entry inspection of the pending-embeddings queue (GAP-005, v1.0.82)
511    Embedding(embedding::EmbeddingArgs),
512    /// Batch operations over the pending-embeddings queue (GAP-005, v1.0.82)
513    #[command(name = "pending-embeddings")]
514    PendingEmbeddings(pending_embeddings::PendingEmbeddingsArgs),
515    /// Remove entities that have no memories and no relationships
516    CleanupOrphans(cleanup_orphans::CleanupOrphansArgs),
517    /// List entities linked to a specific memory
518    MemoryEntities(memory_entities::MemoryEntitiesArgs),
519    /// Manage cached resources (embedding models, etc.)
520    Cache(cache::CacheArgs),
521    /// Delete an entity and all its relationships from the graph
522    #[command(name = "delete-entity")]
523    DeleteEntity(delete_entity::DeleteEntityArgs),
524    /// Reclassify one entity or a batch of entities to a new type
525    Reclassify(reclassify::ReclassifyArgs),
526    /// Rename an entity preserving all relationships and memory bindings
527    #[command(name = "rename-entity")]
528    RenameEntity(rename_entity::RenameEntityArgs),
529    /// Merge multiple source entities into a single target entity
530    #[command(name = "merge-entities")]
531    MergeEntities(merge_entities::MergeEntitiesArgs),
532    /// Enrich graph memories and entities using an LLM provider
533    Enrich(Box<enrich::EnrichArgs>),
534    /// Reclassify relationship types across the graph using rules or LLM judgment
535    #[command(name = "reclassify-relation")]
536    ReclassifyRelation(reclassify_relation::ReclassifyRelationArgs),
537    /// Normalize entity names (deduplicate, kebab-case, merge near-duplicates)
538    #[command(name = "normalize-entities")]
539    NormalizeEntities(normalize_entities::NormalizeEntitiesArgs),
540    /// Generate shell completions for Bash, Zsh, Fish, PowerShell, or Elvish
541    Completions(completions::CompletionsArgs),
542    /// `debug-schema` subcommand.
543    #[command(name = "debug-schema", hide = true)]
544    DebugSchema(debug_schema::DebugSchemaArgs),
545    /// Manage API keys and diagnose provider configuration (v1.0.93)
546    Config(config_cmd::ConfigArgs),
547}
548// FIX-1 (v1.0.89): manual `Debug` impl so test panic messages that print
549// `{:?}` on a captured `Commands` variant compile without requiring every
550// contained subcommand arg struct to derive `Debug`. The Debug output is
551// only used in test assertions for diagnostic messages; we emit the variant
552// name only — arg payload is intentionally omitted.
553impl std::fmt::Debug for Commands {
554    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
555        let name = match self {
556            Self::Init(_) => "Init",
557            Self::Health(_) => "Health",
558            Self::Stats(_) => "Stats",
559            Self::List(_) => "List",
560            Self::Read(_) => "Read",
561            Self::Edit(_) => "Edit",
562            Self::Rename(_) => "Rename",
563            Self::SplitBody(_) => "SplitBody",
564            Self::Restore(_) => "Restore",
565            Self::History(_) => "History",
566            Self::Forget(_) => "Forget",
567            Self::Purge(_) => "Purge",
568            Self::Remember(_) => "Remember",
569            Self::RememberBatch(_) => "RememberBatch",
570            Self::Recall(_) => "Recall",
571            Self::HybridSearch(_) => "HybridSearch",
572            Self::Enrich(_) => "Enrich",
573            Self::Ingest(_) => "Ingest",
574            Self::Optimize(_) => "Optimize",
575            Self::Migrate(_) => "Migrate",
576            Self::SyncSafeCopy(_) => "SyncSafeCopy",
577            Self::Backup(_) => "Backup",
578            Self::Vacuum(_) => "Vacuum",
579            Self::Link(_) => "Link",
580            Self::Unlink(_) => "Unlink",
581            Self::DeepResearch(_) => "DeepResearch",
582            Self::Related(_) => "Related",
583            Self::Graph(_) => "Graph",
584            Self::Export(_) => "Export",
585            Self::Fts(_) => "Fts",
586            Self::Vec(_) => "Vec",
587            Self::CodexModels(_) => "CodexModels",
588            Self::PruneRelations(_) => "PruneRelations",
589            Self::PruneNer(_) => "PruneNer",
590            Self::Slots(_) => "Slots",
591            Self::Pending(_) => "Pending",
592            Self::Embedding(_) => "Embedding",
593            Self::PendingEmbeddings(_) => "PendingEmbeddings",
594            Self::CleanupOrphans(_) => "CleanupOrphans",
595            Self::MemoryEntities(_) => "MemoryEntities",
596            Self::Cache(_) => "Cache",
597            Self::DeleteEntity(_) => "DeleteEntity",
598            Self::Reclassify(_) => "Reclassify",
599            Self::RenameEntity(_) => "RenameEntity",
600            Self::ReclassifyRelation(_) => "ReclassifyRelation",
601            Self::NormalizeEntities(_) => "NormalizeEntities",
602            Self::MergeEntities(_) => "MergeEntities",
603            Self::NamespaceDetect(_) => "NamespaceDetect",
604            Self::Completions(_) => "Completions",
605            Self::DebugSchema(_) => "DebugSchema",
606            Self::Config(_) => "Config",
607        };
608        f.write_str(name)
609    }
610}
611
612/// Memory type.
613#[derive(Copy, Clone, Debug, Default, clap::ValueEnum)]
614pub enum MemoryType {
615    /// User variant.
616    User,
617    /// Feedback variant.
618    Feedback,
619    /// Project variant.
620    Project,
621    /// Reference variant.
622    Reference,
623    /// Decision variant.
624    Decision,
625    /// Incident variant.
626    Incident,
627    /// Skill variant.
628    Skill,
629    /// Document variant.
630    #[default]
631    Document,
632    /// Note variant.
633    Note,
634}
635
636#[cfg(test)]
637#[path = "cli_heavy_concurrency_tests.rs"]
638mod heavy_concurrency_tests;
639
640
641impl MemoryType {
642    /// Return the canonical string representation.
643    pub fn as_str(&self) -> &'static str {
644        match self {
645            Self::User => "user",
646            Self::Feedback => "feedback",
647            Self::Project => "project",
648            Self::Reference => "reference",
649            Self::Decision => "decision",
650            Self::Incident => "incident",
651            Self::Skill => "skill",
652            Self::Document => "document",
653            Self::Note => "note",
654        }
655    }
656}
657
658/// GAP-SG-31/33/34/35/30: parse-time contracts for the Fase G clap fixes.
659#[cfg(test)]
660#[path = "cli_fase_g_parsing_tests.rs"]
661mod fase_g_parsing_tests;