use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use tracing_subscriber::EnvFilter;
use basemind::config::{self, Config, DocumentsCliOverrides};
use basemind::render::{self, Verbosity};
use basemind::store::{LockHolder, Store, StoreError};
use basemind::watcher::{BatchKind, WatchBatch};
mod lang_cli;
#[derive(Parser, Debug)]
#[command(
name = "basemind",
version,
about = "File-watcher and code-map generator using tree-sitter",
long_about = None
)]
struct Cli {
#[arg(long, global = true)]
root: Option<PathBuf>,
#[arg(short, long, global = true, conflicts_with = "verbose")]
quiet: bool,
#[arg(short, long, global = true)]
verbose: bool,
#[arg(long, global = true)]
no_color: bool,
#[arg(long, global = true)]
json: bool,
#[arg(long, global = true, default_value_t = basemind::store::VIEW_WORKING.to_string())]
view: String,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand, Debug)]
enum Cmd {
Init,
Scan(ScanArgs),
Rescan(RescanArgs),
Watch,
#[command(subcommand)]
Query(basemind::cli::codemap::QueryCmd),
#[command(subcommand)]
Git(basemind::cli::git::GitCmd),
#[command(subcommand)]
Memory(basemind::cli::memory::MemoryCmd),
#[command(subcommand)]
Governance(basemind::cli::governance::GovernanceCmd),
#[command(subcommand)]
Web(basemind::cli::web::WebCmd),
#[cfg(all(feature = "shells", any(unix, windows)))]
#[command(subcommand)]
Shells(basemind::cli::shells::ShellsCmd),
Telemetry {
#[arg(long)]
window: Option<String>,
#[arg(long)]
tool: Option<String>,
},
Hook {
#[command(subcommand)]
action: HookCmd,
},
Lang {
#[command(subcommand)]
action: LangCmd,
},
CompressOutput(basemind::textcompress::cli::CompressOutputArgs),
Delta(basemind::textcompress::cli::DeltaArgs),
Checkpoint(basemind::textcompress::cli::CheckpointArgs),
DetectWaste(basemind::textcompress::cli::DetectWasteArgs),
Serve(ServeArgs),
#[command(subcommand)]
Cache(basemind::cli::admin::CacheCmd),
#[cfg(all(feature = "comms", any(unix, windows)))]
Comms {
#[command(subcommand)]
action: CommsLifecycleCmd,
},
}
#[cfg(all(feature = "comms", any(unix, windows)))]
#[derive(Subcommand, Debug)]
enum CommsLifecycleCmd {
Daemon,
Start,
Stop,
Status,
#[command(flatten)]
Agent(basemind::cli::comms::CommsAgentCmd),
}
#[derive(clap::Args, Debug)]
struct ScanArgs {
#[arg(long, conflicts_with = "rev")]
staged: bool,
#[arg(long, value_name = "REV")]
rev: Option<String>,
#[arg(long)]
no_git_history: bool,
#[arg(long)]
rebuild_git_history: bool,
#[command(flatten)]
documents: DocumentsCliOverrides,
}
#[derive(clap::Args, Debug)]
struct RescanArgs {
#[arg(value_name = "PATH")]
paths: Vec<String>,
#[arg(long)]
full: bool,
#[arg(long)]
no_git_history: bool,
#[arg(long)]
rebuild_git_history: bool,
}
#[derive(clap::Args, Debug)]
struct ServeArgs {
#[arg(long, default_value_t = 1024)]
git_cache_mem: usize,
#[arg(long)]
no_git_cache_disk: bool,
#[arg(long)]
no_watch: bool,
#[command(flatten)]
documents: DocumentsCliOverrides,
}
#[derive(Subcommand, Debug)]
enum LangCmd {
List,
Install,
Clean,
}
#[derive(Subcommand, Debug)]
enum HookCmd {
Install,
}
fn default_log_directive(verbosity: Verbosity) -> &'static str {
match verbosity {
Verbosity::Quiet => "warn",
Verbosity::Default => "info",
Verbosity::Verbose => "debug",
}
}
fn main() -> Result<()> {
#[cfg(all(feature = "shells", any(unix, windows)))]
if let Some(result) = basemind::shells::intercept_internal_reexec() {
return result;
}
let cli = Cli::parse();
let verbosity = Verbosity::from_flags(cli.quiet, cli.verbose);
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_log_directive(verbosity))),
)
.with_target(false)
.with_writer(std::io::stderr)
.init();
let no_color = cli.no_color;
let start = cli
.root
.clone()
.map(|p| p.canonicalize().unwrap_or(p))
.unwrap_or_else(|| std::env::current_dir().expect("cwd"));
let root = match basemind::git::Repo::discover(&start) {
Ok(repo) => repo.workdir().to_path_buf(),
Err(_) => start,
};
let json = cli.json;
let view = cli.view.clone();
warn_ignored_global_flags(&cli.cmd, json, &view);
let dispatch = |tc| basemind::cli::run(&root, &view, DocumentsCliOverrides::default(), json, tc);
match cli.cmd {
Cmd::Init => cmd_init(&root),
Cmd::Scan(args) => cmd_scan(&root, &args, verbosity, no_color),
Cmd::Rescan(args) => cmd_rescan(&root, &args, verbosity, no_color),
Cmd::Watch => cmd_watch(&root, verbosity, no_color),
Cmd::Query(q) => {
let _ = basemind::lang::ensure_grammars();
dispatch(basemind::cli::ToolCmd::Query(q))
}
Cmd::Git(g) => dispatch(basemind::cli::ToolCmd::Git(g)),
Cmd::Memory(m) => dispatch(basemind::cli::ToolCmd::Memory(m)),
Cmd::Governance(g) => dispatch(basemind::cli::ToolCmd::Governance(g)),
Cmd::Web(w) => dispatch(basemind::cli::ToolCmd::Web(w)),
#[cfg(all(feature = "shells", any(unix, windows)))]
Cmd::Shells(s) => dispatch(basemind::cli::ToolCmd::Shells(s)),
Cmd::Telemetry { window, tool } => dispatch(basemind::cli::ToolCmd::Telemetry { window, tool }),
Cmd::Hook { action } => match action {
HookCmd::Install => cmd_hook_install(&root),
},
Cmd::Lang { action } => match action {
LangCmd::List => lang_cli::cmd_lang_list(no_color),
LangCmd::Install => lang_cli::cmd_lang_install(verbosity, no_color),
LangCmd::Clean => lang_cli::cmd_lang_clean(),
},
Cmd::CompressOutput(args) => basemind::textcompress::cli::run(&args),
Cmd::Delta(args) => basemind::textcompress::cli::run_delta(&args),
Cmd::Checkpoint(args) => basemind::textcompress::cli::run_checkpoint(&root, &args),
Cmd::DetectWaste(args) => basemind::textcompress::cli::run_detect_waste(&args),
Cmd::Serve(args) => cmd_serve(&root, &view, &args),
Cmd::Cache(action) => basemind::cli::run_cache(&root, action, json),
#[cfg(all(feature = "comms", any(unix, windows)))]
Cmd::Comms { action } => cmd_comms(&root, action, json),
}
}
#[cfg(all(feature = "comms", any(unix, windows)))]
fn cmd_comms(root: &std::path::Path, action: CommsLifecycleCmd, json: bool) -> Result<()> {
match action {
CommsLifecycleCmd::Daemon => basemind::cli::comms_daemon::run(),
CommsLifecycleCmd::Start => cmd_comms_start(),
CommsLifecycleCmd::Stop => cmd_comms_lifecycle_rpc(CommsRpc::Stop, json),
CommsLifecycleCmd::Status => cmd_comms_lifecycle_rpc(CommsRpc::Status, json),
CommsLifecycleCmd::Agent(cmd) => basemind::cli::comms::run(root, json, cmd),
}
}
#[cfg(all(feature = "comms", any(unix, windows)))]
enum CommsRpc {
Stop,
Status,
}
#[cfg(all(feature = "comms", any(unix, windows)))]
fn cmd_comms_start() -> Result<()> {
use basemind::comms::singleton;
let paths = singleton::resolve_paths().context("resolve comms paths")?;
let socket_path = paths.socket_path.clone();
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("build tokio runtime")?;
runtime.block_on(async move {
singleton::ensure_daemon(&paths)
.await
.map_err(|e| anyhow::anyhow!("ensure comms daemon: {e}"))
})?;
println!("comms daemon is running ({})", socket_path.display());
Ok(())
}
#[cfg(all(feature = "comms", any(unix, windows)))]
fn cmd_comms_lifecycle_rpc(rpc: CommsRpc, json: bool) -> Result<()> {
use basemind::comms::client::CommsClient;
use basemind::comms::ids::AgentId;
use basemind::comms::singleton;
let paths = singleton::resolve_paths().context("resolve comms paths")?;
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("build tokio runtime")?;
runtime.block_on(async move {
let agent = AgentId::parse("basemind-cli").map_err(|e| anyhow::anyhow!("agent id: {e}"))?;
let mut client = CommsClient::connect(&paths, agent, None, None)
.await
.map_err(|e| anyhow::anyhow!("connect to comms daemon: {e}"))?;
match rpc {
CommsRpc::Stop => {
client.stop().await.map_err(|e| anyhow::anyhow!("stop: {e}"))?;
if json {
println!("{{\"stopped\":true}}");
} else {
println!("comms daemon stopping");
}
}
CommsRpc::Status => {
let status = client.status().await.map_err(|e| anyhow::anyhow!("status: {e}"))?;
if json {
println!(
"{}",
serde_json::to_string(&status).map_err(|e| anyhow::anyhow!("serialize status: {e}"))?
);
} else {
println!(
"pid={} version={} proto={} uptime={}s rooms={} subscribers={}",
status.pid,
status.version,
status.proto_ver,
status.uptime_secs,
status.rooms,
status.subscribers,
);
}
}
}
Ok::<(), anyhow::Error>(())
})?;
Ok(())
}
fn warn_ignored_global_flags(cmd: &Cmd, json: bool, view: &str) {
let consumes_json = matches!(
cmd,
Cmd::Query(_)
| Cmd::Git(_)
| Cmd::Memory(_)
| Cmd::Governance(_)
| Cmd::Web(_)
| Cmd::Telemetry { .. }
| Cmd::Cache(_)
);
#[cfg(all(feature = "comms", any(unix, windows)))]
let consumes_json = consumes_json || matches!(cmd, Cmd::Comms { .. });
#[cfg(all(feature = "shells", any(unix, windows)))]
let consumes_json = consumes_json || matches!(cmd, Cmd::Shells(_));
let consumes_view = consumes_json || matches!(cmd, Cmd::Serve(_));
if json && !consumes_json {
tracing::warn!("--json has no effect on this subcommand; ignoring");
}
if view != basemind::store::VIEW_WORKING && !consumes_view {
tracing::warn!(view = %view, "--view has no effect on this subcommand; ignoring");
}
}
fn bootstrap_grammars(verbosity: Verbosity, no_color: bool) -> Result<()> {
let summary = basemind::lang::ensure_grammars().map_err(|e| anyhow::anyhow!("grammar bootstrap failed: {e}"))?;
let mut out = render::stdout(no_color);
render::render_grammar_bootstrap(&mut out, &summary, verbosity);
Ok(())
}
const INIT_SCAFFOLD_TOML: &str = r##"# basemind configuration — https://github.com/Goldziher/basemind
# Lives at the repo root and is meant to be committed. The `.basemind/` cache (index + blobs) is
# derived state, gitignored, and wiped on schema bumps — never put durable config there.
# Every value below is the built-in default; uncomment and edit only what you want to change.
"$schema" = "v1"
[scan]
# Files to index. Default is "everything"; the tree-sitter language registry + a binary/size check
# filter the long tail. Narrow it if you only care about specific languages.
# include = ["**/*"]
# Extra exclude globs, ADDED ON TOP of the always-on floor (node_modules, target, dist, .venv,
# __pycache__, .git, .basemind, bazel-*, .idea, .DS_Store, …). You cannot remove a floor entry.
# exclude = []
# Honor .gitignore / .git/info/exclude while walking. Leave on unless you deliberately want
# ignored files indexed.
# respect_gitignore = true
# Follow symlinks during the walk. Off by default — symlinks often escape the repo (e.g. Bazel's
# bazel-* convenience symlinks). Turn on for repos that symlink real source into place.
# follow_symlinks = false
# Skip files larger than this many bytes (prevents minified-bundle stalls).
# max_file_bytes = 2097152
# Skip paths under any submodule root listed in .gitmodules.
# skip_submodules = true
# Run L2 extraction (calls + docs) inline with L1. Powers find_references / find_callers. Turning
# off roughly halves scan time on large repos but leaves reference search empty until an L2 pass.
# eager_l2 = true
# Absolute paths OUTSIDE the repo to also index (e.g. a Bazel external cache). Symlinks are always
# followed for these regardless of scan.follow_symlinks.
# extra_roots = []
[watch]
# Coalesce filesystem events within this window (milliseconds).
# debounce_ms = 250
# Run L2 extraction on live watch edits (extra CPU per edit).
# live_l2 = false
[cache]
# Max extracted file-maps kept hot in memory.
# file_map_lru = 256
[mcp]
# MCP transport. Only "stdio" is supported today.
# transport = "stdio"
[documents]
# Document RAG tier (PDF / Office / HTML / email / images). Requires a `documents` build.
# enabled = true
# Embed documents for semantic search. ON by default — embeddings pay off on real prose / OCR.
# embed = true
# Embedding model preset. Changing it forces a FULL RE-EMBED of the corpus (time + CPU): every
# document is re-encoded at the new model's dimension.
# fast — smallest / fastest, lowest quality
# balanced — default; 768-dim, good quality/cost tradeoff
# quality — larger model, best English quality, slower
# multilingual— multilingual model for non-English corpora
# embedding_preset = "balanced"
# Globs for documents that are still extracted + indexed but NOT embedded (keyword-only).
# embed_exclude = []
# Route archives (.zip/.tar/.jar/…) into the recursive archive extractor. Off by default so one
# archive can't explode into thousands of embeds. True binaries are always skipped.
# extract_archives = false
[code_search]
# Semantic code-search tier. Requires a `code-search` build.
# enabled = true
# Embed source code for VECTOR search. OFF by default — local embeddings on code aren't worth the
# cost (code is embedded with a general English model, and NL→symbol is already served by the BM25
# keyword lane over the same text). Chunking + BM25 keyword search work regardless. Turn on only if
# you specifically want vector search over code (downloads an ONNX model, re-embeds on preset change).
# embed = false
# Globs for source files that are still chunked + BM25-indexed but NOT embedded (only used when
# embed = true).
# embed_exclude = []
"##;
fn cmd_init(root: &std::path::Path) -> Result<()> {
ensure_basemind_gitignored(root)?;
let path = config::config_path(root);
if path.exists() {
anyhow::bail!("config already exists at {}", path.display());
}
let legacy = config::legacy_config_path(root);
if legacy.exists() {
anyhow::bail!(
"a legacy config exists at {legacy} and is still read as a fallback; move it to {path} \
to migrate (the root path then takes precedence) — `init` will not write a defaults \
scaffold that would silently shadow it",
legacy = legacy.display(),
path = path.display(),
);
}
std::fs::write(&path, INIT_SCAFFOLD_TOML).with_context(|| format!("write {}", path.display()))?;
println!("wrote {}", path.display());
Ok(())
}
fn ensure_basemind_gitignored(root: &std::path::Path) -> Result<bool> {
let path = root.join(".gitignore");
let existing = match std::fs::read_to_string(&path) {
Ok(contents) => Some(contents),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => return Err(anyhow::Error::new(e).context(format!("read {}", path.display()))),
};
let already = existing.as_deref().is_some_and(|contents| {
contents.lines().any(|line| {
let entry = line.trim().trim_start_matches('/').trim_end_matches('/');
entry == ".basemind"
})
});
if already {
return Ok(false);
}
let mut contents = existing.unwrap_or_default();
if !contents.is_empty() && !contents.ends_with('\n') {
contents.push('\n');
}
contents.push_str(".basemind/\n");
std::fs::write(&path, contents).with_context(|| format!("write {}", path.display()))?;
println!("updated {}", path.display());
Ok(true)
}
fn load_or_default(root: &std::path::Path) -> Result<Config> {
load_or_default_with(root, None)
}
fn load_or_default_with(root: &std::path::Path, cli: Option<DocumentsCliOverrides>) -> Result<Config> {
match config::load_with_overrides(root, None, cli) {
Ok(loaded) => Ok(loaded.config),
Err(config::ConfigError::NotFound(_)) => {
tracing::info!("no basemind.toml; using defaults");
Ok(config::default_for_root(root))
}
Err(e) => Err(anyhow::anyhow!(e)),
}
}
fn open_store_for_write(root: &std::path::Path, view: &str, what: &str, holder: LockHolder) -> Result<Store> {
Store::open_with_holder(root, view, holder).map_err(|err| {
if err.is_lock_contention() {
anyhow::Error::new(err).context(basemind::store::LOCK_CONTENTION_HELP.to_string())
} else {
anyhow::Error::new(err).context(format!("open store ({what})"))
}
})
}
fn writer_collision_notice(root: &std::path::Path) -> Option<String> {
let basemind_dir = root.join(basemind::config::BASEMIND_DIR);
match basemind::store::probe_writer_lock(&basemind_dir) {
basemind::store::WriterProbe::Free => None,
basemind::store::WriterProbe::Held { holder: Some(meta) } => Some(format!(
"`{}` (pid {}) is already running against this repo and keeping the index fresh — \
running this directly is unnecessary and would collide with it. Use that server's \
`rescan` tool to refresh the index, or stop it first.",
meta.command, meta.pid
)),
basemind::store::WriterProbe::Held { holder: None } => Some(basemind::store::LOCK_CONTENTION_HELP.to_string()),
}
}
fn sync_git_history_after_scan(
root: &std::path::Path,
cli_enabled: bool,
force_rebuild: bool,
out: &mut impl std::io::Write,
) {
if !cli_enabled || !basemind::git_history::index_enabled() {
return;
}
let Ok(repo) = basemind::git::Repo::discover(root) else {
return; };
let basemind_dir = basemind::git_history::shared_history_basemind_dir(root);
let index = match basemind::git_history::GitHistoryIndex::open(&basemind_dir) {
Ok(index) => index,
Err(error) => {
tracing::warn!(?error, "git-history index unavailable; skipping");
return;
}
};
if force_rebuild && let Err(error) = index.clear(&basemind_dir) {
tracing::warn!(?error, "git-history index clear failed");
}
match basemind::git_history::builder::sync(&index, &repo, &basemind_dir) {
Ok(outcome) => {
let summary = match outcome {
basemind::git_history::builder::RebuildOutcome::Fresh => "git-history index: up to date".to_string(),
basemind::git_history::builder::RebuildOutcome::Incremental { added } => {
format!("git-history index: +{added} commits")
}
basemind::git_history::builder::RebuildOutcome::FullRebuild { reason, commits } => {
format!("git-history index: rebuilt {commits} commits ({reason})")
}
};
let _ = writeln!(out, "{summary}");
}
Err(error) => tracing::warn!(?error, "git-history index sync failed"),
}
}
fn cmd_scan(root: &std::path::Path, args: &ScanArgs, verbosity: Verbosity, no_color: bool) -> Result<()> {
bootstrap_grammars(verbosity, no_color)?;
let config = load_or_default_with(root, Some(args.documents.clone()))?;
let mut out = render::stdout(no_color);
if args.staged {
let repo = basemind::git::Repo::discover(root).context("`--staged` requires being inside a git repository")?;
let mut store = open_store_for_write(root, basemind::store::VIEW_STAGED, "staged", LockHolder::Scan)?;
render::render_scan_header(&mut out, "staged index", verbosity);
let report = basemind::scanner::scan(
root,
&mut store,
&config,
basemind::scanner::ScanSource::Staged(&repo),
basemind::scanner::EmbedMode::Inline,
)
.context("scan staged")?;
render::render_report(&mut out, &report, verbosity);
return Ok(());
}
if let Some(rev_spec) = &args.rev {
let repo = basemind::git::Repo::discover(root).context("`--rev` requires being inside a git repository")?;
let sha = repo.resolve_rev(rev_spec).context("resolve rev")?;
let short = &sha[..7.min(sha.len())];
let view = basemind::store::view_name_for_rev(short);
let mut store = open_store_for_write(root, &view, "rev", LockHolder::Scan)?;
render::render_scan_header(&mut out, &format!("rev {short}"), verbosity);
let report = basemind::scanner::scan(
root,
&mut store,
&config,
basemind::scanner::ScanSource::Rev {
repo: &repo,
sha: sha.clone(),
},
basemind::scanner::EmbedMode::Inline,
)
.context("scan rev")?;
render::render_report(&mut out, &report, verbosity);
return Ok(());
}
if let Some(notice) = writer_collision_notice(root) {
use std::io::Write as _;
render::render_scan_header(&mut out, "scan", verbosity);
let _ = writeln!(out, "{notice}");
return Ok(());
}
let mut store = open_store_for_write(root, basemind::store::VIEW_WORKING, "scan", LockHolder::Scan)?;
let report = basemind::scanner::scan(
root,
&mut store,
&config,
basemind::scanner::ScanSource::WorkingTree,
basemind::scanner::EmbedMode::Inline,
)
.context("scan")?;
render::render_report(&mut out, &report, verbosity);
sync_git_history_after_scan(root, !args.no_git_history, args.rebuild_git_history, &mut out);
Ok(())
}
fn cmd_rescan(root: &std::path::Path, args: &RescanArgs, verbosity: Verbosity, no_color: bool) -> Result<()> {
bootstrap_grammars(verbosity, no_color)?;
let config = load_or_default(root)?;
let mut out = render::stdout(no_color);
if let Some(notice) = writer_collision_notice(root) {
use std::io::Write as _;
let _ = writeln!(out, "{notice}");
return Ok(());
}
let mut store = open_store_for_write(root, basemind::store::VIEW_WORKING, "rescan", LockHolder::Rescan)?;
let report = if args.full || args.paths.is_empty() {
basemind::scanner::scan(
root,
&mut store,
&config,
basemind::scanner::ScanSource::WorkingTree,
basemind::scanner::EmbedMode::Inline,
)
.context("rescan (full)")?
} else {
let abs: Vec<PathBuf> = args.paths.iter().map(|p| root.join(p)).collect();
basemind::scanner::scan_paths(root, &mut store, &config, &abs, basemind::scanner::EmbedMode::Inline)
.context("rescan (paths)")?
};
render::render_report(&mut out, &report, verbosity);
sync_git_history_after_scan(root, !args.no_git_history, args.rebuild_git_history, &mut out);
Ok(())
}
fn cmd_watch(root: &std::path::Path, verbosity: Verbosity, no_color: bool) -> Result<()> {
bootstrap_grammars(verbosity, no_color)?;
let config = Arc::new(load_or_default(root)?);
let store = Arc::new(Mutex::new(
Store::open_with_holder(root, basemind::store::VIEW_WORKING, LockHolder::Watch).context("open store")?,
));
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("build tokio runtime")?;
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
let store_w = Arc::clone(&store);
let config_w = Arc::clone(&config);
let root_buf = root.to_path_buf();
let watcher_handle = std::thread::spawn(move || {
let mut stdout = render::stdout(no_color);
let cb: basemind::watcher::BatchCallback = Box::new(move |batch: WatchBatch<'_>| match batch.kind {
BatchKind::InitialScan => {
render::render_report(&mut stdout, batch.report, verbosity);
}
BatchKind::Incremental { paths } => {
render::render_batch_header(&mut stdout, paths, verbosity);
render::render_lines(&mut stdout, batch.report, verbosity);
}
});
basemind::watcher::watch(&root_buf, store_w, config_w, shutdown_rx, cb)
});
runtime.block_on(async {
let _ = tokio::signal::ctrl_c().await;
tracing::info!("ctrl-c received; shutting down");
let _ = shutdown_tx.send(());
});
match watcher_handle.join() {
Ok(Ok(())) => Ok(()),
Ok(Err(e)) => Err(anyhow::anyhow!(e)),
Err(_) => Err(anyhow::anyhow!("watcher thread panicked")),
}
}
fn cmd_serve(root: &std::path::Path, view: &str, args: &ServeArgs) -> Result<()> {
if view != basemind::store::VIEW_WORKING {
let index_path = root
.join(basemind::config::BASEMIND_DIR)
.join(basemind::store::VIEWS_DIR)
.join(view)
.join(basemind::store::INDEX_FILE);
if !index_path.exists() {
anyhow::bail!(
"view {view:?} has not been scanned; run `basemind scan --view {view}` first \
(or omit --view to serve the working view)"
);
}
}
let (store, read_only) = match Store::open_with_holder(root, view, LockHolder::Serve) {
Ok(store) => (store, false),
Err(error) if error.is_lock_contention() => {
match &error {
StoreError::Locked { .. } => tracing::warn!(
%error,
"store write-lock held by another basemind process; starting read-only (reads from the shared index)"
),
_ => tracing::warn!(
%error,
"Fjall index lock still contended after retry (a transient reader, not another serve); starting read-only (reads from the shared index)"
),
}
let store = Store::open_read_only(root, view).context("open store read-only")?;
(store, true)
}
Err(error) => return Err(anyhow::Error::new(error).context("open store")),
};
let basemind_dir = root.join(basemind::config::BASEMIND_DIR);
let root_buf = root.to_path_buf();
let config = Arc::new(load_or_default_with(root, Some(args.documents.clone()))?);
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.context("build tokio runtime")?;
let repo = basemind::git::Repo::discover(root).ok().map(Arc::new);
let git_cache = Arc::new(
basemind::git_cache::GitCache::open(&basemind_dir, args.git_cache_mem, !args.no_git_cache_disk)
.context("open git cache")?,
);
let options = basemind::mcp::ServerOptions {
background: true,
watch: !args.no_watch,
read_only,
};
tracing::info!(
pid = std::process::id(),
version = env!("CARGO_PKG_VERSION"),
view,
read_only,
root = %root.display(),
"basemind serve: MCP server starting"
);
let outcome = runtime.block_on(async move {
use rmcp::ServiceExt;
let server = basemind::mcp::BasemindServer::new_with_options(store, root_buf, config, repo, git_cache, options);
let transport = rmcp::transport::stdio();
let service = server
.serve(transport)
.await
.map_err(|e| anyhow::anyhow!("rmcp serve: {e}"))?;
service
.waiting()
.await
.map_err(|e| anyhow::anyhow!("rmcp waiting: {e}"))?;
Ok::<(), anyhow::Error>(())
});
match &outcome {
Ok(()) => tracing::info!(pid = std::process::id(), "basemind serve: client disconnected, exiting"),
Err(error) => {
tracing::error!(pid = std::process::id(), %error, "basemind serve: exiting on error")
}
}
outcome
}
fn cmd_hook_install(root: &std::path::Path) -> Result<()> {
let hooks_dir = root.join(".git").join("hooks");
if !hooks_dir.exists() {
anyhow::bail!("no .git/hooks directory at {}", hooks_dir.display());
}
let hook_path = hooks_dir.join("pre-commit");
let body = r#"#!/usr/bin/env sh
# Installed by basemind hook install.
set -e
exec basemind scan --staged --quiet
"#;
std::fs::write(&hook_path, body)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&hook_path)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&hook_path, perms)?;
}
println!("installed pre-commit hook at {}", hook_path.display());
Ok(())
}