use std::io::Read;
use std::path::PathBuf;
use std::time::Duration;
use anyhow::Result;
use clap::{Parser, Subcommand};
use serde_json::{json, Value};
use blastcode::tools::{format_event, Engine};
use blastcode::mcp;
#[derive(Parser)]
#[command(
name = "blast",
version,
about = "BlastCode: code-graph MCP server and CLI for AI coding agents"
)]
struct Cli {
#[arg(long, global = true, default_value = ".")]
root: PathBuf,
#[arg(long, global = true)]
db: Option<PathBuf>,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
Index {
#[arg(long)]
full: bool,
},
Serve,
Watch {
#[arg(long, default_value_t = 1000)]
interval: u64,
},
Map {
path: Option<String>,
#[arg(long, default_value_t = 3)]
depth: u64,
},
Context { file: String },
Skeleton { file: String },
Source {
symbol: String,
#[arg(long)]
file: Option<String>,
#[arg(long, default_value_t = 0)]
context: u64,
},
Search {
query: String,
#[arg(long)]
kind: Option<String>,
#[arg(long, default_value_t = 20)]
limit: u64,
},
Trace {
symbol: String,
#[arg(long)]
file: Option<String>,
#[arg(long, default_value = "both")]
direction: String,
},
Impact {
file: String,
#[arg(long)]
stdin: bool,
},
Query {
#[arg(long)]
kind: Option<String>,
#[arg(long)]
name: Option<String>,
#[arg(long)]
path: Option<String>,
#[arg(long)]
calls: Option<String>,
#[arg(long)]
called_by: Option<String>,
#[arg(long, default_value_t = 50)]
limit: u64,
},
Changes {
#[arg(long)]
since: Option<i64>,
#[arg(long)]
file: Option<String>,
#[arg(long, default_value_t = 30)]
limit: u64,
},
Stats,
Tests {
file: Option<String>,
#[arg(long)]
symbol: Option<String>,
},
Verify {
file: String,
#[arg(long)]
patch: Option<String>,
#[arg(long)]
stdin: bool,
},
Coupled {
file: String,
#[arg(long, default_value_t = 100)]
depth: u64,
#[arg(long, default_value_t = 15)]
limit: u64,
},
Dead {
#[arg(long)]
prefix: Option<String>,
#[arg(long, default_value_t = 50)]
limit: u64,
},
Grep {
pattern: String,
#[arg(long)]
path: Option<String>,
#[arg(short = 's', long)]
case_sensitive: bool,
#[arg(long, default_value_t = 50)]
limit: u64,
#[arg(long)]
json: bool,
},
}
fn run_tool(engine: &mut Engine, name: &str, args: Value) -> Result<()> {
println!("{}", engine.call(name, &args)?);
Ok(())
}
fn main() {
if let Err(e) = real_main() {
eprintln!("blast: error: {e:#}");
std::process::exit(1);
}
}
fn real_main() -> Result<()> {
let cli = Cli::parse();
let mut engine = Engine::open(&cli.root, cli.db.clone())?;
match cli.cmd {
Cmd::Serve => return mcp::serve(engine),
Cmd::Index { full } => {
let st = engine.index_now(full)?;
println!("{}", serde_json::to_string_pretty(&st)?);
return Ok(());
}
Cmd::Watch { interval } => {
engine.index_now(false)?;
engine.poll_events()?;
eprintln!("blast: watching {} (Ctrl-C to stop)", engine.root.display());
loop {
std::thread::sleep(Duration::from_millis(interval.max(200)));
engine.index_now(false)?;
for e in engine.poll_events()? {
println!("{}", format_event(&e));
}
}
}
_ => {
engine.index_now(false)?;
}
}
match cli.cmd {
Cmd::Map { path, depth } => run_tool(&mut engine, "get_workspace_map", json!({"path": path.unwrap_or_default(), "depth": depth})),
Cmd::Context { file } => run_tool(&mut engine, "get_file_context", json!({"file_path": file})),
Cmd::Skeleton { file } => run_tool(&mut engine, "get_file_skeleton", json!({"file_path": file})),
Cmd::Source { symbol, file, context } => run_tool(
&mut engine,
"get_symbol_source",
json!({"symbol_name": symbol, "file_path": file.unwrap_or_default(), "context_lines": context}),
),
Cmd::Search { query, kind, limit } => run_tool(&mut engine, "search_symbols", json!({"query": query, "kind": kind.unwrap_or_default(), "limit": limit})),
Cmd::Trace { symbol, file, direction } => run_tool(&mut engine, "trace_symbol", json!({"symbol_name": symbol, "file_path": file.unwrap_or_default(), "direction": direction})),
Cmd::Impact { file, stdin } => {
let mut args = json!({"file_path": file});
if stdin {
let mut s = String::new();
std::io::stdin().read_to_string(&mut s)?;
args["new_source"] = json!(s);
}
run_tool(&mut engine, "get_impact_radius", args)
}
Cmd::Query { kind, name, path, calls, called_by, limit } => run_tool(
&mut engine,
"query_graph",
json!({"kind": kind.unwrap_or_default(), "name": name.unwrap_or_default(), "path_prefix": path.unwrap_or_default(),
"calls": calls.unwrap_or_default(), "called_by": called_by.unwrap_or_default(), "limit": limit}),
),
Cmd::Changes { since, file, limit } => {
let mut args = json!({"file_path": file.unwrap_or_default(), "limit": limit});
if let Some(s) = since {
args["since"] = json!(s);
}
run_tool(&mut engine, "get_workspace_changes", args)
}
Cmd::Stats => {
println!("{}", serde_json::to_string_pretty(&engine.stats()?)?);
Ok(())
}
Cmd::Tests { file, symbol } => {
let mut args = json!({});
if let Some(f) = file {
args["file_path"] = json!(f);
}
if let Some(s) = symbol {
args["symbol_name"] = json!(s);
}
run_tool(&mut engine, "get_affected_tests", args)
}
Cmd::Verify { file, patch, stdin } => {
let content = if let Some(p) = patch {
p
} else if stdin {
let mut s = String::new();
std::io::stdin().read_to_string(&mut s)?;
s
} else {
std::fs::read_to_string(&file)?
};
run_tool(&mut engine, "verify_patch", json!({"file_path": file, "patch": content}))
}
Cmd::Coupled { file, depth, limit } => run_tool(
&mut engine,
"get_co_changed_files",
json!({"file_path": file, "commit_depth": depth, "limit": limit}),
),
Cmd::Dead { prefix, limit } => {
let mut args = json!({"limit": limit});
if let Some(p) = prefix {
args["path_prefix"] = json!(p);
}
run_tool(&mut engine, "find_dead_code", args)
}
Cmd::Grep { pattern, path, case_sensitive, limit, json } => {
let res = blastcode::grep::grep_workspace(
&engine.root,
&pattern,
path.as_deref(),
case_sensitive,
limit as usize,
)?;
if json {
println!("{res}");
} else {
let v: Value = serde_json::from_str(&res)?;
if let Some(arr) = v["matches"].as_array() {
for m in arr {
println!(
"{}:{}: {}",
m["file"].as_str().unwrap_or(""),
m["line"],
m["content"].as_str().unwrap_or("")
);
}
}
}
Ok(())
}
Cmd::Serve | Cmd::Index { .. } | Cmd::Watch { .. } => unreachable!(),
}
}