pub mod commands;
pub(crate) mod daemon_spawn;
pub(crate) mod http_client;
use crate::config;
use crate::config::RepoArgs;
use crate::error::StartupError;
use anyhow::Result;
use clap::error::ErrorKind;
use clap::{
parser::ValueSource, ArgAction, Args, CommandFactory, FromArgMatches, Parser, Subcommand,
ValueEnum,
};
use serde_json::json;
use std::env;
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(
name = "docdexd",
version,
about = "Local documentation index/search daemon",
long_about = "Docdex indexes plain-text/markdown documentation under a workspace and serves top-k search/snippet results over HTTP or CLI. Defaults store data under ~/.docdex/state (scoped as repos/<repo_id>/index) and avoid common tool caches; override paths and exclusions with --state-dir/--exclude-* or matching env vars. The daemon exposes a shared MCP HTTP/SSE endpoint (e.g., /v1/mcp/sse); agents should connect to the HTTP/SSE endpoint directly."
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(ValueEnum, Clone, Copy, Debug)]
#[value(rename_all = "kebab-case")]
pub(crate) enum CliDiffMode {
#[value(alias = "working_tree")]
WorkingTree,
Staged,
Range,
}
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
#[value(rename_all = "kebab-case")]
pub(crate) enum CliMcpIpcMode {
Auto,
Off,
}
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
#[value(rename_all = "kebab-case")]
pub(crate) enum McpAddTransport {
Http,
Ipc,
}
#[derive(Args, Debug, Clone)]
pub(crate) struct ServeArgs {
#[command(flatten)]
pub repo: RepoArgs,
#[arg(skip)]
pub repo_explicit: bool,
#[arg(
long,
value_parser = config::non_empty_string,
help = "Bind host (defaults to server.http_bind_addr in config)"
)]
pub host: Option<String>,
#[arg(long, help = "Bind port (defaults to server.http_bind_addr in config)")]
pub port: Option<u16>,
#[arg(
long,
env = "DOCDEX_EXPOSE",
default_value_t = false,
action = ArgAction::SetTrue,
help = "Allow binding to non-loopback interfaces (requires --auth-token)"
)]
pub expose: bool,
#[arg(long, default_value = "info")]
pub log: String,
#[arg(
long,
env = "DOCDEX_TLS_CERT",
requires = "tls_key",
help = "TLS certificate PEM file for HTTPS (requires --tls-key)"
)]
pub tls_cert: Option<PathBuf>,
#[arg(
long,
env = "DOCDEX_TLS_KEY",
requires = "tls_cert",
help = "TLS private key PEM file for HTTPS (requires --tls-cert)"
)]
pub tls_key: Option<PathBuf>,
#[arg(
long,
env = "DOCDEX_CERTBOT_DOMAIN",
conflicts_with_all = ["tls_cert", "tls_key", "certbot_live_dir"],
help = "Use certbot live dir at /etc/letsencrypt/live/<domain> for TLS (implies HTTPS)"
)]
pub certbot_domain: Option<String>,
#[arg(
long,
env = "DOCDEX_CERTBOT_LIVE_DIR",
value_name = "PATH",
conflicts_with_all = ["tls_cert", "tls_key", "certbot_domain"],
help = "Use explicit certbot live dir containing fullchain.pem and privkey.pem (implies HTTPS)"
)]
pub certbot_live_dir: Option<PathBuf>,
#[arg(
long,
env = "DOCDEX_INSECURE_HTTP",
default_value_t = false,
help = "Allow plain HTTP on non-loopback binds (use only behind a trusted proxy)"
)]
pub insecure: bool,
#[arg(
long,
env = "DOCDEX_REQUIRE_TLS",
default_value_t = true,
action = ArgAction::Set,
help = "Require TLS for non-loopback binds (set to false when TLS is already terminated by a trusted proxy)"
)]
pub require_tls: bool,
#[arg(
long,
env = "DOCDEX_AUTH_TOKEN",
help = "Optional bearer token required on HTTP requests (Authorization: Bearer ...)"
)]
pub auth_token: Option<String>,
#[arg(
long,
env = "DOCDEX_PREFLIGHT_CHECK",
default_value_t = false,
action = ArgAction::Set,
help = "Run `docdexd check` before serving; fail fast on missing dependencies"
)]
pub preflight_check: bool,
#[arg(
long,
env = "DOCDEX_MAX_LIMIT",
default_value_t = 8,
help = "Maximum allowed `limit` on search/snippet requests"
)]
pub max_limit: usize,
#[arg(
long,
env = "DOCDEX_MAX_QUERY_BYTES",
default_value_t = 4096,
help = "Maximum allowed query string size in bytes"
)]
pub max_query_bytes: usize,
#[arg(
long,
env = "DOCDEX_MAX_REQUEST_BYTES",
default_value_t = 16384,
help = "Maximum allowed request size (Content-Length or body hint) in bytes"
)]
pub max_request_bytes: usize,
#[arg(
long,
env = "DOCDEX_RATE_LIMIT_PER_MIN",
default_value_t = 0u32,
help = "Optional per-IP request rate limit per minute (0 disables rate limiting; defaults on in secure mode)"
)]
pub rate_limit_per_min: u32,
#[arg(
long,
env = "DOCDEX_RATE_LIMIT_BURST",
default_value_t = 0u32,
help = "Optional burst size for rate limiting (defaults to per-minute limit when unset/0; defaults on in secure mode)"
)]
pub rate_limit_burst: u32,
#[arg(
long,
env = "DOCDEX_STRIP_SNIPPET_HTML",
default_value_t = false,
action = ArgAction::SetTrue,
help = "Omit snippet HTML in responses (serves text-only snippets)"
)]
pub strip_snippet_html: bool,
#[arg(
long,
env = "DOCDEX_SECURE_MODE",
default_value_t = true,
action = ArgAction::Set,
help = "Secure defaults: enable default rate limits (loopback-only access is enforced unless --expose)"
)]
pub secure_mode: bool,
#[arg(
long,
env = "DOCDEX_DISABLE_SNIPPET_TEXT",
default_value_t = false,
help = "Omit snippet text/html from responses (only doc metadata is returned)"
)]
pub disable_snippet_text: bool,
#[arg(
long,
env = "DOCDEX_ENABLE_MEMORY",
default_value_t = false,
value_parser = clap::builder::BoolishValueParser::new(),
action = ArgAction::Set,
help = "Enable repo-scoped memory endpoints (/v1/memory/store, /v1/memory/recall)"
)]
pub enable_memory: bool,
#[arg(
long,
env = "DOCDEX_AGENT_ID",
value_name = "AGENT_ID",
help = "Default agent id for profile memory (used when requests omit agent_id)"
)]
pub agent_id: Option<String>,
#[arg(
long,
env = "DOCDEX_ENABLE_MCP",
default_value_t = false,
value_parser = clap::builder::BoolishValueParser::new(),
action = ArgAction::Set,
num_args = 0..=1,
default_missing_value = "true",
help = "Enable MCP proxy auto-start (HTTP/SSE endpoint on the daemon)"
)]
pub enable_mcp: bool,
#[arg(
long,
env = "DOCDEX_DISABLE_MCP",
default_value_t = false,
value_parser = clap::builder::BoolishValueParser::new(),
action = ArgAction::Set,
num_args = 0..=1,
default_missing_value = "true",
help = "Disable MCP proxy auto-start"
)]
pub disable_mcp: bool,
#[arg(
long,
value_enum,
help = "MCP IPC transport mode (auto or off). Defaults to server.mcp_ipc_mode"
)]
pub mcp_ipc: Option<CliMcpIpcMode>,
#[arg(
long,
env = "DOCDEX_MCP_SOCKET_PATH",
value_name = "PATH",
help = "Unix socket path for MCP IPC (overrides server.mcp_socket_path)"
)]
pub mcp_socket_path: Option<PathBuf>,
#[arg(
long,
env = "DOCDEX_MCP_PIPE_NAME",
value_name = "NAME",
help = "Windows named pipe for MCP IPC (overrides server.mcp_pipe_name)"
)]
pub mcp_pipe_name: Option<String>,
#[arg(
long,
env = "DOCDEX_EMBEDDING_BASE_URL",
help = "Embedding base URL (preferred over --ollama-base-url)"
)]
pub embedding_base_url: Option<String>,
#[arg(
long,
env = "DOCDEX_OLLAMA_BASE_URL",
default_value = "http://127.0.0.1:11434",
help = "Legacy embedding base URL (deprecated; use --embedding-base-url)"
)]
pub ollama_base_url: String,
#[arg(
long,
env = "DOCDEX_EMBEDDING_MODEL",
default_value = "nomic-embed-text",
help = "Embedding model identifier"
)]
pub embedding_model: String,
#[arg(
long,
env = "DOCDEX_EMBEDDING_TIMEOUT_MS",
default_value_t = 0u64,
help = "Embedding timeout in milliseconds (0 disables)"
)]
pub embedding_timeout_ms: u64,
#[arg(
long,
env = "DOCDEX_ACCESS_LOG",
default_value_t = true,
action = ArgAction::Set,
help = "Enable structured access logs"
)]
pub access_log: bool,
#[arg(
long,
env = "DOCDEX_AUDIT_LOG_PATH",
value_name = "PATH",
help = "Audit log path (defaults to <state-dir>/audit.log)"
)]
pub audit_log_path: Option<PathBuf>,
#[arg(
long,
env = "DOCDEX_AUDIT_MAX_BYTES",
default_value_t = 5_000_000,
help = "Audit log max size before rotation"
)]
pub audit_max_bytes: u64,
#[arg(
long,
env = "DOCDEX_AUDIT_MAX_FILES",
default_value_t = 5,
help = "Audit log max rotated files"
)]
pub audit_max_files: u32,
#[arg(
long,
env = "DOCDEX_AUDIT_DISABLE",
default_value_t = false,
action = ArgAction::SetTrue,
help = "Disable audit logging"
)]
pub audit_disable: bool,
#[arg(long, env = "DOCDEX_RUN_AS_UID")]
pub run_as_uid: Option<u32>,
#[arg(long, env = "DOCDEX_RUN_AS_GID")]
pub run_as_gid: Option<u32>,
#[arg(long, env = "DOCDEX_CHROOT_DIR")]
pub chroot_dir: Option<PathBuf>,
#[arg(
long,
env = "DOCDEX_UNSHARE_NET",
default_value_t = false,
action = ArgAction::SetTrue,
help = "Unshare network namespace (Linux-only)"
)]
pub unshare_net: bool,
#[arg(
long,
env = "DOCDEX_ALLOW_IPS",
value_delimiter = ',',
help = "Comma-separated IPs/CIDRs allowed to access the API"
)]
pub allow_ip: Vec<String>,
}
pub(crate) fn cli_local_mode() -> bool {
match env::var("DOCDEX_CLI_LOCAL")
.ok()
.map(|v| v.trim().to_ascii_lowercase())
{
Some(value) if matches!(value.as_str(), "1" | "true" | "t" | "yes" | "y" | "on") => true,
_ => false,
}
}
#[derive(Subcommand, Debug)]
pub(crate) enum Command {
/// Validate config, state, and local dependencies for readiness.
Check,
/// Serve HTTP API for search/snippets.
Serve {
#[command(flatten)]
args: ServeArgs,
},
/// Run singleton daemon service (multi-repo).
Daemon {
#[command(flatten)]
args: ServeArgs,
},
/// Print help for all commands and flags.
HelpAll,
/// Manage browser discovery and setup.
Browser {
#[command(subcommand)]
command: BrowserCommand,
},
/// Scan the index for sensitive terms before enabling access.
SelfCheck {
#[command(flatten)]
repo: RepoArgs,
#[arg(
long,
value_delimiter = ',',
value_parser = config::non_empty_string,
help = "Comma-separated sensitive terms to scan for"
)]
terms: Vec<String>,
#[arg(
long,
default_value_t = 5,
help = "Max hits to return per term; reports if more exist"
)]
limit: usize,
#[arg(
long,
default_value_t = true,
action = ArgAction::Set,
help = "Include built-in sensitive patterns (tokens/keys/passwords) in the scan"
)]
include_default_patterns: bool,
},
/// Show hardware-aware LLM recommendations.
LlmList,
/// Delegation telemetry and savings.
Delegation {
#[command(subcommand)]
command: DelegationCommand,
},
/// Run the interactive setup wizard for Ollama and models.
#[command(visible_alias = "llm-setup")]
Setup {
#[command(flatten)]
args: SetupArgs,
},
/// Build or rebuild the entire index for a repo.
Index {
#[command(flatten)]
repo: RepoArgs,
#[arg(
long,
value_name = "PATH",
help = "Optional JSON file of libs sources to ingest during indexing"
)]
libs_sources: Option<PathBuf>,
},
/// Ingest a single document file (incremental update).
Ingest {
#[command(flatten)]
repo: RepoArgs,
#[arg(long)]
file: PathBuf,
},
/// Search repo docs/code (HTTP /search equivalent).
Search {
#[command(flatten)]
repo: RepoArgs,
#[arg(long, value_parser = config::non_empty_string, help = "Search query")]
query: String,
#[arg(long, default_value_t = crate::max_size::DEFAULT_SEARCH_LIMIT)]
limit: usize,
#[arg(
long,
default_value_t = true,
action = ArgAction::Set,
help = "Include libs index in search results"
)]
include_libs: bool,
#[arg(
long,
default_value_t = true,
action = ArgAction::Set,
help = "Include snippets in results"
)]
snippets: bool,
#[arg(long, help = "Drop hits whose token_estimate exceeds this value")]
max_tokens: Option<u64>,
#[arg(long, default_value_t = false, help = "Force web discovery")]
force_web: bool,
#[arg(long, default_value_t = false, help = "Skip local search")]
skip_local_search: bool,
#[arg(long, default_value_t = false, help = "Disable web cache")]
no_cache: bool,
#[arg(long, help = "Max web results to fetch (Tier 2)")]
max_web_results: Option<usize>,
#[arg(
long,
default_value_t = false,
help = "Use the LLM to filter local results before scoring"
)]
llm_filter_local_results: bool,
#[arg(
long,
default_value_t = true,
action = ArgAction::Set,
help = "Run web discovery asynchronously when enabled"
)]
async_web: bool,
},
/// Run an ad-hoc chat query via CLI (JSON output).
#[command(visible_alias = "query")]
Chat {
#[command(flatten)]
repo: RepoArgs,
#[arg(short, long, help = "Chat query (omit to start an interactive REPL)")]
query: Option<String>,
#[arg(
long,
value_parser = config::non_empty_string,
help = "Override the Ollama model for this chat query"
)]
model: Option<String>,
#[arg(
long,
value_parser = config::non_empty_string,
help = "Use a mcoda agent slug or id for LLM calls"
)]
agent: Option<String>,
#[arg(
long,
value_parser = config::non_empty_string,
help = "Profile agent id to load behavioral preferences"
)]
agent_id: Option<String>,
#[arg(long, default_value_t = 8)]
limit: usize,
#[arg(
long,
value_name = "N",
help = "Max web results to fetch per query (Tier 2)"
)]
max_web_results: Option<usize>,
#[arg(
long,
default_value_t = false,
help = "Only search the repo index (ignore any repo-scoped libs index, if present)"
)]
repo_only: bool,
#[arg(
long,
alias = "skip-local-search",
default_value_t = false,
help = "Skip local index search and only use web results"
)]
web_only: bool,
#[arg(
long,
alias = "no-web-cache",
default_value_t = false,
help = "Disable web cache reads/writes for this query"
)]
no_cache: bool,
#[arg(
long,
default_value_t = false,
help = "Use the LLM to filter local search results before scoring"
)]
llm_filter_local_results: bool,
#[arg(
long,
default_value_t = false,
help = "Emit a minimal JSON response with only scores and web summary"
)]
compress_results: bool,
#[arg(
long,
default_value_t = false,
help = "Stream a text summary to stdout instead of printing JSON"
)]
stream: bool,
#[arg(
long,
value_enum,
help = "Enable diff-aware context (working-tree, staged, or range)"
)]
diff_mode: Option<CliDiffMode>,
#[arg(
long,
value_name = "REV",
help = "Diff range base ref (required when diff-mode=range)"
)]
diff_base: Option<String>,
#[arg(
long,
value_name = "REV",
help = "Diff range head ref (required when diff-mode=range)"
)]
diff_head: Option<String>,
#[arg(
long,
value_name = "PATH",
action = ArgAction::Append,
help = "Limit diff to specific paths (repeatable)"
)]
diff_path: Vec<PathBuf>,
},
/// Agent-related workflows.
Agent {
#[command(subcommand)]
command: AgentCommand,
},
/// Clear all cached web discovery/fetch entries.
WebCacheFlush,
/// Ingest library documentation sources into the repo-scoped libs index.
LibsIngest {
#[command(flatten)]
repo: RepoArgs,
#[arg(
long,
value_name = "PATH",
help = "Path to a JSON file containing `{ \"sources\": [...] }` entries"
)]
sources: PathBuf,
},
/// Discover eligible library documentation sources for a repo (dependency manifests + optional configured sources).
LibsDiscover {
#[command(flatten)]
repo: RepoArgs,
#[arg(
long,
value_name = "PATH",
help = "Optional JSON file containing `{ \"sources\": [...] }` entries to merge as explicit configured sources"
)]
sources: Option<PathBuf>,
},
/// Manage library docs ingestion and discovery.
Libs {
#[command(subcommand)]
command: LibsCommand,
},
/// Run a web discovery query (DuckDuckGo HTML).
WebSearch {
#[arg(short, long, value_parser = config::non_empty_string)]
query: String,
#[arg(long, default_value_t = 8)]
limit: usize,
},
/// Fetch a single URL for web context.
WebFetch {
#[arg(long, value_parser = config::non_empty_string)]
url: String,
},
/// Run a web-assisted query (forces Tier 2 gate behavior).
WebRag {
#[command(flatten)]
repo: RepoArgs,
#[arg(short, long, value_parser = config::non_empty_string)]
query: String,
#[arg(long, default_value_t = 8)]
limit: usize,
#[arg(
long,
default_value_t = false,
help = "Only search the repo index (ignore any repo-scoped libs index, if present)"
)]
repo_only: bool,
#[arg(
long,
default_value_t = false,
help = "Stream a text summary to stdout instead of printing JSON"
)]
stream: bool,
},
/// Render a repo folder tree with standard excludes.
Tree {
#[command(flatten)]
repo: RepoArgs,
#[arg(
value_name = "PATH",
help = "Repo-relative path to render (defaults to repo root)"
)]
path: Option<PathBuf>,
#[arg(
short = 'd',
long,
value_name = "N",
help = "Max depth (default: unlimited)"
)]
max_depth: Option<usize>,
#[arg(short = 'D', long, default_value_t = false, help = "Directories only")]
dirs_only: bool,
#[arg(
short = 'a',
long,
default_value_t = false,
help = "Include hidden entries"
)]
include_hidden: bool,
#[arg(
short = 'e',
long,
value_delimiter = ',',
help = "Extra excludes (comma-separated)"
)]
extra_excludes: Vec<String>,
},
/// View DAG traces.
Dag {
#[command(subcommand)]
command: DagCommand,
},
/// Run targeted tests for a repo.
RunTests {
#[command(flatten)]
repo: RepoArgs,
#[arg(
long,
value_name = "PATH",
help = "Optional file or directory to scope tests"
)]
target: Option<PathBuf>,
},
/// Launch the local TUI client.
Tui {
#[arg(long, value_name = "PATH", help = "Optional repo root to open")]
repo: Option<PathBuf>,
},
/// Store a memory item (requires Ollama embeddings).
MemoryStore {
#[command(flatten)]
repo: RepoArgs,
#[arg(long, value_parser = config::non_empty_string, help = "Text to store in memory")]
text: String,
#[arg(long, help = "Optional JSON object metadata (stringified)")]
metadata: Option<String>,
#[arg(
long,
env = "DOCDEX_EMBEDDING_BASE_URL",
value_parser = config::non_empty_string,
help = "Ollama base URL for embedding calls; takes precedence over --ollama-base-url when both are set"
)]
embedding_base_url: Option<String>,
#[arg(
long,
env = "DOCDEX_OLLAMA_BASE_URL",
default_value = "http://127.0.0.1:11434",
value_parser = config::non_empty_string,
help = "Ollama base URL for embedding calls (legacy; prefer --embedding-base-url / DOCDEX_EMBEDDING_BASE_URL)"
)]
ollama_base_url: String,
#[arg(
long,
env = "DOCDEX_EMBEDDING_MODEL",
default_value = "nomic-embed-text",
help = "Ollama embedding model identifier"
)]
embedding_model: String,
#[arg(
long,
env = "DOCDEX_EMBEDDING_TIMEOUT_MS",
default_value_t = 0u64,
help = "Embedding request timeout in milliseconds (0 disables)"
)]
embedding_timeout_ms: u64,
},
/// Recall memory items by semantic similarity (requires Ollama embeddings).
MemoryRecall {
#[command(flatten)]
repo: RepoArgs,
#[arg(long, value_parser = config::non_empty_string, help = "Query text to embed")]
query: String,
#[arg(long, default_value_t = 5, help = "Max results to return (1..=50)")]
top_k: usize,
#[arg(
long,
env = "DOCDEX_EMBEDDING_BASE_URL",
value_parser = config::non_empty_string,
help = "Ollama base URL for embedding calls; takes precedence over --ollama-base-url when both are set"
)]
embedding_base_url: Option<String>,
#[arg(
long,
env = "DOCDEX_OLLAMA_BASE_URL",
default_value = "http://127.0.0.1:11434",
value_parser = config::non_empty_string,
help = "Ollama base URL for embedding calls (legacy; prefer --embedding-base-url / DOCDEX_EMBEDDING_BASE_URL)"
)]
ollama_base_url: String,
#[arg(
long,
env = "DOCDEX_EMBEDDING_MODEL",
default_value = "nomic-embed-text",
help = "Ollama embedding model identifier"
)]
embedding_model: String,
#[arg(
long,
env = "DOCDEX_EMBEDDING_TIMEOUT_MS",
default_value_t = 0u64,
help = "Embedding request timeout in milliseconds (0 disables)"
)]
embedding_timeout_ms: u64,
},
/// Manage global agent profiles and preference memory.
Profile {
#[command(subcommand)]
command: ProfileCommand,
},
/// Run semantic gatekeeper hooks against staged changes (HTTP or Unix socket).
Hook {
#[command(subcommand)]
command: HookCommand,
},
/// Report Tree-sitter parser version status for symbols indexing.
SymbolsStatus {
#[command(flatten)]
repo: RepoArgs,
},
/// List unresolved dynamic import diagnostics from the impact graph.
ImpactDiagnostics {
#[command(flatten)]
repo: RepoArgs,
#[arg(
long,
value_parser = config::non_empty_string,
help = "Repo-relative file path to filter diagnostics"
)]
file: Option<String>,
#[arg(long, help = "Max diagnostics to return (default 200, max 1000)")]
limit: Option<usize>,
#[arg(long, help = "Offset into diagnostics list")]
offset: Option<usize>,
},
/// Read impact graph edges for a repo-relative file.
ImpactGraph {
#[command(flatten)]
repo: RepoArgs,
#[arg(
long,
value_parser = config::non_empty_string,
help = "Repo-relative file path to analyze"
)]
file: String,
#[arg(long, help = "Max edges to return (default 1000, max 20000)")]
max_edges: Option<i64>,
#[arg(long, help = "Max traversal depth (default 10, max 50)")]
max_depth: Option<i64>,
#[arg(
long,
value_parser = config::non_empty_string,
help = "Comma-separated edge type filter (e.g. import,require)"
)]
edge_types: Option<String>,
},
/// Read a file slice from the repo (similar to docdex_open).
Open {
#[command(flatten)]
repo: RepoArgs,
#[arg(long, value_parser = config::non_empty_string, help = "Repo-relative file path")]
file: String,
#[arg(long, help = "1-based start line")]
start: Option<usize>,
#[arg(long, help = "1-based end line")]
end: Option<usize>,
#[arg(long, help = "Return the first N lines (implies --clamp)")]
head: Option<usize>,
#[arg(long, default_value_t = false, help = "Clamp range to file bounds")]
clamp: bool,
},
/// File helpers for agent workflows.
File {
#[command(subcommand)]
command: FileCommand,
},
/// Test helpers for agent workflows.
Test {
#[command(subcommand)]
command: TestCommand,
},
/// Manage explicit repo identity mappings for shared state dirs.
Repo {
#[command(subcommand)]
command: RepoCommand,
},
/// Helper to register or remove Docdex MCP in supported agent CLIs.
McpAdd {
/// Agent to configure (currently automates Codex; others print commands to run).
#[arg(
long,
value_parser = [
"codex",
"cursor",
"cursor-cli",
"continue",
"cline",
"claude",
"claude-cli",
"grok",
"droid",
"factory",
"gemini",
"windsurf",
"roo",
"pearai",
"void",
"zed",
"vscode",
"amp",
"forge",
"copilot",
"warp"
],
default_value = "codex"
)]
agent: String,
/// Transport to configure for Codex (http or ipc).
#[arg(long, value_enum, default_value = "http")]
transport: McpAddTransport,
/// Repo/workspace root for MCP configuration; defaults to current directory.
#[arg(long)]
repo: Option<PathBuf>,
/// Remove the MCP entry instead of adding it (where supported).
#[arg(long, default_value_t = false)]
remove: bool,
/// Add to all known agents that are detected on this system.
#[arg(long, default_value_t = false)]
all: bool,
},
}
#[derive(Args, Debug, Clone)]
pub(crate) struct SetupArgs {
#[arg(long, help = "Do not prompt; print manual setup instructions instead")]
pub non_interactive: bool,
#[arg(long, help = "Emit JSON summary to stdout")]
pub json: bool,
#[arg(long, help = "Always run setup even if already completed")]
pub force: bool,
#[arg(long, hide = true)]
pub auto: bool,
#[arg(
long,
env = "DOCDEX_OLLAMA_PATH",
value_name = "PATH",
help = "Explicit path to the Ollama binary (falls back to PATH)"
)]
pub ollama_path: Option<PathBuf>,
}
#[derive(Subcommand, Debug)]
pub(crate) enum RepoCommand {
/// Initialize a repo in the running daemon and print the repo_id payload.
Init {
#[command(flatten)]
repo: RepoArgs,
},
/// Print the repo fingerprint for the current path.
Id {
#[command(flatten)]
repo: RepoArgs,
},
/// Report git status for the repo.
Status {
#[command(flatten)]
repo: RepoArgs,
},
/// Print `clean` or `dirty` based on git status.
Dirty {
#[command(flatten)]
repo: RepoArgs,
/// Exit with code 1 if dirty.
#[arg(long, default_value_t = false)]
exit_code: bool,
},
/// Explicitly re-associate a moved/renamed repo path to existing state under a shared `--state-dir`.
Reassociate {
#[command(flatten)]
repo: RepoArgs,
#[arg(
long,
value_parser = config::non_empty_string,
required_unless_present = "old_path",
help = "Target repo fingerprint (SHA-256 hex) to associate with --repo"
)]
fingerprint: Option<String>,
#[arg(
long,
value_name = "PATH",
required_unless_present = "fingerprint",
help = "Previous canonical repo path (may no longer exist); used to find the existing mapping"
)]
old_path: Option<PathBuf>,
},
/// Inspect how Docdex resolves repo identity and any shared-state mapping.
Inspect {
#[command(flatten)]
repo: RepoArgs,
},
}
#[derive(Subcommand, Debug)]
pub(crate) enum DelegationCommand {
/// Show delegation savings telemetry.
Savings {
#[arg(
long,
default_value_t = true,
action = ArgAction::Set,
help = "Print JSON output"
)]
json: bool,
},
/// List available local delegation agents and models.
Agents {
#[arg(
long,
default_value_t = false,
action = ArgAction::SetTrue,
help = "Print JSON output"
)]
json: bool,
},
}
#[derive(Subcommand, Debug)]
pub(crate) enum FileCommand {
/// Ensure the file ends with a newline.
EnsureNewline {
#[command(flatten)]
repo: RepoArgs,
#[arg(long, value_parser = config::non_empty_string)]
file: String,
},
/// Write file content (overwrites existing file).
Write {
#[command(flatten)]
repo: RepoArgs,
#[arg(long, value_parser = config::non_empty_string)]
file: String,
/// Content to write (mutually exclusive with --stdin).
#[arg(long)]
content: Option<String>,
/// Read content from stdin.
#[arg(long, default_value_t = false)]
stdin: bool,
/// Allow creating a new file if it doesn't exist.
#[arg(long, default_value_t = false)]
create: bool,
},
}
#[derive(Subcommand, Debug)]
pub(crate) enum TestCommand {
/// Run `node <file>` in the repo root.
RunNode {
#[command(flatten)]
repo: RepoArgs,
#[arg(long, value_parser = config::non_empty_string)]
file: String,
/// Additional args to pass to node (repeatable or space-separated).
#[arg(long, value_parser = config::non_empty_string)]
args: Vec<String>,
},
}
#[derive(Subcommand, Debug)]
pub(crate) enum AgentCommand {
/// Evaluate all mcoda agents with a fixed query set (writes results to ./tmp).
Eval {
#[command(flatten)]
repo: RepoArgs,
#[arg(long, default_value_t = 8)]
limit: usize,
#[arg(
long,
value_name = "N",
help = "Max web results to fetch per query (Tier 2)"
)]
max_web_results: Option<usize>,
#[arg(
long,
default_value_t = false,
help = "Only search the repo index (ignore any repo-scoped libs index, if present)"
)]
repo_only: bool,
#[arg(
long,
alias = "skip-local-search",
default_value_t = false,
help = "Skip local index search and only use web results"
)]
web_only: bool,
#[arg(
long,
alias = "no-web-cache",
default_value_t = false,
help = "Disable web cache reads/writes for these queries"
)]
no_cache: bool,
#[arg(
long,
default_value_t = false,
help = "Use the LLM to filter local search results before scoring"
)]
llm_filter_local_results: bool,
#[arg(
long,
value_name = "N",
help = "Limit the number of eval queries to run"
)]
max_queries: Option<usize>,
},
}
#[derive(Subcommand, Debug)]
pub(crate) enum ProfileCommand {
/// List profile agents and preferences.
List {
#[arg(long, value_parser = config::non_empty_string)]
agent_id: Option<String>,
},
/// Add a new preference (bypasses evolution).
Add {
#[arg(long, value_parser = config::non_empty_string)]
agent_id: String,
#[arg(long, value_parser = config::non_empty_string)]
category: String,
#[arg(long, value_parser = config::non_empty_string)]
content: String,
#[arg(long, value_parser = config::non_empty_string)]
role: Option<String>,
},
/// Search preferences by semantic similarity.
Search {
#[arg(long, value_parser = config::non_empty_string)]
agent_id: String,
#[arg(long, value_parser = config::non_empty_string)]
query: String,
#[arg(long, default_value_t = 8)]
top_k: usize,
},
/// Export preferences to a sync manifest.
Export {
#[arg(long, value_name = "PATH", default_value = "profile_sync.json")]
out: PathBuf,
},
/// Import preferences from a sync manifest.
Import {
#[arg(value_name = "PATH")]
path: PathBuf,
},
}
#[derive(Subcommand, Debug)]
pub(crate) enum BrowserCommand {
/// List browser candidates and the selected binary.
List,
/// Run browser discovery (and Linux auto-install) then persist config.
Setup,
/// Install headless Chromium on Linux and update config.
Install,
}
#[derive(Subcommand, Debug)]
pub(crate) enum HookCommand {
/// Validate staged files via the running daemon (fails open if unavailable).
PreCommit {
#[command(flatten)]
repo: RepoArgs,
},
}
#[derive(Subcommand, Debug)]
pub(crate) enum LibsCommand {
/// Fetch and ingest library docs from a sources file.
Fetch {
#[command(flatten)]
repo: RepoArgs,
#[arg(
long,
value_name = "PATH",
help = "Path to a JSON file containing `{ \"sources\": [...] }` entries"
)]
sources: Option<PathBuf>,
},
/// Discover eligible library documentation sources for a repo.
Discover {
#[command(flatten)]
repo: RepoArgs,
#[arg(
long,
value_name = "PATH",
help = "Optional JSON file containing `{ \"sources\": [...] }` entries to merge as explicit configured sources"
)]
sources: Option<PathBuf>,
},
}
#[derive(Subcommand, Debug)]
pub(crate) enum DagCommand {
/// Render a session DAG trace.
View {
#[command(flatten)]
repo: RepoArgs,
#[arg(value_name = "SESSION_ID", value_parser = config::non_empty_string)]
session_id: String,
#[arg(long, default_value = "text", value_parser = ["text", "dot", "json"])]
format: String,
#[arg(long, value_name = "N")]
max_nodes: Option<usize>,
},
/// Alias for `dag view`.
Export {
#[command(flatten)]
repo: RepoArgs,
#[arg(value_name = "SESSION_ID", value_parser = config::non_empty_string)]
session_id: String,
#[arg(long, default_value = "text", value_parser = ["text", "dot", "json"])]
format: String,
#[arg(long, value_name = "N")]
max_nodes: Option<usize>,
},
}
pub async fn run() -> Result<()> {
let matches = match Cli::command().try_get_matches() {
Ok(matches) => matches,
Err(err) => {
if matches!(
err.kind(),
ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
) {
err.print().map_err(anyhow::Error::from)?;
return Ok(());
}
return Err(StartupError::new("startup_config_invalid", err.to_string())
.with_hint("Run `docdexd help-all` for full usage.")
.into());
}
};
let mut cli = match Cli::from_arg_matches(&matches) {
Ok(cli) => cli,
Err(err) => {
if matches!(
err.kind(),
ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
) {
err.print().map_err(anyhow::Error::from)?;
return Ok(());
}
return Err(StartupError::new("startup_config_invalid", err.to_string())
.with_hint("Run `docdexd help-all` for full usage.")
.into());
}
};
if let Some(daemon_matches) = matches.subcommand_matches("daemon") {
let repo_explicit = repo_flag_provided()
|| matches!(
daemon_matches.value_source("repo"),
Some(ValueSource::EnvVariable)
);
if let Command::Daemon { args } = &mut cli.command {
args.repo_explicit = repo_explicit;
}
}
if let Some(serve_matches) = matches.subcommand_matches("serve") {
let repo_explicit = repo_flag_provided()
|| matches!(
serve_matches.value_source("repo"),
Some(ValueSource::EnvVariable)
);
if let Command::Serve { args } = &mut cli.command {
args.repo_explicit = repo_explicit;
}
}
let config = if !matches!(cli.command, Command::HelpAll) {
Some(config::AppConfig::load_default().map_err(|err| {
StartupError::new(
"startup_config_invalid",
format!("failed to load config: {err}"),
)
.with_hint("Ensure ~/.docdex is writable and HOME is set correctly.")
})?)
} else {
None
};
if should_ensure_daemon(&cli.command)
&& !cli_local_mode()
&& std::env::var_os("DOCDEX_HTTP_BASE_URL").is_none()
{
if let Some(config) = config.as_ref() {
let repo_hint = repo_hint_for_command(&cli.command);
daemon_spawn::ensure_daemon_running(config, repo_hint)?;
}
}
commands::dispatch(cli.command).await
}
fn should_ensure_daemon(command: &Command) -> bool {
if matches!(
command,
Command::Delegation {
command: DelegationCommand::Agents { .. }
}
) {
return false;
}
!matches!(
command,
Command::Serve { .. }
| Command::Daemon { .. }
| Command::HelpAll
| Command::Setup { .. }
| Command::Tree { .. }
| Command::Open { .. }
| Command::File { .. }
| Command::Test { .. }
)
}
fn repo_flag_provided() -> bool {
env::args_os().any(|arg| {
if arg == "--repo" {
return true;
}
let value = arg.to_string_lossy();
value.starts_with("--repo=")
})
}
fn repo_hint_for_command(command: &Command) -> Option<PathBuf> {
match command {
Command::SelfCheck { repo, .. } => Some(repo.repo_root()),
Command::Index { repo, .. } => Some(repo.repo_root()),
Command::Ingest { repo, .. } => Some(repo.repo_root()),
Command::Search { repo, .. } => Some(repo.repo_root()),
Command::Chat { repo, .. } => Some(repo.repo_root()),
Command::LibsIngest { repo, .. } => Some(repo.repo_root()),
Command::LibsDiscover { repo, .. } => Some(repo.repo_root()),
Command::Libs { command } => match command {
LibsCommand::Discover { repo, .. } => Some(repo.repo_root()),
LibsCommand::Fetch { repo, .. } => Some(repo.repo_root()),
},
Command::Dag { command } => match command {
DagCommand::View { repo, .. } => Some(repo.repo_root()),
DagCommand::Export { repo, .. } => Some(repo.repo_root()),
},
Command::SymbolsStatus { repo } => Some(repo.repo_root()),
Command::ImpactDiagnostics { repo, .. } => Some(repo.repo_root()),
Command::ImpactGraph { repo, .. } => Some(repo.repo_root()),
Command::Open { repo, .. } => Some(repo.repo_root()),
Command::File { command } => match command {
FileCommand::EnsureNewline { repo, .. } => Some(repo.repo_root()),
FileCommand::Write { repo, .. } => Some(repo.repo_root()),
},
Command::Test { command } => match command {
TestCommand::RunNode { repo, .. } => Some(repo.repo_root()),
},
Command::RunTests { repo, .. } => Some(repo.repo_root()),
Command::Tui { repo } => repo
.as_ref()
.map(|root| root.canonicalize().unwrap_or_else(|_| root.to_path_buf())),
Command::MemoryStore { repo, .. } => Some(repo.repo_root()),
Command::MemoryRecall { repo, .. } => Some(repo.repo_root()),
Command::WebRag { repo, .. } => Some(repo.repo_root()),
Command::Repo { command } => match command {
RepoCommand::Init { repo, .. } => Some(repo.repo_root()),
RepoCommand::Id { repo, .. } => Some(repo.repo_root()),
RepoCommand::Status { repo, .. } => Some(repo.repo_root()),
RepoCommand::Dirty { repo, .. } => Some(repo.repo_root()),
RepoCommand::Inspect { repo, .. } => Some(repo.repo_root()),
RepoCommand::Reassociate { repo, .. } => Some(repo.repo_root()),
},
_ => None,
}
}
pub fn render_error_and_exit(err: anyhow::Error) -> ! {
if let Some(startup) = err.downcast_ref::<StartupError>() {
let mut body = serde_json::Map::new();
body.insert("code".to_string(), json!(startup.code));
body.insert("message".to_string(), json!(startup.message.as_str()));
if let Some(hint) = startup.hint.as_ref() {
body.insert("hint".to_string(), json!(hint));
}
if let Some(steps) = startup.remediation.as_ref() {
body.insert("remediation".to_string(), json!(steps));
}
let payload = serde_json::Value::Object({
let mut root = serde_json::Map::new();
root.insert("error".to_string(), serde_json::Value::Object(body));
root
});
match serde_json::to_string(&payload) {
Ok(line) => eprintln!("{line}"),
Err(_) => eprintln!("{}", startup.message),
}
std::process::exit(1);
}
if let Some(app) = err.downcast_ref::<crate::error::AppError>() {
let mut body = serde_json::Map::new();
body.insert("code".to_string(), json!(app.code));
body.insert("message".to_string(), json!(app.message.as_str()));
if let Some(details) = app.details.as_ref() {
body.insert("details".to_string(), details.clone());
}
let payload = serde_json::Value::Object({
let mut root = serde_json::Map::new();
root.insert("error".to_string(), serde_json::Value::Object(body));
root
});
match serde_json::to_string(&payload) {
Ok(line) => eprintln!("{line}"),
Err(_) => eprintln!("{}", app.message),
}
std::process::exit(1);
}
eprintln!("{err}");
std::process::exit(1);
}
#[cfg(test)]
mod tests;