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::Store;
use basemind::watcher::{BatchKind, WatchBatch};
#[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),
Watch,
#[command(subcommand)]
Query(basemind::cli::codemap::QueryCmd),
#[command(subcommand)]
Git(basemind::cli::git::GitCmd),
#[command(subcommand)]
Memory(basemind::cli::memory::MemoryCmd),
#[command(subcommand)]
Web(basemind::cli::web::WebCmd),
Telemetry {
#[arg(long)]
window: Option<String>,
#[arg(long)]
tool: Option<String>,
},
Hook {
#[command(subcommand)]
action: HookCmd,
},
Lang {
#[command(subcommand)]
action: LangCmd,
},
Serve(ServeArgs),
#[command(subcommand)]
Cache(basemind::cli::admin::CacheCmd),
}
#[derive(clap::Args, Debug)]
struct ScanArgs {
#[arg(long, conflicts_with = "rev")]
staged: bool,
#[arg(long, value_name = "REV")]
rev: Option<String>,
#[command(flatten)]
documents: DocumentsCliOverrides,
}
#[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 main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.with_target(false)
.with_writer(std::io::stderr)
.init();
let cli = Cli::parse();
let verbosity = Verbosity::from_flags(cli.quiet, cli.verbose);
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);
match cli.cmd {
Cmd::Init => cmd_init(&root),
Cmd::Scan(args) => cmd_scan(&root, &args, verbosity, no_color),
Cmd::Watch => cmd_watch(&root, verbosity, no_color),
Cmd::Query(q) => {
let _ = basemind::lang::ensure_grammars();
basemind::cli::run(
&root,
&view,
DocumentsCliOverrides::default(),
json,
basemind::cli::ToolCmd::Query(q),
)
}
Cmd::Git(g) => basemind::cli::run(
&root,
&view,
DocumentsCliOverrides::default(),
json,
basemind::cli::ToolCmd::Git(g),
),
Cmd::Memory(m) => basemind::cli::run(
&root,
&view,
DocumentsCliOverrides::default(),
json,
basemind::cli::ToolCmd::Memory(m),
),
Cmd::Web(w) => basemind::cli::run(
&root,
&view,
DocumentsCliOverrides::default(),
json,
basemind::cli::ToolCmd::Web(w),
),
Cmd::Telemetry { window, tool } => basemind::cli::run(
&root,
&view,
DocumentsCliOverrides::default(),
json,
basemind::cli::ToolCmd::Telemetry { window, tool },
),
Cmd::Hook { action } => match action {
HookCmd::Install => cmd_hook_install(&root),
},
Cmd::Lang { action } => match action {
LangCmd::List => cmd_lang_list(no_color),
LangCmd::Install => cmd_lang_install(verbosity, no_color),
LangCmd::Clean => cmd_lang_clean(),
},
Cmd::Serve(args) => cmd_serve(&root, &view, &args),
Cmd::Cache(action) => basemind::cli::run_cache(&root, action, json),
}
}
fn warn_ignored_global_flags(cmd: &Cmd, json: bool, view: &str) {
let consumes_json = matches!(
cmd,
Cmd::Query(_)
| Cmd::Git(_)
| Cmd::Memory(_)
| Cmd::Web(_)
| Cmd::Telemetry { .. }
| Cmd::Cache(_)
);
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 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 =
Store::open(root, basemind::store::VIEW_STAGED).context("open store (staged)")?;
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);
if report.stats.read_failed + report.stats.extract_failed > 0 {
std::process::exit(2);
}
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 = Store::open(root, &view).context("open store (rev)")?;
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);
if report.stats.read_failed + report.stats.extract_failed > 0 {
std::process::exit(2);
}
return Ok(());
}
let mut store = Store::open(root, basemind::store::VIEW_WORKING).context("open store")?;
let report = basemind::scanner::scan(
root,
&mut store,
&config,
basemind::scanner::ScanSource::WorkingTree,
)
.context("scan")?;
render::render_report(&mut out, &report, verbosity);
if report.stats.read_failed + report.stats.extract_failed > 0 {
std::process::exit(2);
}
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(root, basemind::store::VIEW_WORKING).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<()> {
let store = Store::open(root, view).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,
};
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>(())
})
}
fn cmd_lang_list(no_color: bool) -> Result<()> {
use anstyle::{AnsiColor, Color, Reset, Style};
use std::io::Write;
let mut out = render::stdout(no_color);
let installed = basemind::lang::downloaded_languages();
let supported: std::collections::HashSet<&str> = basemind::lang::SUPPORTED_LANGUAGES
.iter()
.copied()
.collect();
let installed_set: std::collections::HashSet<&str> =
installed.iter().map(String::as_str).collect();
let ok = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Green)));
let warn = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Yellow)));
let dim = Style::new().dimmed();
let _ = writeln!(out, "supported by basemind (queries shipped):");
for &name in basemind::lang::SUPPORTED_LANGUAGES {
let (sym, label, style) = if installed_set.contains(name) {
('✓', "ready", ok)
} else {
('·', "missing", warn)
};
let _ = writeln!(
out,
" {s}{sym} {label:<7}{r} {name}",
s = style.render(),
r = Reset.render(),
sym = sym,
label = label,
name = name,
);
}
let extras: Vec<&str> = installed
.iter()
.map(String::as_str)
.filter(|n| !supported.contains(n))
.collect();
if !extras.is_empty() {
let _ = writeln!(out);
let _ = writeln!(
out,
"{d}also cached (no basemind queries, parse-only):{r}",
d = dim.render(),
r = Reset.render(),
);
for n in extras {
let _ = writeln!(
out,
" {d}· {n}{r}",
d = dim.render(),
r = Reset.render(),
n = n,
);
}
}
if let Some(dir) = basemind::lang::grammar_cache_dir() {
let _ = writeln!(out);
let _ = writeln!(
out,
"{d}cache: {dir}{r}",
d = dim.render(),
r = Reset.render(),
dir = dir.display(),
);
}
Ok(())
}
fn cmd_lang_install(verbosity: Verbosity, no_color: bool) -> Result<()> {
bootstrap_grammars(verbosity, no_color)?;
if verbosity != Verbosity::Quiet {
let summary = basemind::lang::ensure_grammars().map_err(|e| anyhow::anyhow!("{e}"))?;
if !summary.did_download() {
println!(
"all {} supported grammars already cached",
summary.already_cached.len()
);
}
}
Ok(())
}
fn cmd_lang_clean() -> Result<()> {
basemind::lang::clean_grammar_cache().map_err(|e| anyhow::anyhow!("{e}"))?;
println!("grammar cache cleared");
Ok(())
}
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(())
}