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};
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]
167pub fn git_cliff_bin() -> std::ffi::OsString {
168 std::env::var_os("RK_GIT_CLIFF_BIN").unwrap_or_else(|| "git-cliff".into())
169}
170
171#[must_use]
176pub fn uv_bin() -> std::ffi::OsString {
177 std::env::var_os("RK_UV_BIN").unwrap_or_else(|| "uv".into())
178}
179
180#[must_use]
182pub fn run_all() -> Vec<ProbeResult> {
183 vec![
184 shell(),
185 git(),
186 state_root(),
187 skill_roots(),
188 skill_gate(),
189 skill_sources(),
190 git_remote(),
191 forge_cli(
192 "gh-auth",
193 "RK_GH_BIN",
194 "gh",
195 "the GitHub CLI",
196 "gh auth login",
197 &[&["auth", "status", "--active"], &["auth", "status"]],
202 ),
203 forge_cli(
204 "glab-auth",
205 "RK_GLAB_BIN",
206 "glab",
207 "the GitLab CLI",
208 "glab auth login",
209 &[&["auth", "status"]],
210 ),
211 forge_cli_floor(Forge::Github),
212 forge_cli_floor(Forge::Gitlab),
213 tool(
214 "openssl",
215 "RK_OPENSSL_BIN",
216 "openssl",
217 "OpenSSL; install-bot signs the App JWT with it",
218 &["version"],
219 ),
220 tool(
221 "curl",
222 "RK_CURL_BIN",
223 "curl",
224 "curl; install-bot reads the installation, and rk versions --check and rk self-depend sync fetch with it",
225 &["--version"],
226 ),
227 registry(),
228 nix(),
229 direnv(),
230 tool(
231 "cosign",
232 "RK_COSIGN_BIN",
233 "cosign",
234 "cosign; the release verify step checks a GitLab provenance bundle with it",
235 &["version"],
236 ),
237 tool(
238 "pypi-attestations",
239 "RK_PYPI_ATTESTATIONS_BIN",
240 "pypi-attestations",
241 "pypi-attestations; the release verify step checks a PyPI distribution's attestations with it",
242 &["--help"],
243 ),
244 ]
245}
246
247fn tool(
251 id: &'static str,
252 env_override: &str,
253 default_bin: &str,
254 label: &str,
255 args: &[&str],
256) -> ProbeResult {
257 let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
258 match Command::new(&bin).args(args).output() {
259 Ok(out) if out.status.success() => {
260 ProbeResult::ok(id, ProbeClass::Soft, format!("{default_bin} runs"))
261 }
262 Ok(_) => ProbeResult::failed(
263 id,
264 ProbeClass::Soft,
265 format!("{default_bin} does not answer {}", args.join(" ")),
266 format!("repair {label}"),
267 ),
268 Err(_) => ProbeResult::failed(
269 id,
270 ProbeClass::Soft,
271 format!("{default_bin} is not on PATH"),
272 format!("install {label}"),
273 ),
274 }
275}
276
277fn registry() -> ProbeResult {
282 let id = "registry";
283 let curl = std::env::var_os("RK_CURL_BIN").unwrap_or_else(|| "curl".into());
284 let answered = Command::new(curl)
291 .args([
292 "-fsSL",
293 "--max-time",
294 "5",
295 "https://index.crates.io/config.json",
296 ])
297 .output()
298 .is_ok_and(|answer| answer.status.success());
299 if answered {
300 ProbeResult::ok(id, ProbeClass::Soft, "the crates.io index answers")
301 } else {
302 ProbeResult::failed(
303 id,
304 ProbeClass::Soft,
305 "the crates.io index does not answer",
306 "reach the network before rk versions --check or rk self-depend sync; every offline verb runs without it",
307 )
308 }
309}
310
311fn shell() -> ProbeResult {
313 let id = "sh";
314 match Command::new(sh_bin()).args(["-c", "exit 0"]).status() {
315 Ok(status) if status.success() => ProbeResult::ok(id, ProbeClass::Hard, "sh runs"),
316 Ok(status) => ProbeResult::failed(
317 id,
318 ProbeClass::Hard,
319 format!("sh exited {status}"),
320 "repair the POSIX shell on PATH",
321 ),
322 Err(source) => ProbeResult::failed(
323 id,
324 ProbeClass::Hard,
325 format!("sh does not spawn: {source}"),
326 "install a POSIX shell on PATH",
327 ),
328 }
329}
330
331fn git() -> ProbeResult {
334 let id = "git";
335 match Command::new(git_bin()).arg("--version").output() {
336 Ok(out) if out.status.success() => ProbeResult::ok(id, ProbeClass::Hard, "git runs"),
337 Ok(_) => ProbeResult::failed(
338 id,
339 ProbeClass::Hard,
340 "git does not answer --version",
341 "repair the git on PATH, or point RK_GIT_BIN at a working one",
342 ),
343 Err(_) => ProbeResult::failed(id, ProbeClass::Hard, "git is not on PATH", "install git"),
344 }
345}
346
347fn state_root() -> ProbeResult {
350 let id = "state-root";
351 let Some(root) = crate::applog::state_root() else {
352 return ProbeResult::failed(
353 id,
354 ProbeClass::Hard,
355 "neither XDG_STATE_HOME nor HOME is set",
356 "export HOME, or XDG_STATE_HOME",
357 );
358 };
359 let display = root.display().to_string();
360 let probe = root.join(format!(".probe-{}", std::process::id()));
361 let written = std::fs::create_dir_all(&root).and_then(|()| std::fs::write(&probe, b"probe"));
362 let _ = std::fs::remove_file(&probe);
363 match written {
364 Ok(()) => ProbeResult::ok(id, ProbeClass::Hard, format!("{display} is writable")),
365 Err(source) => ProbeResult::failed(
366 id,
367 ProbeClass::Hard,
368 format!("{display} is not writable: {source}"),
369 format!("make {display} writable"),
370 ),
371 }
372}
373
374fn skill_roots() -> ProbeResult {
384 let id = SKILL_PROBES[0];
385 let Ok(home) = crate::skills::home() else {
386 return ProbeResult::failed(
387 id,
388 ProbeClass::Soft,
389 "neither HOME nor USERPROFILE is set, so no skill root resolves",
390 "export HOME",
391 );
392 };
393 let mut refused = Vec::new();
394 for root in [CLAUDE_ROOT, AGENTS_ROOT] {
395 let root = home.join(root);
396 let Some(existing) = nearest_existing(&root) else {
397 refused.push(format!("no ancestor of {root} exists"));
398 continue;
399 };
400 if let Err(source) = accepts_a_write(&existing) {
401 refused.push(format!("{existing} is not writable: {source}"));
402 }
403 }
404 if refused.is_empty() {
405 ProbeResult::ok(
406 id,
407 ProbeClass::Soft,
408 format!("the skill roots under {home} accept writes"),
409 )
410 } else {
411 ProbeResult::failed(
412 id,
413 ProbeClass::Soft,
414 refused.join("; "),
415 format!("make the skill roots under {home} writable"),
416 )
417 }
418}
419
420fn skill_gate() -> ProbeResult {
428 let id = SKILL_PROBES[1];
429 let Some(context) = installed_context(id) else {
430 return no_home(id);
431 };
432 let (home, packages, record) = match context {
433 Ok(context) => context,
434 Err(failed) => return failed,
435 };
436 let planned = installed_files(&home, &packages, true);
437 if planned.is_empty() {
438 return ProbeResult::failed(
439 id,
440 ProbeClass::Soft,
441 format!("no agent skill root exists under {home}"),
442 "rk skill install --apply",
443 );
444 }
445 let found = judge(planned, &record);
446 if let Some(first) = found.missing.first() {
447 return ProbeResult::failed(
448 id,
449 ProbeClass::Soft,
450 format!("a gate every skill reads before acting is missing from its package: {first}"),
451 "rk skill install --apply",
452 );
453 }
454 if !found.differing.is_empty() {
455 return ProbeResult::failed(
456 id,
457 ProbeClass::Soft,
458 format!(
459 "{} installed gate copies are not this binary's; rk is {}",
460 found.differing.len(),
461 env!("CARGO_PKG_VERSION")
462 ),
463 reinstall(found.all_recorded),
464 );
465 }
466 ProbeResult::ok(
467 id,
468 ProbeClass::Soft,
469 format!("{} installed gate copies are this binary's", found.matching),
470 )
471}
472
473fn skill_sources() -> ProbeResult {
483 let id = SKILL_PROBES[2];
484 let Some(context) = installed_context(id) else {
485 return no_home(id);
486 };
487 let (home, packages, record) = match context {
488 Ok(context) => context,
489 Err(failed) => return failed,
490 };
491 let planned = installed_files(&home, &packages, false);
492 if planned.is_empty() {
493 return ProbeResult::failed(
494 id,
495 ProbeClass::Soft,
496 format!("no agent skill root exists under {home}"),
497 "rk skill install --apply",
498 );
499 }
500 let found = judge(planned, &record);
501 if let Some(first) = found.missing.first() {
502 return ProbeResult::failed(
503 id,
504 ProbeClass::Soft,
505 format!(
506 "{} of this binary's skill files are not installed, the first at {first}",
507 found.missing.len()
508 ),
509 "rk skill install --apply",
510 );
511 }
512 if !found.differing.is_empty() {
513 return ProbeResult::failed(
514 id,
515 ProbeClass::Soft,
516 format!(
517 "{} installed skill file(s) are not this binary's; rk is {}",
518 found.differing.len(),
519 env!("CARGO_PKG_VERSION")
520 ),
521 reinstall(found.all_recorded),
522 );
523 }
524 ProbeResult::ok(
525 id,
526 ProbeClass::Soft,
527 format!(
528 "{} installed skill destination(s) are this binary's",
529 found.matching
530 ),
531 )
532}
533
534type SkillContext = (Utf8PathBuf, Vec<crate::skills::Package>, Record);
537
538fn installed_context(id: &'static str) -> Option<Result<SkillContext, ProbeResult>> {
541 let home = crate::skills::home().ok()?;
542 let Ok(packages) = crate::skills::packages() else {
543 return Some(Err(ProbeResult::failed(
544 id,
545 ProbeClass::Soft,
546 "this binary's embedded skills do not read",
547 "reinstall rk; the sources it was built from are defective",
548 )));
549 };
550 let record = Record::load(&home.join(RECORD_PATH));
551 Some(Ok((home, packages, record)))
552}
553
554fn no_home(id: &'static str) -> ProbeResult {
556 ProbeResult::failed(
557 id,
558 ProbeClass::Soft,
559 "neither HOME nor USERPROFILE is set, so no agent root resolves",
560 "export HOME",
561 )
562}
563
564fn installed_files(
570 home: &Utf8Path,
571 packages: &[crate::skills::Package],
572 shared: bool,
573) -> Vec<(Utf8PathBuf, &'static [u8])> {
574 let mut planned = Vec::new();
575 for root in [CLAUDE_ROOT, AGENTS_ROOT] {
576 let root = home.join(root);
577 if !root.is_dir() {
578 continue;
579 }
580 for package in packages {
581 for file in package.files.iter().filter(|file| file.shared == shared) {
582 planned.push((root.join(&package.name).join(&file.path), file.bytes));
583 }
584 }
585 }
586 planned
587}
588
589struct Installed {
591 missing: Vec<Utf8PathBuf>,
593 differing: Vec<Utf8PathBuf>,
595 matching: usize,
597 all_recorded: bool,
601}
602
603fn judge(planned: Vec<(Utf8PathBuf, &'static [u8])>, record: &Record) -> Installed {
605 let mut found = Installed {
606 missing: Vec::new(),
607 differing: Vec::new(),
608 matching: 0,
609 all_recorded: true,
610 };
611 for (destination, bytes) in planned {
612 match std::fs::read(&destination) {
613 Ok(held) if held == bytes => found.matching += 1,
614 Ok(held) => {
615 if !record.wrote(&destination, &Digest::of(&held)) {
616 found.all_recorded = false;
617 }
618 found.differing.push(destination);
619 }
620 Err(_) => found.missing.push(destination),
621 }
622 }
623 found
624}
625
626const fn reinstall(all_recorded: bool) -> &'static str {
630 if all_recorded {
631 "rk skill install --apply"
632 } else {
633 "rk skill install --apply --force"
634 }
635}
636
637fn nearest_existing(path: &Utf8Path) -> Option<Utf8PathBuf> {
640 let mut current = Some(path);
641 while let Some(dir) = current {
642 if dir.is_dir() {
643 return Some(dir.to_owned());
644 }
645 current = dir.parent();
646 }
647 None
648}
649
650fn accepts_a_write(dir: &Utf8Path) -> std::io::Result<()> {
652 let probe = dir.join(format!(".rk-probe-{}", std::process::id()));
653 let written = std::fs::write(&probe, b"probe");
654 let _ = std::fs::remove_file(&probe);
655 written
656}
657
658fn git_remote() -> ProbeResult {
661 let id = "git-remote";
662 let out = Command::new(git_bin())
663 .args(["remote", "get-url", "origin"])
664 .output();
665 let url = match out {
666 Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_owned(),
667 _ => {
668 return ProbeResult::failed(
669 id,
670 ProbeClass::Soft,
671 "the working directory has no origin remote",
672 "pass --repo <owner/name> where a command needs the slug",
673 );
674 }
675 };
676 remote_host(&url).map_or_else(
680 || {
681 ProbeResult::failed(
682 id,
683 ProbeClass::Soft,
684 "the origin remote does not parse to a host",
685 "pass --repo <owner/name> where a command needs the slug",
686 )
687 },
688 |host| ProbeResult::ok(id, ProbeClass::Soft, format!("origin resolves to {host}")),
689 )
690}
691
692fn remote_host(url: &str) -> Option<String> {
694 if let Some(rest) = url.split_once("://").map(|(_, rest)| rest) {
695 let authority = rest.split('/').next()?;
696 let host = authority
697 .rsplit_once('@')
698 .map_or(authority, |(_, host)| host);
699 let host = host.split(':').next()?;
700 return (!host.is_empty()).then(|| host.to_owned());
701 }
702 let (authority, path) = url.split_once(':')?;
703 let host = authority
704 .rsplit_once('@')
705 .map_or(authority, |(_, host)| host);
706 (!host.is_empty() && !path.is_empty()).then(|| host.to_owned())
707}
708
709fn forge_cli(
715 id: &'static str,
716 env_override: &str,
717 default_bin: &str,
718 label: &str,
719 login: &str,
720 attempts: &[&[&str]],
721) -> ProbeResult {
722 let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
723 let mut spawned = false;
724 for args in attempts {
725 match Command::new(&bin).args(*args).output() {
726 Ok(out) if out.status.success() => {
727 return ProbeResult::ok(
728 id,
729 ProbeClass::Soft,
730 format!("{default_bin} is authenticated"),
731 );
732 }
733 Ok(_) => spawned = true,
734 Err(_) => {}
735 }
736 }
737 if spawned {
738 ProbeResult::failed(
739 id,
740 ProbeClass::Soft,
741 format!("{default_bin} is not authenticated"),
742 format!("run {login}"),
743 )
744 } else {
745 ProbeResult::failed(
746 id,
747 ProbeClass::Soft,
748 format!("{default_bin} is not on PATH"),
749 format!("install {label}"),
750 )
751 }
752}
753
754#[must_use]
757pub fn forge_bin(forge: Forge) -> String {
758 std::env::var(forge.cli_override()).unwrap_or_else(|_| forge.cli().to_owned())
759}
760
761const fn version_probe_id(forge: Forge) -> &'static str {
763 match forge {
764 Forge::Github => "gh-version",
765 Forge::Gitlab => "glab-version",
766 }
767}
768
769#[must_use]
774pub fn parse_cli_version(text: &str) -> Option<(u32, u32, u32)> {
775 let bytes = text.as_bytes();
776 let mut start = 0;
777 while start < bytes.len() {
778 if !bytes[start].is_ascii_digit() {
779 start += 1;
780 continue;
781 }
782 let mut end = start;
783 while end < bytes.len() && (bytes[end].is_ascii_digit() || bytes[end] == b'.') {
784 end += 1;
785 }
786 let run = &text[start..end];
787 let mut parts = run.split('.');
788 let parsed = (|| {
789 let major = parts.next()?.parse().ok()?;
790 let minor = parts.next()?.parse().ok()?;
791 let patch = parts.next()?.parse().ok()?;
792 Some((major, minor, patch))
793 })();
794 if let Some(version) = parsed {
795 return Some(version);
796 }
797 start = end.max(start + 1);
798 }
799 None
800}
801
802#[must_use]
806pub fn forge_cli_version(bin: &str) -> Option<(u32, u32, u32)> {
807 let out = Command::new(bin).arg("--version").output().ok()?;
808 if !out.status.success() {
809 return None;
810 }
811 parse_cli_version(&String::from_utf8_lossy(&out.stdout))
812}
813
814fn forge_cli_floor(forge: Forge) -> ProbeResult {
818 let id = version_probe_id(forge);
819 let bin = forge_bin(forge);
820 let name = forge.cli();
821 let floor = forge.cli_floor();
822 match forge_cli_version(&bin) {
823 Some(found) if found >= floor => ProbeResult::ok(
824 id,
825 ProbeClass::Soft,
826 format!("{name} {} is at or above {}", show(found), show(floor)),
827 ),
828 Some(found) => ProbeResult::failed(
829 id,
830 ProbeClass::Soft,
831 format!(
832 "{name} {} is below the {} rk calls",
833 show(found),
834 show(floor)
835 ),
836 forge.cli_upgrade(),
837 ),
838 None => ProbeResult::failed(
839 id,
840 ProbeClass::Soft,
841 format!("{name} does not answer --version with a version"),
842 format!("install {name}"),
843 ),
844 }
845}
846
847fn show((major, minor, patch): (u32, u32, u32)) -> String {
849 format!("{major}.{minor}.{patch}")
850}
851
852pub fn require_forge_cli(forge: Forge) -> Result<(String, (u32, u32, u32)), RkError> {
864 let bin = forge_bin(forge);
865 let name = forge.cli();
866 let floor = forge.cli_floor();
867 let Some(found) = forge_cli_version(&bin) else {
868 return Err(RkError::refusal(
869 Diagnostic::new(
870 Reason::PrerequisiteUnmet,
871 format!("{name} does not answer --version with a version"),
872 )
873 .expected(format!("{name} at or above {} on PATH", show(floor)))
874 .action(format!("install {name}, then rerun"))
875 .target_state("unchanged"),
876 ));
877 };
878 if found < floor {
879 return Err(RkError::refusal(
880 Diagnostic::new(
881 Reason::PrerequisiteUnmet,
882 format!(
883 "{name} {} is below the {} rk calls",
884 show(found),
885 show(floor)
886 ),
887 )
888 .expected(format!("{name} at or above {}", show(floor)))
889 .action(forge.cli_upgrade())
890 .target_state("unchanged"),
891 ));
892 }
893 Ok((bin, found))
894}
895
896#[cfg(test)]
897mod tests {
898 use super::{parse_cli_version, remote_host};
899
900 #[test]
904 fn a_version_line_parses_from_both_forge_clis() {
905 assert_eq!(
906 parse_cli_version("gh version 2.19.0 (2022-10-25)"),
907 Some((2, 19, 0))
908 );
909 assert_eq!(
910 parse_cli_version("glab 1.114.0 (4d7c6cd)\n"),
911 Some((1, 114, 0))
912 );
913 assert_eq!(parse_cli_version("gh version 2.99.0"), Some((2, 99, 0)));
914 assert_eq!(parse_cli_version("no version here"), None);
915 assert_eq!(parse_cli_version("gh version 2.19"), None);
916 }
917
918 #[test]
921 fn a_floor_comparison_orders_by_component() {
922 assert!((2, 100, 0) > (2, 99, 0));
923 assert!((2, 9, 0) < (2, 19, 0));
924 assert!((2, 19, 0) >= (2, 19, 0));
925 }
926
927 #[test]
928 fn a_remote_host_parses_from_both_url_forms() {
929 assert_eq!(
930 remote_host("https://github.com/owner/name.git").as_deref(),
931 Some("github.com")
932 );
933 assert_eq!(
934 remote_host("git@gitlab.com:group/sub/name.git").as_deref(),
935 Some("gitlab.com")
936 );
937 assert_eq!(
938 remote_host("ssh://git@github.com:22/owner/name.git").as_deref(),
939 Some("github.com")
940 );
941 assert_eq!(remote_host("not a url"), None);
942 }
943}