use clap::Parser;
use crossterm::{
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
ExecutableCommand,
};
use hematite::runtime::{
build_runtime_bundle, run_agent_loop, spawn_runtime_profile_sync, AgentLoopConfig,
AgentLoopRuntime, RuntimeBundle,
};
use hematite::{ui, CliCockpit};
use ratatui::{backend::CrosstermBackend, Terminal};
use std::sync::Arc;
fn snapshot_path(name: &str) -> std::path::PathBuf {
let safe: String = name
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
hematite::tools::file_ops::hematite_dir()
.join("snapshots")
.join(format!("{}.txt", safe))
}
fn wants_version_report(args: &[String]) -> bool {
args.len() == 2 && matches!(args[1].as_str(), "--version" | "-V")
}
fn report_indicates_issues(content: &str) -> bool {
hematite::agent::report_export::report_has_issues_in_content(content)
}
fn print_health_banner(content: &str) {
let score = hematite::agent::report_export::score_health_from_content(content);
let bar = match score.grade {
'A' => "██████████ A",
'B' => "████████░░ B",
'C' => "██████░░░░ C",
'D' => "████░░░░░░ D",
_ => "██░░░░░░░░ F",
};
println!();
println!(" Health Score {} — {}", bar, score.label);
println!(" {}", score.summary_line());
}
fn print_fix_suggestions(content: &str) {
let suggestions = hematite::agent::report_export::suggest_fix_commands(content);
if !suggestions.is_empty() {
println!();
println!(" Next steps — run a targeted fix plan:");
for s in &suggestions {
println!(" {}", s.trim());
}
println!();
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
hematite::tools::hardening::pre_main_hardening();
let raw_args: Vec<String> = std::env::args().collect();
if wants_version_report(&raw_args) {
println!("{}", hematite::hematite_version_report());
return Ok(());
}
let cwd_ok = std::env::current_dir()
.map(|p| std::fs::read_dir(&p).is_ok())
.unwrap_or(false);
if !cwd_ok {
let home = std::env::var_os("USERPROFILE")
.or_else(|| std::env::var_os("HOME"))
.map(std::path::PathBuf::from);
if let Some(home) = home {
let _ = std::env::set_current_dir(home);
}
}
let cockpit = CliCockpit::parse();
if cockpit.mcp_server {
let edge = cockpit.edge_redact || cockpit.semantic_redact;
let semantic = cockpit.semantic_redact;
let semantic_url = cockpit.semantic_url.as_deref().unwrap_or(&cockpit.url);
let semantic_model = cockpit.semantic_model.as_deref().unwrap_or("");
hematite::agent::mcp_server::run_mcp_server(
edge,
semantic,
&cockpit.url,
semantic_url,
semantic_model,
)
.await?;
return Ok(());
}
if cockpit.report {
let fmt = cockpit.report_format.trim().to_ascii_lowercase();
if cockpit.open {
let path = match fmt.as_str() {
"json" => hematite::agent::report_export::save_report_json().await.1,
"html" => hematite::agent::report_export::save_report_html().await.1,
_ => {
hematite::agent::report_export::save_report_markdown()
.await
.1
}
};
println!("Report saved: {}", path.display());
open_path(&path);
} else {
let out = match fmt.as_str() {
"json" => hematite::agent::report_export::generate_report_json().await,
"html" => hematite::agent::report_export::generate_report_html().await,
_ => hematite::agent::report_export::generate_report_markdown().await,
};
print!("{}", out);
}
return Ok(());
}
if cockpit.diagnose {
let fmt = cockpit.report_format.trim().to_ascii_lowercase();
let (content, path) = match fmt.as_str() {
"html" => hematite::agent::report_export::save_diagnosis_report_html().await,
_ => hematite::agent::report_export::save_diagnosis_report().await,
};
println!("Diagnosis saved: {}", path.display());
print_health_banner(&content);
print_fix_suggestions(&content);
if cockpit.open {
open_path(&path);
}
std::process::exit(if report_indicates_issues(&content) {
1
} else {
0
});
}
if let Some(ref preset) = cockpit.triage {
let preset_str = preset.as_str();
let fmt = cockpit.report_format.trim().to_ascii_lowercase();
let (content, path) = match fmt.as_str() {
"html" => hematite::agent::report_export::save_triage_report_html(preset_str).await,
_ => hematite::agent::report_export::save_triage_report(preset_str).await,
};
println!("Triage saved: {}", path.display());
print_health_banner(&content);
print_fix_suggestions(&content);
if cockpit.open {
open_path(&path);
}
std::process::exit(if report_indicates_issues(&content) {
1
} else {
0
});
}
if let Some(ref issue) = cockpit.fix {
let issue_str = issue.trim();
if issue_str.eq_ignore_ascii_case("list") || issue_str.eq_ignore_ascii_case("help") {
println!(
"hematite --fix: {} supported issue categories (no model required)\n",
hematite::agent::report_export::fix_issue_categories().len()
);
for (category, keywords) in hematite::agent::report_export::fix_issue_categories() {
let example = keywords.split(',').next().unwrap_or(keywords).trim();
println!(" {:<26} hematite --fix \"{}\"", category, example);
}
println!("\nAdd --report-format html --open for a browser report.");
println!("Add --dry-run to preview which checks would run.");
println!("Add --execute to run safe auto-fixes after the plan.");
return Ok(());
}
if cockpit.dry_run {
let topics = hematite::agent::report_export::fix_plan_topics(issue_str);
println!("hematite --fix \"{}\": would inspect:\n", issue_str);
for (i, (topic, label)) in topics.iter().enumerate() {
println!(" [{}/{}] {} ({})", i + 1, topics.len(), label, topic);
}
println!("\nUp to 3 follow-up checks may be added automatically based on findings.");
return Ok(());
}
let fmt = cockpit.report_format.trim().to_ascii_lowercase();
let (content, path) = if fmt == "html" {
hematite::agent::report_export::save_fix_plan_html(issue).await
} else {
let (summary, md, path) =
hematite::agent::report_export::save_fix_plan_with_summary(issue).await;
println!("\n{}", summary.trim_end());
(md, path)
};
println!("\nFix plan saved: {}", path.display());
if cockpit.open {
open_path(&path);
}
if cockpit.execute {
let auto_cmds = hematite::agent::report_export::fix_plan_auto_commands(&content);
if auto_cmds.is_empty() {
println!("\nNo safe auto-fixes available for these findings.");
} else {
println!("\nFound {} safe auto-fix(es):", auto_cmds.len());
for (i, (label, cmd)) in auto_cmds.iter().enumerate() {
println!(" [{}] {} — {}", i + 1, label, cmd);
}
print!("\nRun these now? [Y/n]: ");
use std::io::Write;
let _ = std::io::stdout().flush();
let mut answer = String::new();
let _ = std::io::stdin().read_line(&mut answer);
if !answer.trim().eq_ignore_ascii_case("n") {
println!();
for (label, cmd) in &auto_cmds {
print!(" Running: {}... ", label);
let _ = std::io::stdout().flush();
let result = std::process::Command::new("cmd")
.args(["/C", cmd])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
match result {
Ok(s) if s.success() => println!("OK"),
Ok(s) => println!("Failed (code {})", s.code().unwrap_or(1)),
Err(e) => println!("Error: {}", e),
}
}
println!("\nSafe fixes complete.");
}
}
}
std::process::exit(if report_indicates_issues(&content) {
1
} else {
0
});
}
if cockpit.inventory {
println!(
"{}",
hematite::agent::direct_answers::build_inspect_inventory()
);
return Ok(());
}
if cockpit.snapshots {
let dir = hematite::tools::file_ops::hematite_dir().join("snapshots");
if !dir.exists() {
println!("No snapshots saved yet.");
println!("Save one with: hematite --inspect <topic> --snapshot <name>");
return Ok(());
}
let mut entries: Vec<_> = std::fs::read_dir(&dir)
.into_iter()
.flatten()
.flatten()
.filter(|e| e.path().extension().is_some_and(|x| x == "txt"))
.collect();
entries.sort_by_key(|e| {
e.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
});
entries.reverse();
if entries.is_empty() {
println!("No snapshots saved yet.");
} else {
println!("Saved snapshots ({}):\n", entries.len());
for e in &entries {
let name = e.file_name();
let stem = std::path::Path::new(&name)
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let size = e.metadata().map(|m| m.len()).unwrap_or(0);
let age = e
.metadata()
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.elapsed().ok())
.map(|d| {
let s = d.as_secs();
if s < 60 {
format!("{}s ago", s)
} else if s < 3600 {
format!("{}m ago", s / 60)
} else if s < 86400 {
format!("{}h ago", s / 3600)
} else {
format!("{}d ago", s / 86400)
}
})
.unwrap_or_else(|| "?".to_string());
println!(" {:30} {:>6} B {}", stem, size, age);
}
println!("\nCompare with: hematite --diff <topic> --from <name>");
}
return Ok(());
}
if let Some(ref topics_csv) = cockpit.watch {
let interval = cockpit.watch_interval.max(1);
let alert_pat = cockpit.alert.as_deref().map(|p| p.to_ascii_lowercase());
if let Some(ref pat) = alert_pat {
eprintln!(
"Watching: {} | alert: {:?} | interval: {}s | Ctrl+C to stop",
topics_csv, pat, interval
);
} else {
eprintln!(
"Watching: {} | interval: {}s | Ctrl+C to stop",
topics_csv, interval
);
}
loop {
use std::io::Write;
let ts = {
use std::time::{SystemTime, UNIX_EPOCH};
let s = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let h = ((s / 3600) % 24) as u32;
let m = ((s / 60) % 60) as u32;
let sec = (s % 60) as u32;
format!("{:02}:{:02}:{:02} UTC", h, m, sec)
};
let content = hematite::agent::report_export::generate_inspect_output(topics_csv).await;
if let Some(ref pat) = alert_pat {
if content.to_ascii_lowercase().contains(pat.as_str()) {
print!("\x1B[2J\x1B[H\x07");
let _ = std::io::stdout().flush();
println!(
"\x1B[32mALERT\x1B[0m — pattern {:?} matched at {} | Ctrl+C to stop\n",
pat, ts
);
print!("{}", content);
} else {
println!(" [{}] no match for {:?}", ts, pat);
}
} else {
print!("\x1B[2J\x1B[H");
let _ = std::io::stdout().flush();
println!(
"Hematite Watch — {} | every {}s | Ctrl+C to stop\n",
ts, interval
);
print!("{}", content);
}
let _ = std::io::stdout().flush();
tokio::time::sleep(std::time::Duration::from_secs(interval)).await;
}
}
if let Some(ref topics_csv) = cockpit.diff {
let after_secs = cockpit.diff_after.max(1);
let ts = |secs: u64| {
let h = ((secs / 3600) % 24) as u32;
let m = ((secs / 60) % 60) as u32;
let s = (secs % 60) as u32;
format!("{:02}:{:02}:{:02} UTC", h, m, s)
};
let now = || {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
};
let (snap_a, ts_a) = if let Some(ref from_name) = cockpit.from {
let path = snapshot_path(from_name);
match std::fs::read_to_string(&path) {
Ok(content) => {
eprintln!("Loaded snapshot A from: {}", path.display());
let age = path
.metadata()
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.elapsed().ok())
.map(|d| {
let s = d.as_secs();
if s < 60 {
format!("{}s ago", s)
} else if s < 3600 {
format!("{}m ago", s / 60)
} else if s < 86400 {
format!("{}h ago", s / 3600)
} else {
format!("{}d ago", s / 86400)
}
})
.unwrap_or_else(|| "saved".to_string());
(content, format!("{} ({})", from_name, age))
}
Err(e) => {
eprintln!("Error loading snapshot '{}': {}", from_name, e);
eprintln!("Run `hematite --snapshots` to list available snapshots.");
std::process::exit(1);
}
}
} else {
eprintln!("Taking snapshot A ({})...", topics_csv);
let s = hematite::agent::report_export::generate_inspect_output(topics_csv).await;
let t = ts(now());
eprintln!(
"Snapshot A taken at {}. Waiting {}s for snapshot B...",
t, after_secs
);
tokio::time::sleep(std::time::Duration::from_secs(after_secs)).await;
(s, t)
};
eprintln!("Taking snapshot B...");
let snap_b = hematite::agent::report_export::generate_inspect_output(topics_csv).await;
let ts_b = ts(now());
println!("--- Snapshot A ({})", ts_a);
println!("+++ Snapshot B ({})", ts_b);
println!();
use similar::{ChangeTag, TextDiff};
let diff = TextDiff::from_lines(&snap_a, &snap_b);
let mut changed = false;
for group in diff.grouped_ops(2) {
for op in &group {
for change in diff.iter_changes(op) {
match change.tag() {
ChangeTag::Delete => {
print!("\x1B[31m- {}\x1B[0m", change);
changed = true;
}
ChangeTag::Insert => {
print!("\x1B[32m+ {}\x1B[0m", change);
changed = true;
}
ChangeTag::Equal => {
print!(" {}", change);
}
}
}
}
println!();
}
if !changed {
println!("No changes detected between snapshots.");
}
return Ok(());
}
if let Some(ref topics_csv) = cockpit.inspect {
let content = hematite::agent::report_export::generate_inspect_output(topics_csv).await;
if let Some(ref snap_name) = cockpit.snapshot {
let snap_path = snapshot_path(snap_name);
if let Some(parent) = snap_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match std::fs::write(&snap_path, &content) {
Ok(()) => println!("Snapshot saved: {}", snap_path.display()),
Err(e) => eprintln!("Failed to save snapshot: {}", e),
}
} else {
let fmt = cockpit.report_format.trim().to_ascii_lowercase();
let save = cockpit.open || matches!(fmt.as_str(), "html" | "json");
if save {
let (_, path) =
hematite::agent::report_export::run_inspect_topics(topics_csv, &fmt, true)
.await;
if let Some(p) = path {
println!("Inspect report saved: {}", p.display());
if cockpit.open {
open_path(&p);
}
}
} else {
print!("{}", content);
}
}
return Ok(());
}
if let Some(ref query) = cockpit.query {
let content = hematite::agent::report_export::generate_query_output(query).await;
print!("{}", content);
return Ok(());
}
if let Some(ref cadence) = cockpit.schedule {
let cadence_str = cadence.trim();
if cadence_str == "status" {
println!("{}", hematite::agent::scheduler::query_scheduled_task());
return Ok(());
}
if cadence_str == "remove" {
match hematite::agent::scheduler::remove_scheduled_task() {
Ok(msg) => println!("{}", msg),
Err(e) => {
eprintln!("Error: {}", e);
std::process::exit(1);
}
}
return Ok(());
}
let exe_path = std::env::current_exe()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| "hematite".to_string());
match hematite::agent::scheduler::register_scheduled_task(cadence_str, &exe_path) {
Ok(msg) => println!("{}", msg),
Err(e) => {
eprintln!("Error: {}", e);
std::process::exit(1);
}
}
return Ok(());
}
if let Some(path) = cockpit.pdf_extract_helper.as_deref() {
let code = hematite::memory::vein::run_pdf_extract_helper(std::path::Path::new(path));
std::process::exit(code);
}
let local_soul = ui::hatch::generate_soul(cockpit.reroll.clone());
if cockpit.stats {
println!(
"Species: {} | Wisdom: {} | Chaos: {}",
local_soul.species, local_soul.wisdom, local_soul.chaos
);
return Ok(());
}
let RuntimeBundle {
services,
channels,
watcher_guard: _watcher_guard,
} = build_runtime_bundle(
&cockpit,
&local_soul.species,
local_soul.snark,
!cockpit.rusty,
)
.await?;
let hematite::runtime::RuntimeServices {
engine,
gpu_state,
git_state,
voice_manager,
swarm_coordinator,
cancel_token,
searx_session,
} = services;
let hematite::runtime::RuntimeChannels {
specular_rx,
agent_tx,
agent_rx,
swarm_tx,
swarm_rx,
user_input_tx,
user_input_rx,
} = channels;
let prewarm_engine = engine.clone();
tokio::spawn(async move {
let _ = prewarm_engine.prewarm().await;
});
let tui_cancel_token = cancel_token.clone();
tokio::spawn(run_agent_loop(
AgentLoopRuntime {
user_input_rx,
agent_tx: agent_tx.clone(),
services: hematite::runtime::RuntimeServices {
engine: engine.clone(),
gpu_state: gpu_state.clone(),
git_state: git_state.clone(),
voice_manager: voice_manager.clone(),
swarm_coordinator: swarm_coordinator.clone(),
cancel_token,
searx_session: searx_session.clone(),
},
},
AgentLoopConfig {
yolo: cockpit.yolo,
professional: !cockpit.rusty,
brief: cockpit.brief,
snark: local_soul.snark,
chaos: local_soul.chaos,
soul_personality: local_soul.personality.clone(),
fast_model: cockpit.fast_model.clone(),
think_model: cockpit.think_model.clone(),
},
));
let _runtime_profile_poller = spawn_runtime_profile_sync(engine.clone(), agent_tx.clone());
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
std::io::stdout().execute(EnterAlternateScreen)?;
std::io::stdout().execute(crossterm::event::EnableMouseCapture)?;
enable_raw_mode()?;
let mut terminal = Terminal::new(CrosstermBackend::new(std::io::stdout()))?;
let _app_result = ui::tui::run_app(
&mut terminal,
specular_rx,
agent_rx,
user_input_tx,
swarm_rx,
swarm_tx,
swarm_coordinator,
Arc::new(std::sync::Mutex::new(std::time::Instant::now())),
cockpit.clone(),
local_soul,
!cockpit.rusty,
gpu_state,
git_state,
tui_cancel_token,
voice_manager,
)
.await;
disable_raw_mode()?;
std::io::stdout().execute(crossterm::event::DisableMouseCapture)?;
std::io::stdout().execute(LeaveAlternateScreen)?;
#[cfg(target_os = "windows")]
{
#[link(name = "kernel32")]
extern "system" {
fn GetStdHandle(nStdHandle: u32) -> *mut std::ffi::c_void;
fn FlushConsoleInputBuffer(hConsoleInput: *mut std::ffi::c_void) -> i32;
}
const STD_INPUT_HANDLE: u32 = 0xFFFFFFF6; unsafe {
let h = GetStdHandle(STD_INPUT_HANDLE);
if !h.is_null() && h as isize != -1 {
FlushConsoleInputBuffer(h);
}
}
}
if let Some(summary) =
hematite::agent::searx_lifecycle::shutdown_searx_if_owned(&searx_session).await
{
eprintln!("{}", summary);
}
Ok(())
}
fn open_path(path: &std::path::Path) {
#[cfg(target_os = "windows")]
{
let s = path.to_string_lossy().into_owned();
let _ = std::process::Command::new("cmd")
.args(["/c", "start", "", &s])
.spawn();
}
#[cfg(not(target_os = "windows"))]
{
let opener = if cfg!(target_os = "macos") {
"open"
} else {
"xdg-open"
};
let _ = std::process::Command::new(opener).arg(path).spawn();
}
}
#[cfg(test)]
mod tests {
use super::wants_version_report;
#[test]
fn detects_plain_version_flag() {
assert!(wants_version_report(&[
"hematite".into(),
"--version".into()
]));
assert!(wants_version_report(&["hematite".into(), "-V".into()]));
assert!(!wants_version_report(&["hematite".into()]));
assert!(!wants_version_report(&[
"hematite".into(),
"--version".into(),
"--brief".into()
]));
}
}