use std::process::Command;
use camino::{Utf8Path, Utf8PathBuf};
use serde::Serialize;
use crate::detect::Forge;
use crate::skills::record::{RECORD_PATH, Record};
use crate::skills::{AGENTS_ROOT, CLAUDE_ROOT, Digest};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ProbeClass {
Hard,
Soft,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ProbeStatus {
Ok,
Failed,
}
#[derive(Debug, Serialize)]
pub struct ProbeResult {
pub id: &'static str,
pub class: ProbeClass,
pub status: ProbeStatus,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub remediation: Option<String>,
}
impl ProbeResult {
fn ok(id: &'static str, class: ProbeClass, message: impl Into<String>) -> Self {
Self {
id,
class,
status: ProbeStatus::Ok,
message: message.into(),
remediation: None,
}
}
fn failed(
id: &'static str,
class: ProbeClass,
message: impl Into<String>,
remediation: impl Into<String>,
) -> Self {
Self {
id,
class,
status: ProbeStatus::Failed,
message: message.into(),
remediation: Some(remediation.into()),
}
}
}
pub const SKILL_PROBES: [&str; 3] = ["skill-roots", "skill-gate", "skill-sources"];
pub const HARD_RUNTIME_TOOLS: [(&str, &str); 2] = [("git", "git"), ("sh", "bash")];
#[must_use]
pub fn git_bin() -> std::ffi::OsString {
crate::requirements::Tool::Git.program()
}
#[must_use]
pub fn nix_bin() -> std::ffi::OsString {
crate::requirements::Tool::Nix.program()
}
#[must_use]
pub fn direnv_bin() -> std::ffi::OsString {
std::env::var_os("RK_DIRENV_BIN")
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "direnv".into())
}
#[must_use]
pub fn nix() -> ProbeResult {
tool(
"nix",
"RK_NIX_BIN",
"nix",
"Nix; rk self-depend sync updates and builds the pinned devshell with it",
&["--version"],
)
}
#[must_use]
pub fn direnv() -> ProbeResult {
tool(
"direnv",
"RK_DIRENV_BIN",
"direnv",
"direnv; it loads the devshell on directory entry",
&["version"],
)
}
#[must_use]
pub fn sh_bin() -> std::ffi::OsString {
crate::requirements::Tool::Sh.program()
}
#[must_use]
pub fn git_cliff_bin() -> std::ffi::OsString {
crate::requirements::Tool::GitCliff.program()
}
#[must_use]
pub fn uv_bin() -> std::ffi::OsString {
crate::requirements::Tool::Uv.program()
}
#[must_use]
pub fn run_all() -> Vec<ProbeResult> {
let mut probes = vec![
shell(),
git(),
state_root(),
skill_roots(),
skill_gate(),
skill_sources(),
git_remote(),
forge_cli(
"gh-auth",
"RK_GH_BIN",
"gh",
"the GitHub CLI",
"gh auth login",
&[&["auth", "status", "--active"]],
),
forge_cli(
"glab-auth",
"RK_GLAB_BIN",
"glab",
"the GitLab CLI",
"glab auth login",
&[&["auth", "status"]],
),
forge_cli_floor(Forge::Github),
forge_cli_floor(Forge::Gitlab),
registry(),
nix(),
direnv(),
tool(
"cosign",
"RK_COSIGN_BIN",
"cosign",
"cosign; the release verify step checks a GitLab provenance bundle with it",
&["version"],
),
tool(
"pypi-attestations",
"RK_PYPI_ATTESTATIONS_BIN",
"pypi-attestations",
"pypi-attestations; the release verify step checks a PyPI distribution's attestations with it",
&["--help"],
),
];
probes.extend(hook_probes());
probes
}
fn hook_probes() -> Vec<ProbeResult> {
let Ok(cwd) = std::env::current_dir() else {
return Vec::new();
};
let Some(installed) = crate::hooks::installed(&cwd) else {
return Vec::new();
};
let names = |filter: &dyn Fn(crate::hooks::Writer) -> bool| -> Vec<&'static str> {
installed
.iter()
.filter(|hook| filter(hook.writer))
.map(|hook| hook.event)
.collect()
};
let mut probes = vec![if installed
.iter()
.any(|hook| hook.event == "pre-merge-commit")
{
ProbeResult::ok(
"pre-merge-commit-hook",
ProbeClass::Soft,
"a pre-merge-commit hook is installed, the gate rk integrate asks git to run",
)
} else {
ProbeResult::failed(
"pre-merge-commit-hook",
ProbeClass::Soft,
"no pre-merge-commit hook is installed, so rk integrate has no gate to run in this clone",
"install it with the project's hook manager, for example prek install --hook-type pre-merge-commit",
)
}];
for (id, writer) in [
("prek", crate::hooks::Writer::Prek),
("pre-commit", crate::hooks::Writer::PreCommit),
] {
let hooks = names(&|found| found == writer);
if hooks.is_empty() {
continue;
}
let answers = Command::new(id)
.arg("--version")
.output()
.is_ok_and(|out| out.status.success());
let wrote = hooks.join(", ");
probes.push(if answers {
ProbeResult::ok(id, ProbeClass::Soft, format!("{id} runs; it wrote the {wrote} hooks"))
} else {
ProbeResult::failed(
id,
ProbeClass::Soft,
format!("{id} does not run, and the {wrote} hooks call it"),
format!("install {id} on the host, or reinstall the hooks with the project's hook manager"),
)
});
}
let configured = names(&|found| found == crate::hooks::Writer::Configured);
if !configured.is_empty() {
let mut events = configured.clone();
events.dedup();
probes.push(ProbeResult::ok(
"configured-hooks",
ProbeClass::Soft,
format!(
"git's configuration defines {} hooks for {}",
configured.len(),
events.join(", ")
),
));
}
let own = names(&|found| found == crate::hooks::Writer::Own);
if !own.is_empty() {
probes.push(ProbeResult::ok(
"own-hooks",
ProbeClass::Soft,
format!(
"the {} hooks are the project's own, written by no known hook manager",
own.join(", ")
),
));
}
probes
}
fn tool(
id: &'static str,
env_override: &str,
default_bin: &str,
label: &str,
args: &[&str],
) -> ProbeResult {
let bin = std::env::var(env_override)
.ok()
.filter(|value| !value.is_empty())
.unwrap_or_else(|| default_bin.to_owned());
match Command::new(&bin).args(args).output() {
Ok(out) if out.status.success() => {
ProbeResult::ok(id, ProbeClass::Soft, format!("{default_bin} runs"))
}
Ok(_) => ProbeResult::failed(
id,
ProbeClass::Soft,
format!("{default_bin} does not answer {}", args.join(" ")),
format!("repair {label}"),
),
Err(_) => ProbeResult::failed(
id,
ProbeClass::Soft,
format!("{default_bin} is not on PATH"),
format!("install {label}"),
),
}
}
fn registry() -> ProbeResult {
let id = "registry";
let answered = crate::http::send(
&crate::http::Request::get(
"https://index.crates.io/config.json",
std::time::Duration::from_secs(5),
)
.following(),
)
.is_ok_and(|answer| answer.ok());
if answered {
ProbeResult::ok(id, ProbeClass::Soft, "the crates.io index answers")
} else {
ProbeResult::failed(
id,
ProbeClass::Soft,
"the crates.io index does not answer",
"reach the network before rk versions --check or rk self-depend sync; every offline verb runs without it",
)
}
}
fn shell() -> ProbeResult {
let id = "sh";
match Command::new(sh_bin()).args(["-c", "exit 0"]).status() {
Ok(status) if status.success() => ProbeResult::ok(id, ProbeClass::Hard, "sh runs"),
Ok(status) => ProbeResult::failed(
id,
ProbeClass::Hard,
format!("sh exited {status}"),
"repair the POSIX shell on PATH",
),
Err(source) => ProbeResult::failed(
id,
ProbeClass::Hard,
format!("sh does not spawn: {source}"),
"install a POSIX shell on PATH",
),
}
}
fn git() -> ProbeResult {
let id = "git";
match Command::new(git_bin()).arg("--version").output() {
Ok(out) if out.status.success() => ProbeResult::ok(id, ProbeClass::Hard, "git runs"),
Ok(_) => ProbeResult::failed(
id,
ProbeClass::Hard,
"git does not answer --version",
"repair the git on PATH, or point RK_GIT_BIN at a working one",
),
Err(_) => ProbeResult::failed(id, ProbeClass::Hard, "git is not on PATH", "install git"),
}
}
fn state_root() -> ProbeResult {
let id = "state-root";
let Some(root) = crate::applog::state_root() else {
return ProbeResult::failed(
id,
ProbeClass::Hard,
"neither XDG_STATE_HOME nor HOME is set",
"export HOME, or XDG_STATE_HOME",
);
};
let display = root.display().to_string();
let probe = root.join(format!(".probe-{}", std::process::id()));
let written = std::fs::create_dir_all(&root).and_then(|()| std::fs::write(&probe, b"probe"));
let _ = std::fs::remove_file(&probe);
match written {
Ok(()) => ProbeResult::ok(id, ProbeClass::Hard, format!("{display} is writable")),
Err(source) => ProbeResult::failed(
id,
ProbeClass::Hard,
format!("{display} is not writable: {source}"),
format!("make {display} writable"),
),
}
}
fn skill_roots() -> ProbeResult {
let id = SKILL_PROBES[0];
let Ok(home) = crate::skills::home() else {
return ProbeResult::failed(
id,
ProbeClass::Soft,
"neither HOME nor USERPROFILE is set, so no skill root resolves",
"export HOME",
);
};
let mut refused = Vec::new();
for root in [CLAUDE_ROOT, AGENTS_ROOT] {
let root = home.join(root);
let Some(existing) = nearest_existing(&root) else {
refused.push(format!("no ancestor of {root} exists"));
continue;
};
if let Err(source) = accepts_a_write(&existing) {
refused.push(format!("{existing} is not writable: {source}"));
}
}
if refused.is_empty() {
ProbeResult::ok(
id,
ProbeClass::Soft,
format!("the skill roots under {home} accept writes"),
)
} else {
ProbeResult::failed(
id,
ProbeClass::Soft,
refused.join("; "),
format!("make the skill roots under {home} writable"),
)
}
}
fn skill_gate() -> ProbeResult {
let id = SKILL_PROBES[1];
let Some(context) = installed_context(id) else {
return no_home(id);
};
let (home, packages, record) = match context {
Ok(context) => context,
Err(failed) => return failed,
};
let planned = installed_files(&home, &packages, true);
if planned.is_empty() {
return ProbeResult::failed(
id,
ProbeClass::Soft,
format!("no agent skill root exists under {home}"),
"rk skill install --apply",
);
}
let found = judge(planned, &record);
if let Some(first) = found.missing.first() {
return ProbeResult::failed(
id,
ProbeClass::Soft,
format!("a gate every skill reads before acting is missing from its package: {first}"),
"rk skill install --apply",
);
}
if !found.differing.is_empty() {
return ProbeResult::failed(
id,
ProbeClass::Soft,
format!(
"{} installed gate copies are not this binary's; rk is {}",
found.differing.len(),
env!("CARGO_PKG_VERSION")
),
reinstall(found.all_recorded),
);
}
ProbeResult::ok(
id,
ProbeClass::Soft,
format!("{} installed gate copies are this binary's", found.matching),
)
}
fn skill_sources() -> ProbeResult {
let id = SKILL_PROBES[2];
let Some(context) = installed_context(id) else {
return no_home(id);
};
let (home, packages, record) = match context {
Ok(context) => context,
Err(failed) => return failed,
};
let planned = installed_files(&home, &packages, false);
if planned.is_empty() {
return ProbeResult::failed(
id,
ProbeClass::Soft,
format!("no agent skill root exists under {home}"),
"rk skill install --apply",
);
}
let found = judge(planned, &record);
if let Some(first) = found.missing.first() {
return ProbeResult::failed(
id,
ProbeClass::Soft,
format!(
"{} of this binary's skill files are not installed, the first at {first}",
found.missing.len()
),
"rk skill install --apply",
);
}
if !found.differing.is_empty() {
return ProbeResult::failed(
id,
ProbeClass::Soft,
format!(
"{} installed skill file(s) are not this binary's; rk is {}",
found.differing.len(),
env!("CARGO_PKG_VERSION")
),
reinstall(found.all_recorded),
);
}
ProbeResult::ok(
id,
ProbeClass::Soft,
format!(
"{} installed skill destination(s) are this binary's",
found.matching
),
)
}
type SkillContext = (Utf8PathBuf, Vec<crate::skills::Package>, Record);
fn installed_context(id: &'static str) -> Option<Result<SkillContext, ProbeResult>> {
let home = crate::skills::home().ok()?;
let Ok(packages) = crate::skills::packages() else {
return Some(Err(ProbeResult::failed(
id,
ProbeClass::Soft,
"this binary's embedded skills do not read",
"reinstall rk; the sources it was built from are defective",
)));
};
let record = Record::load(&home.join(RECORD_PATH));
Some(Ok((home, packages, record)))
}
fn no_home(id: &'static str) -> ProbeResult {
ProbeResult::failed(
id,
ProbeClass::Soft,
"neither HOME nor USERPROFILE is set, so no agent root resolves",
"export HOME",
)
}
fn installed_files(
home: &Utf8Path,
packages: &[crate::skills::Package],
shared: bool,
) -> Vec<(Utf8PathBuf, &'static [u8])> {
let mut planned = Vec::new();
for root in [CLAUDE_ROOT, AGENTS_ROOT] {
let root = home.join(root);
if !root.is_dir() {
continue;
}
for package in packages {
for file in package.files.iter().filter(|file| file.shared == shared) {
planned.push((root.join(&package.name).join(&file.path), file.bytes));
}
}
}
planned
}
struct Installed {
missing: Vec<Utf8PathBuf>,
differing: Vec<Utf8PathBuf>,
matching: usize,
all_recorded: bool,
}
fn judge(planned: Vec<(Utf8PathBuf, &'static [u8])>, record: &Record) -> Installed {
let mut found = Installed {
missing: Vec::new(),
differing: Vec::new(),
matching: 0,
all_recorded: true,
};
for (destination, bytes) in planned {
match std::fs::read(&destination) {
Ok(held) if held == bytes => found.matching += 1,
Ok(held) => {
if !record.wrote(&destination, &Digest::of(&held)) {
found.all_recorded = false;
}
found.differing.push(destination);
}
Err(_) => found.missing.push(destination),
}
}
found
}
const fn reinstall(all_recorded: bool) -> &'static str {
if all_recorded {
"rk skill install --apply"
} else {
"rk skill install --apply --force"
}
}
fn nearest_existing(path: &Utf8Path) -> Option<Utf8PathBuf> {
let mut current = Some(path);
while let Some(dir) = current {
if dir.is_dir() {
return Some(dir.to_owned());
}
current = dir.parent();
}
None
}
fn accepts_a_write(dir: &Utf8Path) -> std::io::Result<()> {
let probe = dir.join(format!(".rk-probe-{}", std::process::id()));
let written = std::fs::write(&probe, b"probe");
let _ = std::fs::remove_file(&probe);
written
}
fn git_remote() -> ProbeResult {
let id = "git-remote";
let out = Command::new(git_bin())
.args(["remote", "get-url", "origin"])
.output();
let url = match out {
Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_owned(),
_ => {
return ProbeResult::failed(
id,
ProbeClass::Soft,
"the working directory has no origin remote",
"pass --repo <owner/name> where a command needs the slug",
);
}
};
remote_host(&url).map_or_else(
|| {
ProbeResult::failed(
id,
ProbeClass::Soft,
"the origin remote does not parse to a host",
"pass --repo <owner/name> where a command needs the slug",
)
},
|host| ProbeResult::ok(id, ProbeClass::Soft, format!("origin resolves to {host}")),
)
}
fn remote_host(url: &str) -> Option<String> {
if let Some(rest) = url.split_once("://").map(|(_, rest)| rest) {
let authority = rest.split('/').next()?;
let host = authority
.rsplit_once('@')
.map_or(authority, |(_, host)| host);
let host = host.split(':').next()?;
return (!host.is_empty()).then(|| host.to_owned());
}
let (authority, path) = url.split_once(':')?;
let host = authority
.rsplit_once('@')
.map_or(authority, |(_, host)| host);
(!host.is_empty() && !path.is_empty()).then(|| host.to_owned())
}
fn forge_cli(
id: &'static str,
env_override: &str,
default_bin: &str,
label: &str,
login: &str,
attempts: &[&[&str]],
) -> ProbeResult {
let bin = std::env::var(env_override)
.ok()
.filter(|value| !value.is_empty())
.unwrap_or_else(|| default_bin.to_owned());
let mut spawned = false;
for args in attempts {
match Command::new(&bin).args(*args).output() {
Ok(out) if out.status.success() => {
return ProbeResult::ok(
id,
ProbeClass::Soft,
format!("{default_bin} is authenticated"),
);
}
Ok(_) => spawned = true,
Err(_) => {}
}
}
if spawned {
ProbeResult::failed(
id,
ProbeClass::Soft,
format!("{default_bin} is not authenticated"),
format!("run {login}"),
)
} else {
ProbeResult::failed(
id,
ProbeClass::Soft,
format!("{default_bin} is not on PATH"),
format!("install {label}"),
)
}
}
#[must_use]
pub fn forge_bin(forge: Forge) -> String {
crate::requirements::Tool::ForgeCli(forge)
.program()
.to_string_lossy()
.into_owned()
}
const fn version_probe_id(forge: Forge) -> &'static str {
match forge {
Forge::Github => "gh-version",
Forge::Gitlab => "glab-version",
}
}
#[must_use]
pub fn parse_cli_version(text: &str) -> Option<(u32, u32, u32)> {
let bytes = text.as_bytes();
let mut start = 0;
while start < bytes.len() {
if !bytes[start].is_ascii_digit() {
start += 1;
continue;
}
let mut end = start;
while end < bytes.len() && (bytes[end].is_ascii_digit() || bytes[end] == b'.') {
end += 1;
}
let run = &text[start..end];
let mut parts = run.split('.');
let parsed = (|| {
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
Some((major, minor, patch))
})();
if let Some(version) = parsed {
return Some(version);
}
start = end.max(start + 1);
}
None
}
#[must_use]
pub fn forge_cli_version(bin: &str) -> Option<(u32, u32, u32)> {
let out = Command::new(bin).arg("--version").output().ok()?;
if !out.status.success() {
return None;
}
parse_cli_version(&String::from_utf8_lossy(&out.stdout))
}
fn forge_cli_floor(forge: Forge) -> ProbeResult {
let id = version_probe_id(forge);
let bin = forge_bin(forge);
let name = forge.cli();
let floor = forge.cli_floor();
match forge_cli_version(&bin) {
Some(found) if found >= floor => ProbeResult::ok(
id,
ProbeClass::Soft,
format!("{name} {} is at or above {}", show(found), show(floor)),
),
Some(found) => ProbeResult::failed(
id,
ProbeClass::Soft,
format!(
"{name} {} is below the {} rk calls",
show(found),
show(floor)
),
forge.cli_upgrade(),
),
None => ProbeResult::failed(
id,
ProbeClass::Soft,
format!("{name} does not answer --version with a version"),
format!("install {name}"),
),
}
}
fn show((major, minor, patch): (u32, u32, u32)) -> String {
format!("{major}.{minor}.{patch}")
}
#[cfg(test)]
mod tests {
use super::{parse_cli_version, remote_host};
#[test]
fn a_version_line_parses_from_both_forge_clis() {
assert_eq!(
parse_cli_version("gh version 2.19.0 (2022-10-25)"),
Some((2, 19, 0))
);
assert_eq!(
parse_cli_version("glab 1.114.0 (4d7c6cd)\n"),
Some((1, 114, 0))
);
assert_eq!(parse_cli_version("gh version 2.99.0"), Some((2, 99, 0)));
assert_eq!(parse_cli_version("no version here"), None);
assert_eq!(parse_cli_version("gh version 2.19"), None);
}
#[test]
fn a_floor_comparison_orders_by_component() {
assert!((2, 100, 0) > (2, 99, 0));
assert!((2, 9, 0) < (2, 19, 0));
assert!((2, 19, 0) >= (2, 19, 0));
}
#[test]
fn a_remote_host_parses_from_both_url_forms() {
assert_eq!(
remote_host("https://github.com/owner/name.git").as_deref(),
Some("github.com")
);
assert_eq!(
remote_host("git@gitlab.com:group/sub/name.git").as_deref(),
Some("gitlab.com")
);
assert_eq!(
remote_host("ssh://git@github.com:22/owner/name.git").as_deref(),
Some("github.com")
);
assert_eq!(remote_host("not a url"), None);
}
}