use std::io::{IsTerminal, Read, Write};
use std::path::Path;
use std::str::FromStr;
use anyhow::{Context, Result};
use clap::Subcommand;
use crate::mcp::BasemindServer;
use crate::mcp::params::*;
use crate::store_gc::{self, CacheComponent};
use super::choices::{CompressLevel, TelemetryWindow};
use super::exit::CliExit;
use super::render::{Emit, emit, render_human, render_json};
use super::{resolve_path, run_tool};
#[derive(Subcommand, Debug)]
pub enum AdminCmd {
Repo,
Telemetry {
#[arg(long, value_enum)]
window: Option<TelemetryWindow>,
#[arg(long)]
tool: Option<String>,
},
Compress {
#[arg(long)]
path: Option<String>,
#[arg(long)]
text: Option<String>,
#[arg(long, value_enum)]
level: Option<CompressLevel>,
#[arg(long)]
target_tokens: Option<u32>,
#[arg(long)]
no_preserve_code: bool,
},
Tokens {
#[arg(long)]
stdin: bool,
},
Eval(crate::eval::EvalArgs),
}
#[cfg(feature = "tokenizer")]
fn run_tokens(out: &mut impl Write) -> Result<()> {
let text = read_stdin()?;
let count = crate::mcp::tokens::count_tokens(&text);
writeln!(out, "{count}").context("write token count")?;
Ok(())
}
#[cfg(not(feature = "tokenizer"))]
fn run_tokens(_out: &mut impl Write) -> Result<()> {
anyhow::bail!(
"`admin tokens` requires the `tokenizer` feature, which is not compiled into this \
basemind binary. Rebuild with `--features tokenizer` (or `--features documents`, which \
includes it)."
)
}
fn read_stdin() -> Result<String> {
let mut raw = Vec::new();
std::io::stdin().read_to_end(&mut raw).context("read stdin")?;
Ok(String::from_utf8_lossy(&raw).into_owned())
}
pub async fn run(server: &BasemindServer, cmd: AdminCmd, opts: &Emit, out: &mut impl Write) -> Result<()> {
let p = match cmd {
AdminCmd::Repo => AdminParams::new(AdminMode::Repo),
AdminCmd::Telemetry { window, tool } => AdminParams {
window: window.map(|w| w.as_str().to_string()),
tool,
..AdminParams::new(AdminMode::Telemetry)
},
AdminCmd::Compress {
path,
text,
level,
target_tokens,
no_preserve_code,
} => {
let resolved = path.as_deref().map(|p| resolve_path(server, p));
let text = match (&resolved, text) {
(Some(_), text) => text,
(None, Some(text)) => Some(text),
(None, None) => Some(read_stdin()?),
};
AdminParams {
path: resolved,
text,
level: level.map(|l| l.as_str().to_string()),
target_tokens,
preserve_code: no_preserve_code.then_some(false),
..AdminParams::new(AdminMode::Compress)
}
}
AdminCmd::Tokens { .. } => return run_tokens(out),
AdminCmd::Eval(args) => return crate::eval::run(server, &args, out).await,
};
let key = p.mode.telemetry_key();
let r = run_tool(key, server.admin_cli(p).await)?;
emit(key, &r, opts, out)
}
#[derive(Subcommand, Debug)]
pub enum CacheCmd {
Gc {
#[arg(long)]
dry_run: bool,
},
Stats,
Clear {
#[arg(long, default_value = "git-cache")]
component: String,
#[arg(short = 'y', long)]
yes: bool,
},
}
pub async fn run_status(server: &BasemindServer, opts: &Emit, out: &mut impl Write) -> Result<()> {
let key = AdminMode::Status.telemetry_key();
let r = run_tool(key, server.admin_cli(AdminParams::new(AdminMode::Status)).await)?;
emit(key, &r, opts, out)
}
pub fn run_cache(root: &Path, cmd: CacheCmd, json: bool, out: &mut impl Write) -> Result<()> {
let basemind_dir = crate::store::workspace_cache_dir(root);
match cmd {
CacheCmd::Gc { dry_run } => {
let report = if dry_run {
store_gc::gc_report_only().context("count blobs")?
} else {
store_gc::run_gc(&basemind_dir).context("run blob GC")?
};
let value = serde_json::to_value(&report).context("serialize GC report")?;
if json {
render_json(&value, out)
} else {
render_human("admin:gc", &value, out)
}
}
CacheCmd::Stats => {
let stats = store_gc::cache_stats(&basemind_dir).context("collect cache stats")?;
let value = serde_json::to_value(&stats).context("serialize cache stats")?;
if json {
render_json(&value, out)
} else {
render_human("admin:cache_stats", &value, out)
}
}
CacheCmd::Clear { component, yes } => {
guard_clear(&basemind_dir, &component, yes)?;
let value = if let Some(name) = component.strip_prefix("views:") {
store_gc::clear_single_view(&basemind_dir, name)
.with_context(|| format!("clear single view {name}"))?;
serde_json::json!({ "component": format!("views:{name}"), "cleared": true })
} else {
let comp = CacheComponent::from_str(&component).map_err(|e| anyhow::anyhow!(e))?;
store_gc::clear_component(&basemind_dir, comp)
.with_context(|| format!("clear cache component {component}"))?;
serde_json::json!({ "component": comp.as_str(), "cleared": true })
};
if json {
render_json(&value, out)
} else {
render_human("admin:cache_clear", &value, out)
}
}
}
}
fn guard_clear(basemind_dir: &Path, component: &str, yes: bool) -> Result<()> {
if component == CacheComponent::GitCache.as_str() {
return Ok(());
}
if let crate::store::WriterProbe::Held { holder } = crate::store::probe_writer_lock(basemind_dir) {
let who = holder.map_or_else(
|| "another basemind process".to_string(),
|meta| format!("`{}` (pid {})", meta.command, meta.pid),
);
return Err(CliExit::busy(format!(
"refusing to clear `{component}`: {who} holds this workspace's index lock. Stop it first \
(`basemind comms stop` for the daemon), then retry."
)));
}
let global = component == CacheComponent::Blobs.as_str();
if global && super::rescan::daemon_is_up() {
return Err(CliExit::busy(
"refusing to clear `blobs`: the basemind daemon is running and reads the machine-global blob \
store on behalf of every workspace. Stop it with `basemind comms stop`, then retry.",
));
}
if yes {
return Ok(());
}
let what = if global {
format!(
"`blobs` is the MACHINE-GLOBAL extraction store ({}) shared by EVERY workspace on this machine; \
clearing it forces all of them to re-extract on their next scan",
crate::store::global_blobs_dir().display()
)
} else {
format!("clear `{component}` under {}", basemind_dir.display())
};
if !std::io::stdin().is_terminal() {
return Err(CliExit::usage(format!(
"refusing to {what} without confirmation; pass --yes to proceed"
)));
}
eprint!("About to {what}. Continue? [y/N] ");
std::io::stderr().flush().context("flush prompt")?;
let mut answer = String::new();
std::io::stdin().read_line(&mut answer).context("read confirmation")?;
if matches!(answer.trim(), "y" | "Y" | "yes" | "YES") {
Ok(())
} else {
Err(CliExit::usage("aborted: nothing was cleared"))
}
}
#[cfg(test)]
mod tests {
use clap::{CommandFactory, Parser, Subcommand as _};
use super::AdminCmd;
#[derive(Parser)]
struct Harness {
#[command(subcommand)]
cmd: AdminCmd,
}
#[test]
fn should_expose_one_subcommand_per_advertised_admin_mode() {
let command = AdminCmd::augment_subcommands(Harness::command());
let names: Vec<String> = command.get_subcommands().map(|s| s.get_name().to_string()).collect();
const TOP_LEVEL: &[&str] = &[
"status",
"rescan",
"cache_stats",
"gc",
"cache_clear",
"delta",
"checkpoint",
"waste",
];
for mode in crate::mcp::mode::AdminMode::ALL_MODES
.iter()
.filter(|m| !TOP_LEVEL.contains(m))
{
let expected = mode.replace('_', "-");
assert!(
names.contains(&expected),
"`admin` mode `{mode}` has no `basemind admin {expected}` subcommand; got {names:?}"
);
}
}
}