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(())
}
fn cmd_init(root: &std::path::Path) -> Result<()> {
let dir = root.join(config::BASEMIND_DIR);
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
let path = config::config_path(root);
if path.exists() {
anyhow::bail!("config already exists at {}", path.display());
}
let default_toml = r##""$schema" = "v1"
[scan]
include = ["**/*.rs", "**/*.py", "**/*.ts", "**/*.tsx", "**/*.js", "**/*.go"]
exclude = ["**/target/**", "**/node_modules/**", "**/dist/**", "**/.venv/**", "**/.basemind/**", "**/.git/**"]
respect_gitignore = true
max_file_bytes = 2097152
[watch]
debounce_ms = 250
[cache]
file_map_lru = 256
[mcp]
transport = "stdio"
"##;
std::fs::write(&path, default_toml).with_context(|| format!("write {}", path.display()))?;
println!("wrote {}", path.display());
Ok(())
}
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/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 = root.join(basemind::config::BASEMIND_DIR);
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),
)
.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(),
},
)
.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,
)
.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,
)
.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).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(())
}