1use std::process::Command;
11
12use camino::{Utf8Path, Utf8PathBuf};
13use serde::Serialize;
14
15use crate::detect::Forge;
16use crate::diagnostic::{Diagnostic, Reason};
17use crate::error::RkError;
18use crate::skills::record::{RECORD_PATH, Record};
19use crate::skills::{AGENTS_ROOT, CLAUDE_ROOT, Digest, SHARED_ROOT};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
23#[serde(rename_all = "kebab-case")]
24pub enum ProbeClass {
25 Hard,
27 Soft,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
33#[serde(rename_all = "kebab-case")]
34pub enum ProbeStatus {
35 Ok,
37 Failed,
39}
40
41#[derive(Debug, Serialize)]
43pub struct ProbeResult {
44 pub id: &'static str,
46 pub class: ProbeClass,
48 pub status: ProbeStatus,
50 pub message: String,
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub remediation: Option<String>,
55}
56
57impl ProbeResult {
58 fn ok(id: &'static str, class: ProbeClass, message: impl Into<String>) -> Self {
59 Self {
60 id,
61 class,
62 status: ProbeStatus::Ok,
63 message: message.into(),
64 remediation: None,
65 }
66 }
67
68 fn failed(
69 id: &'static str,
70 class: ProbeClass,
71 message: impl Into<String>,
72 remediation: impl Into<String>,
73 ) -> Self {
74 Self {
75 id,
76 class,
77 status: ProbeStatus::Failed,
78 message: message.into(),
79 remediation: Some(remediation.into()),
80 }
81 }
82}
83
84pub const SKILL_PROBES: [&str; 3] = ["skill-roots", "skill-gate", "skill-sources"];
91
92pub const HARD_RUNTIME_TOOLS: [(&str, &str); 2] = [("git", "git"), ("sh", "bash")];
100
101#[must_use]
107pub fn git_bin() -> std::ffi::OsString {
108 std::env::var_os("RK_GIT_BIN").unwrap_or_else(|| "git".into())
109}
110
111#[must_use]
114pub fn nix_bin() -> std::ffi::OsString {
115 std::env::var_os("RK_NIX_BIN").unwrap_or_else(|| "nix".into())
116}
117
118#[must_use]
122pub fn direnv_bin() -> std::ffi::OsString {
123 std::env::var_os("RK_DIRENV_BIN").unwrap_or_else(|| "direnv".into())
124}
125
126#[must_use]
130pub fn nix() -> ProbeResult {
131 tool(
132 "nix",
133 "RK_NIX_BIN",
134 "nix",
135 "Nix; rk self-depend sync updates and builds the pinned devshell with it",
136 &["--version"],
137 )
138}
139
140#[must_use]
142pub fn direnv() -> ProbeResult {
143 tool(
144 "direnv",
145 "RK_DIRENV_BIN",
146 "direnv",
147 "direnv; it loads the devshell on directory entry",
148 &["version"],
149 )
150}
151
152#[must_use]
157pub fn sh_bin() -> std::ffi::OsString {
158 std::env::var_os("RK_SH_BIN").unwrap_or_else(|| "sh".into())
159}
160
161#[must_use]
163pub fn run_all() -> Vec<ProbeResult> {
164 vec![
165 shell(),
166 git(),
167 state_root(),
168 skill_roots(),
169 skill_gate(),
170 skill_sources(),
171 git_remote(),
172 forge_cli(
173 "gh-auth",
174 "RK_GH_BIN",
175 "gh",
176 "the GitHub CLI",
177 "gh auth login",
178 &[&["auth", "status", "--active"], &["auth", "status"]],
183 ),
184 forge_cli(
185 "glab-auth",
186 "RK_GLAB_BIN",
187 "glab",
188 "the GitLab CLI",
189 "glab auth login",
190 &[&["auth", "status"]],
191 ),
192 forge_cli_floor(Forge::Github),
193 forge_cli_floor(Forge::Gitlab),
194 tool(
195 "openssl",
196 "RK_OPENSSL_BIN",
197 "openssl",
198 "OpenSSL; install-bot signs the App JWT with it",
199 &["version"],
200 ),
201 tool(
202 "curl",
203 "RK_CURL_BIN",
204 "curl",
205 "curl; install-bot reads the installation, and rk versions --check and rk self-depend sync fetch with it",
206 &["--version"],
207 ),
208 registry(),
209 nix(),
210 direnv(),
211 tool(
212 "cosign",
213 "RK_COSIGN_BIN",
214 "cosign",
215 "cosign; the release verify step checks a GitLab provenance bundle with it",
216 &["version"],
217 ),
218 tool(
219 "pypi-attestations",
220 "RK_PYPI_ATTESTATIONS_BIN",
221 "pypi-attestations",
222 "pypi-attestations; the release verify step checks a PyPI distribution's attestations with it",
223 &["--help"],
224 ),
225 ]
226}
227
228fn tool(
232 id: &'static str,
233 env_override: &str,
234 default_bin: &str,
235 label: &str,
236 args: &[&str],
237) -> ProbeResult {
238 let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
239 match Command::new(&bin).args(args).output() {
240 Ok(out) if out.status.success() => {
241 ProbeResult::ok(id, ProbeClass::Soft, format!("{default_bin} runs"))
242 }
243 Ok(_) => ProbeResult::failed(
244 id,
245 ProbeClass::Soft,
246 format!("{default_bin} does not answer {}", args.join(" ")),
247 format!("repair {label}"),
248 ),
249 Err(_) => ProbeResult::failed(
250 id,
251 ProbeClass::Soft,
252 format!("{default_bin} is not on PATH"),
253 format!("install {label}"),
254 ),
255 }
256}
257
258fn registry() -> ProbeResult {
263 let id = "registry";
264 let curl = std::env::var_os("RK_CURL_BIN").unwrap_or_else(|| "curl".into());
265 let answered = Command::new(curl)
272 .args([
273 "-fsSL",
274 "--max-time",
275 "5",
276 "https://index.crates.io/config.json",
277 ])
278 .output()
279 .is_ok_and(|answer| answer.status.success());
280 if answered {
281 ProbeResult::ok(id, ProbeClass::Soft, "the crates.io index answers")
282 } else {
283 ProbeResult::failed(
284 id,
285 ProbeClass::Soft,
286 "the crates.io index does not answer",
287 "reach the network before rk versions --check or rk self-depend sync; every offline verb runs without it",
288 )
289 }
290}
291
292fn shell() -> ProbeResult {
294 let id = "sh";
295 match Command::new(sh_bin()).args(["-c", "exit 0"]).status() {
296 Ok(status) if status.success() => ProbeResult::ok(id, ProbeClass::Hard, "sh runs"),
297 Ok(status) => ProbeResult::failed(
298 id,
299 ProbeClass::Hard,
300 format!("sh exited {status}"),
301 "repair the POSIX shell on PATH",
302 ),
303 Err(source) => ProbeResult::failed(
304 id,
305 ProbeClass::Hard,
306 format!("sh does not spawn: {source}"),
307 "install a POSIX shell on PATH",
308 ),
309 }
310}
311
312fn git() -> ProbeResult {
315 let id = "git";
316 match Command::new(git_bin()).arg("--version").output() {
317 Ok(out) if out.status.success() => ProbeResult::ok(id, ProbeClass::Hard, "git runs"),
318 Ok(_) => ProbeResult::failed(
319 id,
320 ProbeClass::Hard,
321 "git does not answer --version",
322 "repair the git on PATH, or point RK_GIT_BIN at a working one",
323 ),
324 Err(_) => ProbeResult::failed(id, ProbeClass::Hard, "git is not on PATH", "install git"),
325 }
326}
327
328fn state_root() -> ProbeResult {
331 let id = "state-root";
332 let Some(root) = crate::applog::state_root() else {
333 return ProbeResult::failed(
334 id,
335 ProbeClass::Hard,
336 "neither XDG_STATE_HOME nor HOME is set",
337 "export HOME, or XDG_STATE_HOME",
338 );
339 };
340 let display = root.display().to_string();
341 let probe = root.join(format!(".probe-{}", std::process::id()));
342 let written = std::fs::create_dir_all(&root).and_then(|()| std::fs::write(&probe, b"probe"));
343 let _ = std::fs::remove_file(&probe);
344 match written {
345 Ok(()) => ProbeResult::ok(id, ProbeClass::Hard, format!("{display} is writable")),
346 Err(source) => ProbeResult::failed(
347 id,
348 ProbeClass::Hard,
349 format!("{display} is not writable: {source}"),
350 format!("make {display} writable"),
351 ),
352 }
353}
354
355fn skill_roots() -> ProbeResult {
365 let id = SKILL_PROBES[0];
366 let Ok(home) = crate::skills::home() else {
367 return ProbeResult::failed(
368 id,
369 ProbeClass::Soft,
370 "neither HOME nor USERPROFILE is set, so no skill root resolves",
371 "export HOME",
372 );
373 };
374 let mut refused = Vec::new();
375 for root in [CLAUDE_ROOT, AGENTS_ROOT, SHARED_ROOT] {
376 let root = home.join(root);
377 let Some(existing) = nearest_existing(&root) else {
378 refused.push(format!("no ancestor of {root} exists"));
379 continue;
380 };
381 if let Err(source) = accepts_a_write(&existing) {
382 refused.push(format!("{existing} is not writable: {source}"));
383 }
384 }
385 if refused.is_empty() {
386 ProbeResult::ok(
387 id,
388 ProbeClass::Soft,
389 format!("the skill roots under {home} accept writes"),
390 )
391 } else {
392 ProbeResult::failed(
393 id,
394 ProbeClass::Soft,
395 refused.join("; "),
396 format!("make the skill roots under {home} writable"),
397 )
398 }
399}
400
401fn skill_gate() -> ProbeResult {
411 let id = SKILL_PROBES[1];
412 let Ok(home) = crate::skills::home() else {
413 return ProbeResult::failed(
414 id,
415 ProbeClass::Soft,
416 "neither HOME nor USERPROFILE is set, so the shared root does not resolve",
417 "export HOME",
418 );
419 };
420 let root = home.join(SHARED_ROOT);
421 let record = Record::load(&home.join(RECORD_PATH));
422 let planned: Vec<(Utf8PathBuf, &'static [u8])> = crate::skills::shared()
423 .into_iter()
424 .map(|artifact| (root.join(&artifact.path), artifact.bytes))
425 .collect();
426 let found = judge(planned, &record);
427 if let Some(first) = found.missing.first() {
428 return ProbeResult::failed(
429 id,
430 ProbeClass::Soft,
431 format!("a shared artifact every skill reads before acting is not installed: {first}"),
432 "rk skill install --apply",
433 );
434 }
435 if !found.differing.is_empty() {
436 return ProbeResult::failed(
437 id,
438 ProbeClass::Soft,
439 format!(
440 "{} shared artifact(s) under {root} are not this binary's",
441 found.differing.len()
442 ),
443 reinstall(found.all_recorded),
444 );
445 }
446 ProbeResult::ok(
447 id,
448 ProbeClass::Soft,
449 format!("{root} holds this binary's shared artifacts"),
450 )
451}
452
453fn skill_sources() -> ProbeResult {
461 let id = SKILL_PROBES[2];
462 let Ok(home) = crate::skills::home() else {
463 return ProbeResult::failed(
464 id,
465 ProbeClass::Soft,
466 "neither HOME nor USERPROFILE is set, so no agent root resolves",
467 "export HOME",
468 );
469 };
470 let Ok(skills) = crate::skills::all() else {
471 return ProbeResult::failed(
472 id,
473 ProbeClass::Soft,
474 "this binary's embedded skills do not read",
475 "reinstall rk; the sources it was built from are defective",
476 );
477 };
478 let record = Record::load(&home.join(RECORD_PATH));
479 let mut planned = Vec::new();
480 for root in [CLAUDE_ROOT, AGENTS_ROOT] {
481 let root = home.join(root);
482 if !root.is_dir() {
485 continue;
486 }
487 for skill in &skills {
488 planned.push((
489 root.join(&skill.name).join("SKILL.md"),
490 skill.text.as_bytes(),
491 ));
492 }
493 }
494 if planned.is_empty() {
495 return ProbeResult::failed(
496 id,
497 ProbeClass::Soft,
498 format!("no agent skill root exists under {home}"),
499 "rk skill install --apply",
500 );
501 }
502 let found = judge(planned, &record);
503 if let Some(first) = found.missing.first() {
504 return ProbeResult::failed(
505 id,
506 ProbeClass::Soft,
507 format!(
508 "{} of this binary's skills are not installed, the first at {first}",
509 found.missing.len()
510 ),
511 "rk skill install --apply",
512 );
513 }
514 if !found.differing.is_empty() {
515 return ProbeResult::failed(
516 id,
517 ProbeClass::Soft,
518 format!(
519 "{} installed skill(s) are not this binary's; rk is {}",
520 found.differing.len(),
521 env!("CARGO_PKG_VERSION")
522 ),
523 reinstall(found.all_recorded),
524 );
525 }
526 ProbeResult::ok(
527 id,
528 ProbeClass::Soft,
529 format!(
530 "{} installed skill destination(s) are this binary's",
531 found.matching
532 ),
533 )
534}
535
536struct Installed {
538 missing: Vec<Utf8PathBuf>,
540 differing: Vec<Utf8PathBuf>,
542 matching: usize,
544 all_recorded: bool,
548}
549
550fn judge(planned: Vec<(Utf8PathBuf, &'static [u8])>, record: &Record) -> Installed {
552 let mut found = Installed {
553 missing: Vec::new(),
554 differing: Vec::new(),
555 matching: 0,
556 all_recorded: true,
557 };
558 for (destination, bytes) in planned {
559 match std::fs::read(&destination) {
560 Ok(held) if held == bytes => found.matching += 1,
561 Ok(held) => {
562 if !record.wrote(&destination, &Digest::of(&held)) {
563 found.all_recorded = false;
564 }
565 found.differing.push(destination);
566 }
567 Err(_) => found.missing.push(destination),
568 }
569 }
570 found
571}
572
573const fn reinstall(all_recorded: bool) -> &'static str {
577 if all_recorded {
578 "rk skill install --apply"
579 } else {
580 "rk skill install --apply --force"
581 }
582}
583
584fn nearest_existing(path: &Utf8Path) -> Option<Utf8PathBuf> {
587 let mut current = Some(path);
588 while let Some(dir) = current {
589 if dir.is_dir() {
590 return Some(dir.to_owned());
591 }
592 current = dir.parent();
593 }
594 None
595}
596
597fn accepts_a_write(dir: &Utf8Path) -> std::io::Result<()> {
599 let probe = dir.join(format!(".rk-probe-{}", std::process::id()));
600 let written = std::fs::write(&probe, b"probe");
601 let _ = std::fs::remove_file(&probe);
602 written
603}
604
605fn git_remote() -> ProbeResult {
608 let id = "git-remote";
609 let out = Command::new(git_bin())
610 .args(["remote", "get-url", "origin"])
611 .output();
612 let url = match out {
613 Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_owned(),
614 _ => {
615 return ProbeResult::failed(
616 id,
617 ProbeClass::Soft,
618 "the working directory has no origin remote",
619 "pass --repo <owner/name> where a command needs the slug",
620 );
621 }
622 };
623 remote_host(&url).map_or_else(
627 || {
628 ProbeResult::failed(
629 id,
630 ProbeClass::Soft,
631 "the origin remote does not parse to a host",
632 "pass --repo <owner/name> where a command needs the slug",
633 )
634 },
635 |host| ProbeResult::ok(id, ProbeClass::Soft, format!("origin resolves to {host}")),
636 )
637}
638
639fn remote_host(url: &str) -> Option<String> {
641 if let Some(rest) = url.split_once("://").map(|(_, rest)| rest) {
642 let authority = rest.split('/').next()?;
643 let host = authority
644 .rsplit_once('@')
645 .map_or(authority, |(_, host)| host);
646 let host = host.split(':').next()?;
647 return (!host.is_empty()).then(|| host.to_owned());
648 }
649 let (authority, path) = url.split_once(':')?;
650 let host = authority
651 .rsplit_once('@')
652 .map_or(authority, |(_, host)| host);
653 (!host.is_empty() && !path.is_empty()).then(|| host.to_owned())
654}
655
656fn forge_cli(
662 id: &'static str,
663 env_override: &str,
664 default_bin: &str,
665 label: &str,
666 login: &str,
667 attempts: &[&[&str]],
668) -> ProbeResult {
669 let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
670 let mut spawned = false;
671 for args in attempts {
672 match Command::new(&bin).args(*args).output() {
673 Ok(out) if out.status.success() => {
674 return ProbeResult::ok(
675 id,
676 ProbeClass::Soft,
677 format!("{default_bin} is authenticated"),
678 );
679 }
680 Ok(_) => spawned = true,
681 Err(_) => {}
682 }
683 }
684 if spawned {
685 ProbeResult::failed(
686 id,
687 ProbeClass::Soft,
688 format!("{default_bin} is not authenticated"),
689 format!("run {login}"),
690 )
691 } else {
692 ProbeResult::failed(
693 id,
694 ProbeClass::Soft,
695 format!("{default_bin} is not on PATH"),
696 format!("install {label}"),
697 )
698 }
699}
700
701#[must_use]
704pub fn forge_bin(forge: Forge) -> String {
705 std::env::var(forge.cli_override()).unwrap_or_else(|_| forge.cli().to_owned())
706}
707
708const fn version_probe_id(forge: Forge) -> &'static str {
710 match forge {
711 Forge::Github => "gh-version",
712 Forge::Gitlab => "glab-version",
713 }
714}
715
716#[must_use]
721pub fn parse_cli_version(text: &str) -> Option<(u32, u32, u32)> {
722 let bytes = text.as_bytes();
723 let mut start = 0;
724 while start < bytes.len() {
725 if !bytes[start].is_ascii_digit() {
726 start += 1;
727 continue;
728 }
729 let mut end = start;
730 while end < bytes.len() && (bytes[end].is_ascii_digit() || bytes[end] == b'.') {
731 end += 1;
732 }
733 let run = &text[start..end];
734 let mut parts = run.split('.');
735 let parsed = (|| {
736 let major = parts.next()?.parse().ok()?;
737 let minor = parts.next()?.parse().ok()?;
738 let patch = parts.next()?.parse().ok()?;
739 Some((major, minor, patch))
740 })();
741 if let Some(version) = parsed {
742 return Some(version);
743 }
744 start = end.max(start + 1);
745 }
746 None
747}
748
749#[must_use]
753pub fn forge_cli_version(bin: &str) -> Option<(u32, u32, u32)> {
754 let out = Command::new(bin).arg("--version").output().ok()?;
755 if !out.status.success() {
756 return None;
757 }
758 parse_cli_version(&String::from_utf8_lossy(&out.stdout))
759}
760
761fn forge_cli_floor(forge: Forge) -> ProbeResult {
765 let id = version_probe_id(forge);
766 let bin = forge_bin(forge);
767 let name = forge.cli();
768 let floor = forge.cli_floor();
769 match forge_cli_version(&bin) {
770 Some(found) if found >= floor => ProbeResult::ok(
771 id,
772 ProbeClass::Soft,
773 format!("{name} {} is at or above {}", show(found), show(floor)),
774 ),
775 Some(found) => ProbeResult::failed(
776 id,
777 ProbeClass::Soft,
778 format!(
779 "{name} {} is below the {} rk calls",
780 show(found),
781 show(floor)
782 ),
783 forge.cli_upgrade(),
784 ),
785 None => ProbeResult::failed(
786 id,
787 ProbeClass::Soft,
788 format!("{name} does not answer --version with a version"),
789 format!("install {name}"),
790 ),
791 }
792}
793
794fn show((major, minor, patch): (u32, u32, u32)) -> String {
796 format!("{major}.{minor}.{patch}")
797}
798
799pub fn require_forge_cli(forge: Forge) -> Result<(String, (u32, u32, u32)), RkError> {
811 let bin = forge_bin(forge);
812 let name = forge.cli();
813 let floor = forge.cli_floor();
814 let Some(found) = forge_cli_version(&bin) else {
815 return Err(RkError::refusal(
816 Diagnostic::new(
817 Reason::PrerequisiteUnmet,
818 format!("{name} does not answer --version with a version"),
819 )
820 .expected(format!("{name} at or above {} on PATH", show(floor)))
821 .action(format!("install {name}, then rerun"))
822 .target_state("unchanged"),
823 ));
824 };
825 if found < floor {
826 return Err(RkError::refusal(
827 Diagnostic::new(
828 Reason::PrerequisiteUnmet,
829 format!(
830 "{name} {} is below the {} rk calls",
831 show(found),
832 show(floor)
833 ),
834 )
835 .expected(format!("{name} at or above {}", show(floor)))
836 .action(forge.cli_upgrade())
837 .target_state("unchanged"),
838 ));
839 }
840 Ok((bin, found))
841}
842
843#[cfg(test)]
844mod tests {
845 use super::{parse_cli_version, remote_host};
846
847 #[test]
851 fn a_version_line_parses_from_both_forge_clis() {
852 assert_eq!(
853 parse_cli_version("gh version 2.19.0 (2022-10-25)"),
854 Some((2, 19, 0))
855 );
856 assert_eq!(
857 parse_cli_version("glab 1.114.0 (4d7c6cd)\n"),
858 Some((1, 114, 0))
859 );
860 assert_eq!(parse_cli_version("gh version 2.99.0"), Some((2, 99, 0)));
861 assert_eq!(parse_cli_version("no version here"), None);
862 assert_eq!(parse_cli_version("gh version 2.19"), None);
863 }
864
865 #[test]
868 fn a_floor_comparison_orders_by_component() {
869 assert!((2, 100, 0) > (2, 99, 0));
870 assert!((2, 9, 0) < (2, 19, 0));
871 assert!((2, 19, 0) >= (2, 19, 0));
872 }
873
874 #[test]
875 fn a_remote_host_parses_from_both_url_forms() {
876 assert_eq!(
877 remote_host("https://github.com/owner/name.git").as_deref(),
878 Some("github.com")
879 );
880 assert_eq!(
881 remote_host("git@gitlab.com:group/sub/name.git").as_deref(),
882 Some("gitlab.com")
883 );
884 assert_eq!(
885 remote_host("ssh://git@github.com:22/owner/name.git").as_deref(),
886 Some("github.com")
887 );
888 assert_eq!(remote_host("not a url"), None);
889 }
890}