1use std::process::Command;
11
12use camino::{Utf8Path, Utf8PathBuf};
13use serde::Serialize;
14
15use crate::skills::record::{RECORD_PATH, Record};
16use crate::skills::{AGENTS_ROOT, CLAUDE_ROOT, Digest, SHARED_ROOT};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
20#[serde(rename_all = "kebab-case")]
21pub enum ProbeClass {
22 Hard,
24 Soft,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
30#[serde(rename_all = "kebab-case")]
31pub enum ProbeStatus {
32 Ok,
34 Failed,
36}
37
38#[derive(Debug, Serialize)]
40pub struct ProbeResult {
41 pub id: &'static str,
43 pub class: ProbeClass,
45 pub status: ProbeStatus,
47 pub message: String,
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub remediation: Option<String>,
52}
53
54impl ProbeResult {
55 fn ok(id: &'static str, class: ProbeClass, message: impl Into<String>) -> Self {
56 Self {
57 id,
58 class,
59 status: ProbeStatus::Ok,
60 message: message.into(),
61 remediation: None,
62 }
63 }
64
65 fn failed(
66 id: &'static str,
67 class: ProbeClass,
68 message: impl Into<String>,
69 remediation: impl Into<String>,
70 ) -> Self {
71 Self {
72 id,
73 class,
74 status: ProbeStatus::Failed,
75 message: message.into(),
76 remediation: Some(remediation.into()),
77 }
78 }
79}
80
81pub const SKILL_PROBES: [&str; 3] = ["skill-roots", "skill-gate", "skill-payload"];
88
89pub const HARD_RUNTIME_TOOLS: [(&str, &str); 2] = [("git", "git"), ("sh", "bash")];
97
98#[must_use]
104pub fn git_bin() -> std::ffi::OsString {
105 std::env::var_os("RK_GIT_BIN").unwrap_or_else(|| "git".into())
106}
107
108#[must_use]
113pub fn sh_bin() -> std::ffi::OsString {
114 std::env::var_os("RK_SH_BIN").unwrap_or_else(|| "sh".into())
115}
116
117#[must_use]
119pub fn run_all() -> Vec<ProbeResult> {
120 vec![
121 shell(),
122 git(),
123 state_root(),
124 skill_roots(),
125 skill_gate(),
126 skill_payload(),
127 git_remote(),
128 forge_cli(
129 "gh-auth",
130 "RK_GH_BIN",
131 "gh",
132 "the GitHub CLI",
133 "gh auth login",
134 &[&["auth", "status", "--active"], &["auth", "status"]],
139 ),
140 forge_cli(
141 "glab-auth",
142 "RK_GLAB_BIN",
143 "glab",
144 "the GitLab CLI",
145 "glab auth login",
146 &[&["auth", "status"]],
147 ),
148 tool(
149 "openssl",
150 "RK_OPENSSL_BIN",
151 "openssl",
152 "OpenSSL; install-bot signs the App JWT with it",
153 &["version"],
154 ),
155 tool(
156 "curl",
157 "RK_CURL_BIN",
158 "curl",
159 "curl; install-bot reads the installation and rk versions --check fetches with it",
160 &["--version"],
161 ),
162 tool(
163 "cosign",
164 "RK_COSIGN_BIN",
165 "cosign",
166 "cosign; the release verify step checks a GitLab provenance bundle with it",
167 &["version"],
168 ),
169 tool(
170 "pypi-attestations",
171 "RK_PYPI_ATTESTATIONS_BIN",
172 "pypi-attestations",
173 "pypi-attestations; the release verify step checks a PyPI distribution's attestations with it",
174 &["--help"],
175 ),
176 ]
177}
178
179fn tool(
183 id: &'static str,
184 env_override: &str,
185 default_bin: &str,
186 label: &str,
187 args: &[&str],
188) -> ProbeResult {
189 let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
190 match Command::new(&bin).args(args).output() {
191 Ok(out) if out.status.success() => {
192 ProbeResult::ok(id, ProbeClass::Soft, format!("{default_bin} runs"))
193 }
194 Ok(_) => ProbeResult::failed(
195 id,
196 ProbeClass::Soft,
197 format!("{default_bin} does not answer {}", args.join(" ")),
198 format!("repair {label}"),
199 ),
200 Err(_) => ProbeResult::failed(
201 id,
202 ProbeClass::Soft,
203 format!("{default_bin} is not on PATH"),
204 format!("install {label}"),
205 ),
206 }
207}
208
209fn shell() -> ProbeResult {
211 let id = "sh";
212 match Command::new(sh_bin()).args(["-c", "exit 0"]).status() {
213 Ok(status) if status.success() => ProbeResult::ok(id, ProbeClass::Hard, "sh runs"),
214 Ok(status) => ProbeResult::failed(
215 id,
216 ProbeClass::Hard,
217 format!("sh exited {status}"),
218 "repair the POSIX shell on PATH",
219 ),
220 Err(source) => ProbeResult::failed(
221 id,
222 ProbeClass::Hard,
223 format!("sh does not spawn: {source}"),
224 "install a POSIX shell on PATH",
225 ),
226 }
227}
228
229fn git() -> ProbeResult {
232 let id = "git";
233 match Command::new(git_bin()).arg("--version").output() {
234 Ok(out) if out.status.success() => ProbeResult::ok(id, ProbeClass::Hard, "git runs"),
235 Ok(_) => ProbeResult::failed(
236 id,
237 ProbeClass::Hard,
238 "git does not answer --version",
239 "repair the git on PATH, or point RK_GIT_BIN at a working one",
240 ),
241 Err(_) => ProbeResult::failed(id, ProbeClass::Hard, "git is not on PATH", "install git"),
242 }
243}
244
245fn state_root() -> ProbeResult {
248 let id = "state-root";
249 let Some(root) = crate::applog::state_root() else {
250 return ProbeResult::failed(
251 id,
252 ProbeClass::Hard,
253 "neither XDG_STATE_HOME nor HOME is set",
254 "export HOME, or XDG_STATE_HOME",
255 );
256 };
257 let display = root.display().to_string();
258 let probe = root.join(format!(".probe-{}", std::process::id()));
259 let written = std::fs::create_dir_all(&root).and_then(|()| std::fs::write(&probe, b"probe"));
260 let _ = std::fs::remove_file(&probe);
261 match written {
262 Ok(()) => ProbeResult::ok(id, ProbeClass::Hard, format!("{display} is writable")),
263 Err(source) => ProbeResult::failed(
264 id,
265 ProbeClass::Hard,
266 format!("{display} is not writable: {source}"),
267 format!("make {display} writable"),
268 ),
269 }
270}
271
272fn skill_roots() -> ProbeResult {
282 let id = SKILL_PROBES[0];
283 let Ok(home) = crate::skills::home() else {
284 return ProbeResult::failed(
285 id,
286 ProbeClass::Soft,
287 "neither HOME nor USERPROFILE is set, so no skill root resolves",
288 "export HOME",
289 );
290 };
291 let mut refused = Vec::new();
292 for root in [CLAUDE_ROOT, AGENTS_ROOT, SHARED_ROOT] {
293 let root = home.join(root);
294 let Some(existing) = nearest_existing(&root) else {
295 refused.push(format!("no ancestor of {root} exists"));
296 continue;
297 };
298 if let Err(source) = accepts_a_write(&existing) {
299 refused.push(format!("{existing} is not writable: {source}"));
300 }
301 }
302 if refused.is_empty() {
303 ProbeResult::ok(
304 id,
305 ProbeClass::Soft,
306 format!("the skill roots under {home} accept writes"),
307 )
308 } else {
309 ProbeResult::failed(
310 id,
311 ProbeClass::Soft,
312 refused.join("; "),
313 format!("make the skill roots under {home} writable"),
314 )
315 }
316}
317
318fn skill_gate() -> ProbeResult {
328 let id = SKILL_PROBES[1];
329 let Ok(home) = crate::skills::home() else {
330 return ProbeResult::failed(
331 id,
332 ProbeClass::Soft,
333 "neither HOME nor USERPROFILE is set, so the shared root does not resolve",
334 "export HOME",
335 );
336 };
337 let root = home.join(SHARED_ROOT);
338 let record = Record::load(&home.join(RECORD_PATH));
339 let planned: Vec<(Utf8PathBuf, &'static [u8])> = crate::skills::shared()
340 .into_iter()
341 .map(|artifact| (root.join(&artifact.path), artifact.bytes))
342 .collect();
343 let found = judge(planned, &record);
344 if let Some(first) = found.missing.first() {
345 return ProbeResult::failed(
346 id,
347 ProbeClass::Soft,
348 format!("a shared artifact every skill reads before acting is not installed: {first}"),
349 "rk skill install --apply",
350 );
351 }
352 if !found.differing.is_empty() {
353 return ProbeResult::failed(
354 id,
355 ProbeClass::Soft,
356 format!(
357 "{} shared artifact(s) under {root} are not this binary's",
358 found.differing.len()
359 ),
360 reinstall(found.all_recorded),
361 );
362 }
363 ProbeResult::ok(
364 id,
365 ProbeClass::Soft,
366 format!("{root} holds this binary's shared artifacts"),
367 )
368}
369
370fn skill_payload() -> ProbeResult {
378 let id = SKILL_PROBES[2];
379 let Ok(home) = crate::skills::home() else {
380 return ProbeResult::failed(
381 id,
382 ProbeClass::Soft,
383 "neither HOME nor USERPROFILE is set, so no agent root resolves",
384 "export HOME",
385 );
386 };
387 let Ok(skills) = crate::skills::all() else {
388 return ProbeResult::failed(
389 id,
390 ProbeClass::Soft,
391 "this binary's embedded skills do not read",
392 "reinstall rk; the payload it was built from is defective",
393 );
394 };
395 let record = Record::load(&home.join(RECORD_PATH));
396 let mut planned = Vec::new();
397 for root in [CLAUDE_ROOT, AGENTS_ROOT] {
398 let root = home.join(root);
399 if !root.is_dir() {
402 continue;
403 }
404 for skill in &skills {
405 planned.push((
406 root.join(&skill.name).join("SKILL.md"),
407 skill.text.as_bytes(),
408 ));
409 }
410 }
411 if planned.is_empty() {
412 return ProbeResult::failed(
413 id,
414 ProbeClass::Soft,
415 format!("no agent skill root exists under {home}"),
416 "rk skill install --apply",
417 );
418 }
419 let found = judge(planned, &record);
420 if let Some(first) = found.missing.first() {
421 return ProbeResult::failed(
422 id,
423 ProbeClass::Soft,
424 format!(
425 "{} of this binary's skills are not installed, the first at {first}",
426 found.missing.len()
427 ),
428 "rk skill install --apply",
429 );
430 }
431 if !found.differing.is_empty() {
432 return ProbeResult::failed(
433 id,
434 ProbeClass::Soft,
435 format!(
436 "{} installed skill(s) are not this binary's; rk is {}",
437 found.differing.len(),
438 env!("CARGO_PKG_VERSION")
439 ),
440 reinstall(found.all_recorded),
441 );
442 }
443 ProbeResult::ok(
444 id,
445 ProbeClass::Soft,
446 format!(
447 "{} installed skill destination(s) are this binary's",
448 found.matching
449 ),
450 )
451}
452
453struct Installed {
455 missing: Vec<Utf8PathBuf>,
457 differing: Vec<Utf8PathBuf>,
459 matching: usize,
461 all_recorded: bool,
465}
466
467fn judge(planned: Vec<(Utf8PathBuf, &'static [u8])>, record: &Record) -> Installed {
469 let mut found = Installed {
470 missing: Vec::new(),
471 differing: Vec::new(),
472 matching: 0,
473 all_recorded: true,
474 };
475 for (destination, bytes) in planned {
476 match std::fs::read(&destination) {
477 Ok(held) if held == bytes => found.matching += 1,
478 Ok(held) => {
479 if !record.wrote(&destination, &Digest::of(&held)) {
480 found.all_recorded = false;
481 }
482 found.differing.push(destination);
483 }
484 Err(_) => found.missing.push(destination),
485 }
486 }
487 found
488}
489
490const fn reinstall(all_recorded: bool) -> &'static str {
494 if all_recorded {
495 "rk skill install --apply"
496 } else {
497 "rk skill install --apply --force"
498 }
499}
500
501fn nearest_existing(path: &Utf8Path) -> Option<Utf8PathBuf> {
504 let mut current = Some(path);
505 while let Some(dir) = current {
506 if dir.is_dir() {
507 return Some(dir.to_owned());
508 }
509 current = dir.parent();
510 }
511 None
512}
513
514fn accepts_a_write(dir: &Utf8Path) -> std::io::Result<()> {
516 let probe = dir.join(format!(".rk-probe-{}", std::process::id()));
517 let written = std::fs::write(&probe, b"probe");
518 let _ = std::fs::remove_file(&probe);
519 written
520}
521
522fn git_remote() -> ProbeResult {
525 let id = "git-remote";
526 let out = Command::new(git_bin())
527 .args(["remote", "get-url", "origin"])
528 .output();
529 let url = match out {
530 Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_owned(),
531 _ => {
532 return ProbeResult::failed(
533 id,
534 ProbeClass::Soft,
535 "the working directory has no origin remote",
536 "pass --repo <owner/name> where a command needs the slug",
537 );
538 }
539 };
540 remote_host(&url).map_or_else(
544 || {
545 ProbeResult::failed(
546 id,
547 ProbeClass::Soft,
548 "the origin remote does not parse to a host",
549 "pass --repo <owner/name> where a command needs the slug",
550 )
551 },
552 |host| ProbeResult::ok(id, ProbeClass::Soft, format!("origin resolves to {host}")),
553 )
554}
555
556fn remote_host(url: &str) -> Option<String> {
558 if let Some(rest) = url.split_once("://").map(|(_, rest)| rest) {
559 let authority = rest.split('/').next()?;
560 let host = authority
561 .rsplit_once('@')
562 .map_or(authority, |(_, host)| host);
563 let host = host.split(':').next()?;
564 return (!host.is_empty()).then(|| host.to_owned());
565 }
566 let (authority, path) = url.split_once(':')?;
567 let host = authority
568 .rsplit_once('@')
569 .map_or(authority, |(_, host)| host);
570 (!host.is_empty() && !path.is_empty()).then(|| host.to_owned())
571}
572
573fn forge_cli(
579 id: &'static str,
580 env_override: &str,
581 default_bin: &str,
582 label: &str,
583 login: &str,
584 attempts: &[&[&str]],
585) -> ProbeResult {
586 let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
587 let mut spawned = false;
588 for args in attempts {
589 match Command::new(&bin).args(*args).output() {
590 Ok(out) if out.status.success() => {
591 return ProbeResult::ok(
592 id,
593 ProbeClass::Soft,
594 format!("{default_bin} is authenticated"),
595 );
596 }
597 Ok(_) => spawned = true,
598 Err(_) => {}
599 }
600 }
601 if spawned {
602 ProbeResult::failed(
603 id,
604 ProbeClass::Soft,
605 format!("{default_bin} is not authenticated"),
606 format!("run {login}"),
607 )
608 } else {
609 ProbeResult::failed(
610 id,
611 ProbeClass::Soft,
612 format!("{default_bin} is not on PATH"),
613 format!("install {label}"),
614 )
615 }
616}
617
618#[cfg(test)]
619mod tests {
620 use super::remote_host;
621
622 #[test]
623 fn a_remote_host_parses_from_both_url_forms() {
624 assert_eq!(
625 remote_host("https://github.com/owner/name.git").as_deref(),
626 Some("github.com")
627 );
628 assert_eq!(
629 remote_host("git@gitlab.com:group/sub/name.git").as_deref(),
630 Some("gitlab.com")
631 );
632 assert_eq!(
633 remote_host("ssh://git@github.com:22/owner/name.git").as_deref(),
634 Some("github.com")
635 );
636 assert_eq!(remote_host("not a url"), None);
637 }
638}