use std::path::PathBuf;
use std::time::Duration;
use tokio::sync::OnceCell;
pub(crate) const RTK_VERSION: &str = "0.40.0";
pub const RTK_NOT_INSTALLED_HELP: &str = "⚡ *Token Optimization (RTK)*\n\n\
RTK is not installed and the automatic download did not complete (no \
network, GitHub unreachable, or an unsupported platform). OpenCrabs retries \
the download on first use, so check connectivity and try `/rtk` again after \
a restart. Source: https://github.com/rtk-ai/rtk";
const RTK_SUPPORTED_COMMANDS: &[&str] = &[
"git",
"gh",
"glab",
"aws",
"psql",
"pnpm",
"npm",
"npx",
"cargo",
"docker",
"kubectl",
"grep",
"find",
"ls",
"tree",
"diff",
"curl",
"wget",
"jest",
"vitest",
"prisma",
"tsc",
"next",
"dotnet",
"playwright",
"prettier",
"eslint",
"ps",
"top",
"lsof",
"netstat",
"ss",
"journalctl",
"dmesg",
"dig",
"nslookup",
"host",
"traceroute",
];
const RTK_BLOCKLIST: &[&str] = &[
"rtk", "sudo", "ssh", "scp", "sftp", "rsync", "vim", "vi", "nvim", "nano", "emacs", "less", "more", "man", "python", "python3", "node", "mysql", "redis-cli", "psql", ];
#[derive(Debug, Clone)]
pub struct RtkResult {
pub rewritten_command: String,
pub was_rewritten: bool,
pub original_command: String,
}
static RTK_BINARY: OnceCell<Option<String>> = OnceCell::const_new();
pub(crate) fn rtk_bin_filename() -> &'static str {
if cfg!(windows) { "rtk.exe" } else { "rtk" }
}
fn ensure_dir_in_path(dir: &std::path::Path) {
let dir_str = dir.to_string_lossy().to_string();
if let Ok(path) = std::env::var("PATH")
&& !path.split(':').any(|p| p == dir_str)
{
unsafe {
std::env::set_var("PATH", format!("{}:{}", dir_str, path));
}
}
}
async fn find_rtk_binary() -> Option<String> {
RTK_BINARY
.get_or_init(|| async {
let bin = rtk_bin_filename();
if let Ok(exe_path) = std::env::current_exe()
&& let Some(exe_dir) = exe_path.parent()
{
let bundled_path = exe_dir.join(bin);
if bundled_path.exists() && bundled_path.is_file() {
tracing::info!("RTK binary found bundled at: {:?}", bundled_path);
ensure_dir_in_path(exe_dir);
return Some("rtk".to_string());
}
let bin_path = exe_dir.join("bin").join(bin);
if bin_path.exists() && bin_path.is_file() {
tracing::info!("RTK binary found bundled at: {:?}", bin_path);
ensure_dir_in_path(&exe_dir.join("bin"));
return Some("rtk".to_string());
}
}
let which_bin = if cfg!(windows) { "where" } else { "which" };
match tokio::process::Command::new(which_bin)
.arg("rtk")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
.await
{
Ok(output) if output.status.success() => {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
tracing::info!("RTK binary found in PATH: {}", path);
return Some("rtk".to_string());
}
Ok(_) => {
tracing::info!("RTK binary not found bundled or in PATH; auto-downloading");
}
Err(_) => {
tracing::info!("RTK PATH lookup failed; auto-downloading the bundled release");
}
}
download_and_install_rtk().await
})
.await
.clone()
}
pub(crate) fn rtk_asset_name() -> Option<&'static str> {
match (std::env::consts::OS, std::env::consts::ARCH) {
("linux", "x86_64") => Some("rtk-x86_64-unknown-linux-musl.tar.gz"),
("linux", "aarch64") => Some("rtk-aarch64-unknown-linux-gnu.tar.gz"),
("macos", "x86_64") => Some("rtk-x86_64-apple-darwin.tar.gz"),
("macos", "aarch64") => Some("rtk-aarch64-apple-darwin.tar.gz"),
("windows", "x86_64") => Some("rtk-x86_64-pc-windows-msvc.zip"),
_ => None,
}
}
fn rtk_install_targets() -> Vec<PathBuf> {
let bin = rtk_bin_filename();
let mut targets = Vec::new();
if let Ok(exe_path) = std::env::current_exe()
&& let Some(exe_dir) = exe_path.parent()
{
targets.push(exe_dir.join(bin));
}
if let Some(home) = std::env::var_os("HOME") {
targets.push(PathBuf::from(home).join(".local").join("bin").join(bin));
}
targets
}
async fn download_and_install_rtk() -> Option<String> {
let Some(asset) = rtk_asset_name() else {
tracing::warn!(
os = std::env::consts::OS,
arch = std::env::consts::ARCH,
"RTK auto-download: no release asset for this platform"
);
return None;
};
let url = format!("https://github.com/rtk-ai/rtk/releases/download/v{RTK_VERSION}/{asset}");
tracing::info!(url = %url, "RTK auto-download: fetching release asset");
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(60))
.build()
.ok()?;
let resp = match client
.get(&url)
.header("User-Agent", "opencrabs")
.send()
.await
{
Ok(r) if r.status().is_success() => r,
Ok(r) => {
tracing::warn!(status = %r.status(), url = %url, "RTK auto-download: non-2xx response");
return None;
}
Err(e) => {
tracing::warn!(error = %e, url = %url, "RTK auto-download: request failed");
return None;
}
};
let bytes = match resp.bytes().await {
Ok(b) => b,
Err(e) => {
tracing::warn!(error = %e, "RTK auto-download: failed to read response body");
return None;
}
};
let bin = rtk_bin_filename();
let extracted = if asset.ends_with(".zip") {
extract_zip_member(&bytes, bin)
} else {
extract_tar_gz_member(&bytes, bin)
};
let Some(data) = extracted else {
tracing::warn!(
asset = asset,
"RTK auto-download: binary not found inside archive"
);
return None;
};
for dest in rtk_install_targets() {
if let Some(parent) = dest.parent()
&& let Err(e) = tokio::fs::create_dir_all(parent).await
{
tracing::debug!(dir = %parent.display(), error = %e, "RTK auto-download: mkdir failed, trying next target");
continue;
}
if let Err(e) = tokio::fs::write(&dest, &data).await {
tracing::debug!(path = %dest.display(), error = %e, "RTK auto-download: write failed, trying next target");
continue;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(e) = std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755))
{
tracing::warn!(path = %dest.display(), error = %e, "RTK auto-download: chmod failed");
}
}
tracing::info!(path = %dest.display(), bytes = data.len(), "RTK auto-download: installed");
if let Some(parent) = dest.parent() {
ensure_dir_in_path(parent);
}
return Some("rtk".to_string());
}
tracing::warn!("RTK auto-download: no writable install location found");
None
}
fn extract_tar_gz_member(data: &[u8], file_name: &str) -> Option<Vec<u8>> {
use std::io::Read;
let decoder = flate2::read::GzDecoder::new(data);
let mut archive = tar::Archive::new(decoder);
for entry in archive.entries().ok()? {
let mut entry = entry.ok()?;
let path = entry.path().ok()?.to_path_buf();
if path.file_name().and_then(|n| n.to_str()) == Some(file_name) {
let mut buf = Vec::new();
entry.read_to_end(&mut buf).ok()?;
return Some(buf);
}
}
None
}
fn extract_zip_member(data: &[u8], file_name: &str) -> Option<Vec<u8>> {
use std::io::Read;
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(data)).ok()?;
for i in 0..archive.len() {
let mut file = archive.by_index(i).ok()?;
if file.name().ends_with(file_name) {
let mut buf = Vec::new();
file.read_to_end(&mut buf).ok()?;
return Some(buf);
}
}
None
}
pub async fn is_rtk_available() -> bool {
find_rtk_binary().await.is_some()
}
pub fn warm_up() {
tokio::spawn(async {
if is_rtk_available().await {
tracing::debug!("RTK warm-up complete: binary available");
} else {
tracing::info!(
"RTK warm-up: binary unavailable after auto-download attempt; token savings disabled"
);
}
});
}
pub(crate) fn first_command_token(command: &str) -> &str {
for token in command.split_whitespace() {
if token.contains('=') && !token.starts_with('-') && !token.starts_with('/') {
continue;
}
return token;
}
""
}
pub(crate) fn is_rtk_supported(token: &str) -> bool {
let basename = token.rsplit('/').next().unwrap_or(token);
if RTK_BLOCKLIST.contains(&basename) {
return false;
}
RTK_SUPPORTED_COMMANDS.contains(&basename)
}
pub async fn rewrite_command(command: &str) -> Option<RtkResult> {
let rtk_binary = find_rtk_binary().await?;
let trimmed = command.trim();
if trimmed.is_empty() {
return None;
}
if trimmed.starts_with("rtk ") || trimmed == "rtk" {
return None;
}
let first_token = first_command_token(trimmed);
if !is_rtk_supported(first_token) {
tracing::debug!(
"RTK: command '{}' not supported (token: '{}')",
command,
first_token
);
return None;
}
let rewritten = format!("{} {}", rtk_binary, trimmed);
tracing::debug!("RTK rewrote: '{}' -> '{}'", command, rewritten);
Some(RtkResult {
rewritten_command: rewritten,
was_rewritten: true,
original_command: command.to_string(),
})
}