use anyhow::{Result, bail};
use clap::{Parser, Subcommand};
use fastembed::TextEmbedding;
use semlith::home;
use semlith::{Semlith, embed, embed::Model, filter::Filter, fleet::Fleet};
use std::io::{IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::time::Instant;
#[derive(Parser)]
#[command(name = "semlith", version, about, long_about = None)]
struct Cli {
#[arg(long, short, global = true)]
store: Vec<PathBuf>,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Index {
paths: Vec<PathBuf>,
#[arg(long, short)]
model: Option<String>,
#[arg(long)]
name: Option<String>,
#[arg(long, short)]
quiet: bool,
#[arg(long)]
airgap: bool,
#[arg(long)]
include_secrets: bool,
},
Start {
paths: Vec<PathBuf>,
#[arg(long)]
no_ledger: bool,
#[arg(long)]
port: Option<u16>,
#[arg(long, default_value_t = semlith::watch::DEBOUNCE.as_millis() as u64)]
debounce: u64,
#[arg(long)]
airgap: bool,
#[arg(long)]
no_mcp_http: bool,
},
Key {
#[command(subcommand)]
what: KeyCommand,
},
Adopt {
store_dir: PathBuf,
#[arg(long)]
root: Option<PathBuf>,
#[arg(long)]
name: Option<String>,
},
Trust {
store_dir: Option<PathBuf>,
#[arg(long)]
list: bool,
},
Watch {
paths: Vec<PathBuf>,
#[arg(long, default_value_t = semlith::watch::DEBOUNCE.as_millis() as u64)]
debounce: u64,
#[arg(long, short)]
quiet: bool,
},
Search {
query: String,
#[arg(long, short, default_value_t = 8)]
k: usize,
#[arg(long, short)]
path: Vec<String>,
#[arg(long, short)]
ext: Vec<String>,
#[arg(long, short)]
lang: Vec<String>,
#[arg(long, default_value = "any")]
prefer: String,
#[arg(long)]
json: bool,
},
Read {
target: String,
#[arg(long, short)]
path: Vec<String>,
#[arg(long)]
json: bool,
},
Pattern {
query: String,
#[arg(long, short)]
lang: String,
#[arg(long, short)]
path: Vec<String>,
#[arg(long)]
json: bool,
},
Stats,
Files,
Forget { path: PathBuf },
Drop {
#[arg(value_name = "STORE")]
name: String,
#[arg(long)]
yes: bool,
},
Add {
url: String,
#[arg(long)]
name: Option<String>,
#[arg(long)]
airgap: bool,
},
Mcp,
Setup {
#[arg(long, short)]
yes: bool,
#[arg(long)]
airgap: bool,
},
Upgrade {
#[arg(long)]
check: bool,
#[arg(long)]
version: Option<String>,
#[arg(long)]
airgap: bool,
},
Models,
Languages,
Ledger {
#[arg(long, default_value_t = 20)]
last: usize,
#[arg(long)]
verify: bool,
#[arg(long)]
json: bool,
},
Symbol {
name: String,
#[arg(long, short, default_value_t = 20)]
k: usize,
#[arg(long)]
json: bool,
},
Neighbors {
name: String,
#[arg(long, short)]
kind: Vec<String>,
#[arg(long)]
all: bool,
#[arg(long)]
json: bool,
},
Path {
from: String,
to: String,
#[arg(long, short, default_value_t = 6)]
depth: u32,
#[arg(long)]
all_edges: bool,
#[arg(long)]
strict: bool,
#[arg(long)]
json: bool,
},
}
#[derive(Subcommand)]
enum KeyCommand {
Show {
#[arg(long)]
quiet: bool,
},
Rotate {
#[arg(long)]
now: bool,
},
}
fn main() -> Result<()> {
quiet_on_a_closed_pipe();
let cli = Cli::parse();
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
match cli.command {
Command::Setup { yes, airgap } => {
arm_airgap(airgap);
semlith::setup::run(yes, airgap)?;
}
Command::Upgrade {
check,
version,
airgap,
} => {
arm_airgap(airgap);
if check {
if let Some(reason) = semlith::upgrade::offline() {
bail!("{reason}");
}
let found = semlith::upgrade::check()?;
println!("installed {}", found.installed);
println!("latest {}", found.latest.trim_start_matches('v'));
if let Some(reason) = &found.blocked {
println!("note: {reason}");
}
if found.available {
println!("run `semlith upgrade` to install it");
std::process::exit(semlith::upgrade::UPGRADE_AVAILABLE);
}
println!("already current");
} else {
semlith::upgrade::apply(version)?;
}
}
Command::Models => {
println!(
"{:<44} {:>5} dim default. IBM Granite R2 small, int8, English",
embed::GRANITE_NAME,
384
);
for info in TextEmbedding::list_supported_models() {
println!(
"{:<44} {:>5} dim {}",
info.model, info.dim, info.description
);
}
}
Command::Index {
paths,
model,
name,
quiet,
airgap,
include_secrets,
} => {
arm_airgap(airgap);
let model = model
.map(|m| m.parse::<Model>().map_err(anyhow::Error::msg))
.transpose()?;
let roots = if paths.is_empty() {
vec![PathBuf::from(".")]
} else {
paths
};
let (roots, unreadable) = semlith::check_roots(&roots);
for (path, why) in &unreadable {
eprintln!("cannot index {}: {why}", path.display());
}
let Some(first) = roots.first().cloned() else {
bail!(
"nothing to index: no path given could be read. Nothing was created \
and nothing was registered."
);
};
let choice = home::resolve(&cli.store, &first, name.as_deref())?;
if let Some(hint) = choice.hint() {
eprintln!("{hint}");
}
let dir = choice.one()?;
let mut store = Semlith::open(&dir, model)?;
store.quiet = quiet;
store.boundary = semlith::Boundary {
roots: None,
allow_secrets: include_secrets,
};
let started = Instant::now();
let mut spoke = Instant::now();
let report = store.index_paths(&roots, |path, p| {
if quiet {
return;
}
if p.outcome == semlith::FileOutcome::Indexing {
eprintln!(" + {}", display(path));
}
if p.outcome == semlith::FileOutcome::Refused {
eprintln!(" - {}", display(path));
}
if spoke.elapsed() >= PROGRESS_INTERVAL {
spoke = Instant::now();
eprintln!(" {}", predict(p, started.elapsed()));
}
})?;
let model_name = store.model().to_string();
home::record(&choice, &roots, &model_name)?;
let (files, chunks, bytes) = store.stats()?;
let images = if report.images > 0 {
format!(", {} images", report.images)
} else {
String::new()
};
for (path, why) in &report.refused {
eprintln!("refused: {path} — {why}");
}
if !report.refused.is_empty() && !include_secrets {
eprintln!(" `--include-secrets` indexes these anyway, if you meant to.");
}
eprintln!(
"indexed {} files ({} chunks{images}) in {:.1}s — {} already indexed, {} skipped, {} removed",
report.indexed,
report.chunks,
started.elapsed().as_secs_f32(),
report.unchanged,
report.skipped,
report.removed,
);
eprintln!(
"store: {files} files, {chunks} chunks, {} at {}",
semlith::human_bytes(bytes),
dir.display()
);
if !unreadable.is_empty() {
bail!(
"{} of {} paths could not be read, and were not indexed",
unreadable.len(),
unreadable.len() + roots.len()
);
}
}
Command::Watch {
paths,
debounce,
quiet,
} => {
let roots = if paths.is_empty() {
vec![PathBuf::from(".")]
} else {
paths
};
let choice = home::resolve(&cli.store, &roots[0], None)?;
if let Some(hint) = choice.hint() {
eprintln!("{hint}");
}
let dir = choice.one()?;
let mut store = Semlith::open(&dir, None)?;
store.quiet = quiet;
home::record(&choice, &roots, &store.model().to_string())?;
semlith::watch::stop_on_signal();
let shown: Vec<String> = roots.iter().map(|r| display(r)).collect();
semlith::watch::run(
&mut store,
&roots,
std::time::Duration::from_millis(debounce),
&semlith::watch::STOP,
|progress| {
use semlith::watch::Progress;
match progress {
Progress::Ready {
catch_up,
files,
chunks,
} => eprintln!(
"watching {} — {files} files, {chunks} chunks \
({} indexed at startup, {} unchanged)",
shown.join(", "),
catch_up.indexed,
catch_up.unchanged,
),
Progress::File(path) if !quiet => eprintln!(" ~ {}", display(path)),
Progress::Batch(report, elapsed) if !quiet => eprintln!(
" {} re-embedded, {} removed, {} chunks in {:.1}s",
report.indexed,
report.removed,
report.chunks,
elapsed.as_secs_f32(),
),
Progress::Error(e) => eprintln!("semlith: watch error: {e}"),
_ => {}
}
},
)?;
}
Command::Search {
query,
k,
path,
ext,
lang,
prefer,
json,
} => {
let filter = Filter::new(&path, &ext, &lang)?;
let prefer = semlith::Prefer::parse(&prefer)?;
let mut fleet = read_fleet(&cli.store, &cwd, false)?;
fleet.quiet = json;
let selected = (!filter.is_empty())
.then(|| fleet.matching_files(&filter))
.transpose()?;
if selected == Some(0) {
if json {
println!("[]");
} else {
eprintln!(
"no files match the filter (store has {} chunks)",
fleet.chunks()
);
}
return Ok(());
}
let started = Instant::now();
let hits = fleet.search_preferring(None, &query, k, &filter, prefer)?;
let elapsed = started.elapsed();
semlith::ledger::search(&fleet, &CLI_LEDGER, &query, &hits, elapsed);
if json {
println!("{}", serde_json::to_string_pretty(&hits)?);
} else if hits.is_empty() {
eprintln!("no matches (store has {} chunks)", fleet.chunks());
} else {
let mut out = std::io::stdout().lock();
for (i, h) in hits.iter().enumerate() {
let from = match &h.store {
Some(label) => format!("[{label}] "),
None => String::new(),
};
let via: String = h
.lists
.iter()
.map(|l| match *l {
"vector" => 'v',
"keyword" => 'f',
"image" => 'i',
_ => 'g',
})
.collect();
let where_in = match h.image {
Some(px) => format!("{}x{} px", px.width, px.height),
None => format!("{}-{}", h.start_line, h.end_line),
};
writeln!(
out,
"{}{}. {:.3} {via:<3} {from}{}:{where_in}{}",
bold(),
i + 1,
h.score,
display(std::path::Path::new(&h.path)),
reset()
)?;
for line in h.text.lines() {
writeln!(out, " {line}")?;
}
writeln!(out)?;
}
let across = if fleet.len() > 1 {
let breakdown: Vec<String> = fleet
.labels()
.iter()
.map(|label| {
let n = hits
.iter()
.filter(|h| h.store.as_deref() == Some(*label))
.count();
format!("{label} {n}")
})
.collect();
format!(" across {} stores: {}", fleet.len(), breakdown.join(", "))
} else {
String::new()
};
match selected {
Some(n) => eprintln!(
"{} hits in {:?} (filter selected {n} of {} files){across}",
hits.len(),
elapsed,
fleet.files()?,
),
None => eprintln!("{} hits in {:?}{across}", hits.len(), elapsed),
}
let shape = semlith::shape_of(&query);
match prefer {
semlith::Prefer::Any => {
eprintln!("{} · {}", shape.as_str(), shape.weighting())
}
chosen => eprintln!(
"{} · {} · prefer {}",
shape.as_str(),
shape.weighting(),
chosen.as_str()
),
}
let evicted: u64 = fleet.each().map(|(_, s)| s.evictions()).sum();
if evicted > 0 {
eprintln!(
"semlith: put down {evicted} shard(s) to stay inside the {} MB index \
budget; raise {} to hold more",
semlith::index::budget_mb(),
semlith::index::INDEX_MEMORY_ENV,
);
}
}
}
Command::Ledger { last, verify, json } => {
let fleet = read_fleet(&cli.store, &cwd, false)?;
let many = fleet.len() > 1;
if verify {
let mut broken = false;
for (label, store) in fleet.each() {
match semlith::store::ledger_break(store.db())? {
Some(row) => {
broken = true;
println!("{label}: the chain does not verify from row {row} onwards");
}
None => println!("{label}: the chain is intact"),
}
}
if broken {
std::process::exit(1);
}
return Ok(());
}
let mut any = false;
for (label, store) in fleet.each() {
let rows = semlith::store::retrievals(store.db(), last)?;
if json {
println!("{}", serde_json::to_string_pretty(&rows)?);
any = any || !rows.is_empty();
continue;
}
if many {
println!("{}{label}{}", bold(), reset());
}
if rows.is_empty() {
continue;
}
any = true;
let mut out = std::io::stdout().lock();
for row in &rows {
writeln!(
out,
"{} {:<14} {:>3} hits {:>6} ms {}",
human_time(row.at),
row.client,
row.hits,
row.micros / 1000,
row.query,
)?;
}
if let Some(broken) = semlith::store::ledger_break(store.db())? {
writeln!(
out,
"\n the chain does not verify from row {broken} onwards: \
these rows have been edited or removed"
)?;
}
}
if !any && !json {
eprintln!(
"nothing recorded yet. The daemon records by default from 0.15.0 and says \
so on every start; `--no-ledger` or SEMLITH_LEDGER=0 stops it. Nothing \
recorded ever leaves this machine."
);
}
}
Command::Symbol { name, k, json } => {
let fleet = read_fleet(&cli.store, &cwd, false)?;
let found =
fleet.evidence_in(None, &name, &semlith::graph::dependency_kinds(), k, false)?;
if json {
println!("{}", serde_json::to_string_pretty(&found)?);
} else if found.definitions.is_empty() {
eprintln!("{}", nothing_known(&fleet, &name));
} else {
let mut out = std::io::stdout().lock();
writeln!(
out,
"{}",
found.render(bold(), reset(), &|p| display(std::path::Path::new(p)))
)?;
}
}
Command::Read { target, path, json } => {
let filter = Filter::new(&path, &[], &[])?;
let target = semlith::Target::parse(&target);
let fleet = read_fleet(&cli.store, &cwd, false)?;
let started = Instant::now();
let found = fleet.read_in(None, &target, &filter)?;
semlith::ledger::graph(
&fleet,
&CLI_LEDGER,
"read",
match &target {
semlith::Target::Span { path, .. } => path,
semlith::Target::Symbol(name) => name,
},
"",
found.is_some(),
started.elapsed(),
);
match found {
None => eprintln!(
"nothing indexed at that span or under that name (store has {} chunks)",
fleet.chunks()
),
Some(found) if json => println!("{}", serde_json::to_string_pretty(&found)?),
Some(semlith::Read::Choose(rows)) => {
let mut out = std::io::stdout().lock();
writeln!(out, "{} definitions of this name:", rows.len())?;
for row in &rows {
writeln!(
out,
" {} {} {}{}:{}-{}",
row.name,
row.kind,
store_prefix(&row.store),
display(std::path::Path::new(&row.path)),
row.start_line,
row.end_line,
)?;
}
}
Some(semlith::Read::One(span)) => {
let mut out = std::io::stdout().lock();
let named = match (&span.symbol, &span.symbol_kind) {
(Some(name), Some(kind)) => format!(" {name} {kind}"),
(Some(name), None) => format!(" {name}"),
_ => String::new(),
};
writeln!(
out,
"{}{}{}:{}-{}{named}{}{}",
bold(),
store_prefix(&span.store),
display(std::path::Path::new(&span.path)),
span.start_line,
span.end_line,
if span.fresh { "" } else { " · stale" },
reset(),
)?;
for line in span.text.lines() {
writeln!(out, "{line}")?;
}
}
}
}
Command::Pattern {
query,
lang,
path,
json,
} => {
let filter = Filter::new(&path, &[], &[])?;
let fleet = read_fleet(&cli.store, &cwd, false)?;
let started = Instant::now();
let found = fleet.pattern_in(None, &lang, &query, &filter)?;
semlith::ledger::graph(
&fleet,
&CLI_LEDGER,
"pattern",
&query,
"",
!found.matches.is_empty(),
started.elapsed(),
);
if json {
println!("{}", serde_json::to_string_pretty(&found)?);
} else if found.matches.is_empty() {
if found.files == 0 {
eprintln!("no indexed file is {}", found.language);
} else {
eprintln!("no match in {} {} files", found.files, found.language);
}
} else {
let mut out = std::io::stdout().lock();
for found in &found.matches {
writeln!(
out,
"{}{}{}:{}-{}{} @{} {}",
bold(),
store_prefix(&found.store),
display(std::path::Path::new(&found.path)),
found.start_line,
found.end_line,
reset(),
found.capture,
found.text,
)?;
}
eprintln!(
"{} match(es) in {} {} files{}",
found.matches.len(),
found.files,
found.language,
if found.truncated { " (truncated)" } else { "" },
);
}
}
Command::Neighbors {
name,
kind,
all,
json,
} => {
for k in &kind {
if !semlith::graph::KINDS.contains(&k.as_str()) {
anyhow::bail!(
"unknown edge kind {k:?}; the kinds are {}",
semlith::graph::KINDS.join(", ")
);
}
}
let fleet = read_fleet(&cli.store, &cwd, false)?;
let started = Instant::now();
let neighbours = fleet.neighbours_in(None, &name, &kind, all)?;
let found = !neighbours.callers.is_empty() || !neighbours.callees.is_empty();
semlith::ledger::graph(
&fleet,
&CLI_LEDGER,
"neighbors",
&name,
"",
found,
started.elapsed(),
);
if json {
println!("{}", serde_json::to_string_pretty(&neighbours)?);
} else if neighbours.callers.is_empty()
&& neighbours.callees.is_empty()
&& neighbours.hidden == 0
&& neighbours.unresolved.is_empty()
{
eprintln!("{}", nothing_known(&fleet, &name));
} else {
let mut out = std::io::stdout().lock();
print_ends(&mut out, "callers", &neighbours.callers)?;
print_ends(&mut out, "callees", &neighbours.callees)?;
if neighbours.hidden > 0 {
writeln!(
out,
"\n{} target{} outside this store, not listed (--all)",
neighbours.hidden,
if neighbours.hidden == 1 { "" } else { "s" },
)?;
}
if !neighbours.unresolved.is_empty() {
writeln!(out, "\n{}outside this store{}", bold(), reset())?;
for end in &neighbours.unresolved {
writeln!(out, " {} via {}", end.name, end.kind)?;
}
}
}
}
Command::Path {
from,
to,
depth,
all_edges,
strict,
json,
} => {
let all_edges = all_edges && !strict;
let fleet = read_fleet(&cli.store, &cwd, false)?;
let started = Instant::now();
let chain = fleet.path_in(None, &from, &to, depth, all_edges)?;
semlith::ledger::graph(
&fleet,
&CLI_LEDGER,
"path",
&from,
"",
chain.is_some(),
started.elapsed(),
);
if json {
println!("{}", serde_json::to_string_pretty(&chain)?);
} else {
match chain {
Some(chain) if chain.steps.is_empty() => {
println!("{from} is {to}");
}
Some(chain) => {
let mut out = std::io::stdout().lock();
write!(
out,
"{}",
chain.render(bold(), reset(), &|p| display(std::path::Path::new(p)))
)?;
}
None => eprintln!(
"{}",
semlith::graph::not_connected(&from, &to, depth, all_edges)
),
}
}
}
Command::Languages => {
for entry in semlith::filter::LANGUAGES {
let what = entry
.extensions
.iter()
.map(|e| format!(".{e}"))
.chain(entry.filenames.iter().map(|f| f.to_string()))
.collect::<Vec<_>>()
.join(" ");
let graph = if semlith::graph::has_graph(entry.name) {
"graph"
} else {
" "
};
println!("{:<12} {graph} {what}", entry.name);
}
}
Command::Stats => {
let fleet = read_fleet(&cli.store, &cwd, false)?;
let many = fleet.len() > 1;
let mut totals = (0, 0, 0);
for (label, store) in fleet.each() {
let (files, chunks, bytes) = store.stats()?;
totals = (totals.0 + files, totals.1 + chunks, totals.2 + bytes);
if many {
println!("{label}");
}
println!("store {}", store.dir().display());
if let Some(mode) = home::loose_mode(store.dir()) {
println!(
"warning the store directory is mode {mode:o}; other users on this \
machine can read what it indexed. Opening it with this semlith \
narrows it to 700."
);
}
println!("model {} ({} dim)", store.model(), store.dim());
println!("files {files}");
println!("chunks {chunks}");
let images = store.image_count()?;
if images > 0 {
println!("images {images}");
}
println!("vectors {}", store.len());
if let Some((shards, max)) = store.shards() {
println!(
"shards {shards}, up to {max} resident ({} MB budget, {})",
semlith::index::budget_mb(),
semlith::index::INDEX_MEMORY_ENV,
);
}
println!("indexed {}", semlith::human_bytes(bytes));
let savings = semlith::store::ledger_savings(store.db())?;
if savings.total > 0 {
println!(
"saved {} tokens not read, over {} of {} retrievals \
({}% coverage, {})",
savings.net,
savings.credited,
savings.total,
savings.coverage(),
savings.tier(),
);
}
if many {
println!();
}
}
if many {
println!(
"total {} stores, {} files, {} chunks, {}",
fleet.len(),
totals.0,
totals.1,
semlith::human_bytes(totals.2),
);
}
}
Command::Files => {
let fleet = read_fleet(&cli.store, &cwd, false)?;
let many = fleet.len() > 1;
for (label, store) in fleet.each() {
if many {
println!("{label}");
}
for path in semlith::store::all_paths(store.db())? {
println!("{}", display(std::path::Path::new(&path)));
}
if many {
println!();
}
}
}
Command::Add { url, name, airgap } => {
arm_airgap(airgap);
let choice = home::resolve(&cli.store, &cwd, name.as_deref())?;
let choice = resolve_for_add(choice, &cli.store, name.as_deref())?;
if let Some(hint) = choice.hint() {
eprintln!("{hint}");
}
let dir = choice.one()?;
let spinner = cliclack::spinner();
spinner.start(format!("fetching {url}"));
let fetched = match semlith::add::fetch(&url, &dir) {
Ok(fetched) => {
spinner.stop(format!(
"fetched {}",
semlith::human_bytes(fetched.bytes as i64)
));
fetched
}
Err(e) => {
spinner.error("fetch failed");
return Err(e);
}
};
let mut store = Semlith::open(&dir, None)?;
store.quiet = true;
let report = store.index_paths(std::slice::from_ref(&fetched.path), |_, _| {})?;
let model_name = store.model().to_string();
home::record(&choice, &[semlith::add::downloads_dir(&dir)], &model_name)?;
eprintln!("{} -> {}", fetched.url, display(&fetched.path));
eprintln!("indexed {} chunks into {}", report.chunks, dir.display());
}
Command::Forget { path } => {
let anchor = if path.is_dir() {
path.clone()
} else {
match path.parent() {
Some(dir) if !dir.as_os_str().is_empty() => dir.to_path_buf(),
_ => cwd.clone(),
}
};
let choice = home::resolve(&cli.store, &anchor, None)?;
if let Some(hint) = choice.hint() {
eprintln!("{hint}");
}
if matches!(choice, home::Choice::New { .. }) {
anyhow::bail!(not_indexed(&path));
}
let mut store = Semlith::open(&choice.one()?, None)?;
let (chunks, images) = store.forget(&path)?;
if chunks == 0 && images == 0 {
anyhow::bail!(not_indexed(&path));
}
let what = match (chunks, images) {
(0, images) => format!("{images} image vector(s)"),
(chunks, 0) => format!("{chunks} chunks"),
(chunks, images) => format!("{chunks} chunks and {images} image vector(s)"),
};
eprintln!("removed {what} for {}", path.display());
}
Command::Drop { name: store, yes } => {
let dir = semlith::home::Registry::dir_of(&store)?;
if !semlith::home::Registry::load()?.stores.contains_key(&store) {
anyhow::bail!("no registered store called {store}");
}
if !yes {
let go = cliclack::confirm(format!(
"Delete {store}? Its vectors, chunks, graph and ledger go; the files it indexed are untouched."
))
.initial_value(false)
.interact()
.unwrap_or(false);
if !go {
eprintln!("nothing was deleted");
return Ok(());
}
}
let dirs = semlith::home::all_dirs(&cli.store, &cwd).unwrap_or_default();
let through_daemon = match semlith::proxy::find(&dirs) {
Some(upstream) => upstream.delete_store(&store).is_ok(),
None => false,
};
if !through_daemon {
semlith::home::delete_store(&store)?;
}
eprintln!("deleted {store} ({})", dir.display());
eprintln!("the files it indexed are untouched");
}
Command::Start {
paths,
no_ledger,
port,
debounce,
airgap,
no_mcp_http,
} => {
arm_airgap(airgap);
let dirs = semlith::daemon::stores_to_open(&cli.store, &paths, &cwd)?;
if dirs.is_empty() {
eprintln!("semlith: no store registered yet — the portal will offer to make one");
}
semlith::daemon::run(
&dirs,
semlith::daemon::port_of(port),
std::time::Duration::from_millis(debounce),
semlith::embed::airgap(),
!no_ledger && semlith::ledger::enabled(),
!no_mcp_http,
|line| eprintln!("semlith: {line}"),
)?;
}
Command::Key { what } => match what {
KeyCommand::Show { quiet } => {
let key = semlith::home::agent_key()?;
if quiet {
println!("{key}");
} else {
let port = semlith::daemon::port_of(None);
println!("{}{key}{}", bold(), reset());
println!("{}", semlith::home::agent_key_path()?.display());
println!();
println!("It authenticates the MCP endpoint and nothing else, and it does not");
println!("change when the daemon restarts or the portal's token is rotated.");
for stanza in semlith::clients::http_stanzas(&key) {
println!();
println!("{}{}{}", bold(), stanza.format, reset());
print!("{}", stanza.text);
}
println!();
println!("Endpoint: http://127.0.0.1:{port}/mcp");
}
}
KeyCommand::Rotate { now } => {
let previous = semlith::home::agent_key().unwrap_or_default();
let fresh = semlith::home::rotate_agent_key()?;
let carried = semlith::setup::recarry_key(&previous, &fresh);
let port = semlith::daemon::port_of(None);
let url = format!("http://127.0.0.1:{port}/mcp");
let dirs = semlith::home::all_dirs(&cli.store, &cwd).unwrap_or_default();
let reached = match semlith::proxy::find(&dirs) {
Some(upstream) => upstream.adopt_key(&fresh, now).is_ok(),
None => false,
};
println!("{}{fresh}{}", bold(), reset());
if reached {
if now {
println!("The running daemon took it up; the previous key is refused now.");
} else {
println!(
"The running daemon took it up. The previous key keeps working until \
that daemon exits, so a session already open finishes."
);
}
} else {
println!(
"No daemon is running here; the next `semlith start` reads the new key."
);
}
if semlith::setup::claude_present() {
if semlith::setup::register_claude_http(&url) {
println!(
"Claude Code was re-registered against {url}, naming \
${{{}}} rather than the key itself, so this is the last \
rotation that needed it touched.",
semlith::setup::KEY_ENV
);
} else {
println!(
"Claude Code is installed but `claude mcp add` failed; paste the stanza below."
);
}
}
if carried.is_empty() {
println!("No configuration file on this machine carried the old key.");
} else {
println!();
println!("Carried the new key into:");
for path in &carried {
println!(" {}", path.display());
}
}
println!();
println!("Any client configured somewhere else needs the stanza below pasted in.");
for stanza in semlith::clients::http_stanzas(&fresh) {
println!();
print!("{}", stanza.text);
}
}
},
Command::Trust { store_dir, list } => {
let mut registry = home::Registry::load()?;
if list || store_dir.is_none() {
if registry.trusted.is_empty() {
eprintln!(
"no store outside {} is trusted. Every store semlith made is \
opened without asking; a `.semlith` that arrived some other \
way needs `semlith trust <dir>` once.",
home::home_or_error()?.display()
);
} else {
for dir in ®istry.trusted {
let missing = if dir.join("store.db").exists() {
""
} else {
" (gone)"
};
println!("{}{missing}", dir.display());
}
}
return Ok(());
}
let dir = registry.trust(store_dir.as_deref().expect("checked above"))?;
eprintln!(
"{} is trusted. semlith will open it from this directory without \
--store; `semlith adopt` moves it into {} if you would rather it \
lived with the others.",
dir.display(),
home::stores_root()?.display()
);
}
Command::Adopt {
store_dir,
root,
name,
} => {
let registered = home::Registry::load()?
.name_of(&store_dir)
.map(str::to_string)
.or_else(|| {
name.as_deref()
.filter(|n| home::Registry::load().is_ok_and(|r| r.stores.contains_key(*n)))
.map(str::to_string)
});
match (registered, &root) {
(Some(name), Some(root)) => {
home::repoint(&name, root)?;
eprintln!("{name} now indexes {}", root.display());
}
_ => {
let (name, target) = home::adopt(&store_dir, root.as_deref(), name.as_deref())?;
let store = Semlith::open_existing(&target)?;
let (files, chunks, bytes) = store.stats()?;
eprintln!(
"adopted {} as {name} — {files} files, {chunks} chunks, {} at {}",
store_dir.display(),
semlith::human_bytes(bytes),
target.display(),
);
}
}
}
Command::Mcp => {
let dirs = semlith::home::all_dirs(&cli.store, &cwd)?;
if let Some(upstream) = semlith::proxy::find(&semlith::proxy::candidates(&dirs)) {
eprintln!(
"semlith {}: forwarding to the daemon on 127.0.0.1:{} (found via {})",
env!("CARGO_PKG_VERSION"),
upstream.port,
upstream.via.display(),
);
return semlith::proxy::serve(
&upstream,
std::io::stdin().lock(),
std::io::stdout().lock(),
);
}
let mut fleet = read_fleet(&cli.store, &cwd, true)?;
fleet.quiet = true;
fleet.warm()?;
eprintln!(
"semlith {}: serving {} on {}",
env!("CARGO_PKG_VERSION"),
match fleet.len() {
1 => "1 store".to_string(),
n => format!("{n} stores"),
},
fleet.labels().join(", "),
);
semlith::mcp::serve(
&mut fleet,
std::io::stdin().lock(),
std::io::stdout().lock(),
)?;
}
}
Ok(())
}
fn arm_airgap(on: bool) {
if on {
unsafe { std::env::set_var(semlith::embed::AIRGAP_ENV, "1") };
}
}
fn read_fleet(flags: &[PathBuf], cwd: &Path, all: bool) -> Result<Fleet> {
let dirs = if all {
home::all_dirs(flags, cwd)?
} else {
home::read_dirs(flags, cwd)?
};
if dirs.is_empty() {
bail!(
"no semlith store covers {} and none is registered — \
run `semlith index` in a directory to make one, \
or `semlith adopt ./.semlith` to move an existing one into {}",
cwd.display(),
semlith::home::stores_root()?.display(),
);
}
Fleet::open(&dirs)
}
const PROGRESS_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2);
fn predict(p: semlith::IndexProgress, elapsed: std::time::Duration) -> String {
let secs = elapsed.as_secs_f32().max(0.001);
let rate = p.chunks as f32 / secs;
let left = p.total.saturating_sub(p.scanned);
let per_file = secs / p.scanned.max(1) as f32;
format!(
"{}/{} files, {} chunks, {rate:.0} chunks/s, ~{} left",
p.scanned,
p.total,
p.chunks,
human_duration(left as f32 * per_file),
)
}
fn human_duration(secs: f32) -> String {
if secs >= 3600.0 {
format!("{:.1}h", secs / 3600.0)
} else if secs >= 60.0 {
format!("{:.0}m", secs / 60.0)
} else {
format!("{secs:.0}s")
}
}
fn resolve_for_add(
choice: home::Choice,
flags: &[PathBuf],
name: Option<&str>,
) -> Result<home::Choice> {
if !matches!(choice, home::Choice::New { .. }) || !flags.is_empty() || name.is_some() {
return Ok(choice);
}
let registry = home::Registry::load()?;
let mut names = registry.stores.keys();
match (names.next(), names.next()) {
(Some(only), None) => {
let only = only.clone();
Ok(home::Choice::Registered {
dir: home::Registry::dir_of(&only)?,
name: only,
})
}
(None, _) => bail!(
"there is no store to add to yet — run `semlith index <path>` first, \
or give this one a name with `semlith add <url> --name <store>`"
),
_ => bail!(
"nothing indexes this directory, and there are {} stores to choose from: {}. \
Name one with `--store <dir>` or `--name <store>`, or run this from a \
directory one of them covers",
registry.stores.len(),
registry
.stores
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
),
}
}
const CLI_LEDGER: semlith::ledger::Who<'static> = semlith::ledger::Who {
client: "cli",
session: "cli",
};
fn quiet_on_a_closed_pipe() {
#[cfg(unix)]
{
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
}
let inherited = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let said = info
.payload()
.downcast_ref::<String>()
.map(String::as_str)
.or_else(|| info.payload().downcast_ref::<&str>().copied())
.unwrap_or_default();
let printing = said.starts_with("failed printing to std");
if printing && (said.contains("Broken pipe") || said.contains("pipe")) {
std::process::exit(0);
}
inherited(info);
}));
}
fn not_indexed(path: &std::path::Path) -> String {
format!("nothing to forget: {} is not indexed", path.display())
}
fn display(path: &std::path::Path) -> String {
let cwd = semlith::canonical(&std::env::current_dir().unwrap_or_default());
let real = semlith::canonical(path);
let shown = real.strip_prefix(&cwd).unwrap_or(&real);
semlith::plain(&shown.display().to_string())
}
fn bold() -> &'static str {
if std::io::stdout().is_terminal() {
"\x1b[1m"
} else {
""
}
}
fn reset() -> &'static str {
if std::io::stdout().is_terminal() {
"\x1b[0m"
} else {
""
}
}
fn nothing_known(fleet: &semlith::fleet::Fleet, name: &str) -> String {
let symbols: i64 = fleet
.each()
.map(|(_, store)| {
semlith::store::graph_stats(store.db())
.map(|(s, _)| s)
.unwrap_or(0)
})
.sum();
if symbols == 0 {
format!(
"no symbols in this store yet. The graph is built as files are indexed, \
so run `semlith index` once (or leave `semlith start` running) and \
{name} will be there if the corpus defines it."
)
} else {
format!("no symbol named {name} in the graph")
}
}
fn store_prefix(store: &Option<String>) -> String {
match store {
Some(label) => format!("[{label}] "),
None => String::new(),
}
}
fn print_ends(out: &mut impl Write, heading: &str, ends: &[semlith::store::EdgeEnd]) -> Result<()> {
writeln!(out, "{}{heading}{} ({})", bold(), reset(), ends.len())?;
if ends.is_empty() {
writeln!(out, " none")?;
return Ok(());
}
for end in ends {
if end.confidence == semlith::graph::AMBIGUOUS {
writeln!(
out,
" {} via {} ({}) · {} definitions{}",
end.symbol.name,
end.kind,
end.confidence,
end.definitions,
semlith::graph::call_site(end, &|p| display(std::path::Path::new(p))),
)?;
continue;
}
writeln!(
out,
" {} via {} ({}) {}{}:{}{}",
end.symbol.name,
end.kind,
end.confidence,
store_prefix(&end.symbol.store),
display(std::path::Path::new(&end.symbol.path)),
end.symbol.start_line,
semlith::graph::call_site(end, &|p| display(std::path::Path::new(p))),
)?;
}
Ok(())
}
fn human_time(at: i64) -> String {
let secs = at.max(0) as u64;
let day = secs / 86_400;
let rest = secs % 86_400;
format!(
"{:02}:{:02}:{:02} d{day}",
rest / 3600,
(rest % 3600) / 60,
rest % 60
)
}