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]
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] {
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 {
409 let id = SKILL_PROBES[1];
410 let Some(context) = installed_context(id) else {
411 return no_home(id);
412 };
413 let (home, packages, record) = match context {
414 Ok(context) => context,
415 Err(failed) => return failed,
416 };
417 let planned = installed_files(&home, &packages, true);
418 if planned.is_empty() {
419 return ProbeResult::failed(
420 id,
421 ProbeClass::Soft,
422 format!("no agent skill root exists under {home}"),
423 "rk skill install --apply",
424 );
425 }
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 gate every skill reads before acting is missing from its package: {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 "{} installed gate copies are not this binary's; rk is {}",
441 found.differing.len(),
442 env!("CARGO_PKG_VERSION")
443 ),
444 reinstall(found.all_recorded),
445 );
446 }
447 ProbeResult::ok(
448 id,
449 ProbeClass::Soft,
450 format!("{} installed gate copies are this binary's", found.matching),
451 )
452}
453
454fn skill_sources() -> ProbeResult {
464 let id = SKILL_PROBES[2];
465 let Some(context) = installed_context(id) else {
466 return no_home(id);
467 };
468 let (home, packages, record) = match context {
469 Ok(context) => context,
470 Err(failed) => return failed,
471 };
472 let planned = installed_files(&home, &packages, false);
473 if planned.is_empty() {
474 return ProbeResult::failed(
475 id,
476 ProbeClass::Soft,
477 format!("no agent skill root exists under {home}"),
478 "rk skill install --apply",
479 );
480 }
481 let found = judge(planned, &record);
482 if let Some(first) = found.missing.first() {
483 return ProbeResult::failed(
484 id,
485 ProbeClass::Soft,
486 format!(
487 "{} of this binary's skill files are not installed, the first at {first}",
488 found.missing.len()
489 ),
490 "rk skill install --apply",
491 );
492 }
493 if !found.differing.is_empty() {
494 return ProbeResult::failed(
495 id,
496 ProbeClass::Soft,
497 format!(
498 "{} installed skill file(s) are not this binary's; rk is {}",
499 found.differing.len(),
500 env!("CARGO_PKG_VERSION")
501 ),
502 reinstall(found.all_recorded),
503 );
504 }
505 ProbeResult::ok(
506 id,
507 ProbeClass::Soft,
508 format!(
509 "{} installed skill destination(s) are this binary's",
510 found.matching
511 ),
512 )
513}
514
515type SkillContext = (Utf8PathBuf, Vec<crate::skills::Package>, Record);
518
519fn installed_context(id: &'static str) -> Option<Result<SkillContext, ProbeResult>> {
522 let home = crate::skills::home().ok()?;
523 let Ok(packages) = crate::skills::packages() else {
524 return Some(Err(ProbeResult::failed(
525 id,
526 ProbeClass::Soft,
527 "this binary's embedded skills do not read",
528 "reinstall rk; the sources it was built from are defective",
529 )));
530 };
531 let record = Record::load(&home.join(RECORD_PATH));
532 Some(Ok((home, packages, record)))
533}
534
535fn no_home(id: &'static str) -> ProbeResult {
537 ProbeResult::failed(
538 id,
539 ProbeClass::Soft,
540 "neither HOME nor USERPROFILE is set, so no agent root resolves",
541 "export HOME",
542 )
543}
544
545fn installed_files(
551 home: &Utf8Path,
552 packages: &[crate::skills::Package],
553 shared: bool,
554) -> Vec<(Utf8PathBuf, &'static [u8])> {
555 let mut planned = Vec::new();
556 for root in [CLAUDE_ROOT, AGENTS_ROOT] {
557 let root = home.join(root);
558 if !root.is_dir() {
559 continue;
560 }
561 for package in packages {
562 for file in package.files.iter().filter(|file| file.shared == shared) {
563 planned.push((root.join(&package.name).join(&file.path), file.bytes));
564 }
565 }
566 }
567 planned
568}
569
570struct Installed {
572 missing: Vec<Utf8PathBuf>,
574 differing: Vec<Utf8PathBuf>,
576 matching: usize,
578 all_recorded: bool,
582}
583
584fn judge(planned: Vec<(Utf8PathBuf, &'static [u8])>, record: &Record) -> Installed {
586 let mut found = Installed {
587 missing: Vec::new(),
588 differing: Vec::new(),
589 matching: 0,
590 all_recorded: true,
591 };
592 for (destination, bytes) in planned {
593 match std::fs::read(&destination) {
594 Ok(held) if held == bytes => found.matching += 1,
595 Ok(held) => {
596 if !record.wrote(&destination, &Digest::of(&held)) {
597 found.all_recorded = false;
598 }
599 found.differing.push(destination);
600 }
601 Err(_) => found.missing.push(destination),
602 }
603 }
604 found
605}
606
607const fn reinstall(all_recorded: bool) -> &'static str {
611 if all_recorded {
612 "rk skill install --apply"
613 } else {
614 "rk skill install --apply --force"
615 }
616}
617
618fn nearest_existing(path: &Utf8Path) -> Option<Utf8PathBuf> {
621 let mut current = Some(path);
622 while let Some(dir) = current {
623 if dir.is_dir() {
624 return Some(dir.to_owned());
625 }
626 current = dir.parent();
627 }
628 None
629}
630
631fn accepts_a_write(dir: &Utf8Path) -> std::io::Result<()> {
633 let probe = dir.join(format!(".rk-probe-{}", std::process::id()));
634 let written = std::fs::write(&probe, b"probe");
635 let _ = std::fs::remove_file(&probe);
636 written
637}
638
639fn git_remote() -> ProbeResult {
642 let id = "git-remote";
643 let out = Command::new(git_bin())
644 .args(["remote", "get-url", "origin"])
645 .output();
646 let url = match out {
647 Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_owned(),
648 _ => {
649 return ProbeResult::failed(
650 id,
651 ProbeClass::Soft,
652 "the working directory has no origin remote",
653 "pass --repo <owner/name> where a command needs the slug",
654 );
655 }
656 };
657 remote_host(&url).map_or_else(
661 || {
662 ProbeResult::failed(
663 id,
664 ProbeClass::Soft,
665 "the origin remote does not parse to a host",
666 "pass --repo <owner/name> where a command needs the slug",
667 )
668 },
669 |host| ProbeResult::ok(id, ProbeClass::Soft, format!("origin resolves to {host}")),
670 )
671}
672
673fn remote_host(url: &str) -> Option<String> {
675 if let Some(rest) = url.split_once("://").map(|(_, rest)| rest) {
676 let authority = rest.split('/').next()?;
677 let host = authority
678 .rsplit_once('@')
679 .map_or(authority, |(_, host)| host);
680 let host = host.split(':').next()?;
681 return (!host.is_empty()).then(|| host.to_owned());
682 }
683 let (authority, path) = url.split_once(':')?;
684 let host = authority
685 .rsplit_once('@')
686 .map_or(authority, |(_, host)| host);
687 (!host.is_empty() && !path.is_empty()).then(|| host.to_owned())
688}
689
690fn forge_cli(
696 id: &'static str,
697 env_override: &str,
698 default_bin: &str,
699 label: &str,
700 login: &str,
701 attempts: &[&[&str]],
702) -> ProbeResult {
703 let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
704 let mut spawned = false;
705 for args in attempts {
706 match Command::new(&bin).args(*args).output() {
707 Ok(out) if out.status.success() => {
708 return ProbeResult::ok(
709 id,
710 ProbeClass::Soft,
711 format!("{default_bin} is authenticated"),
712 );
713 }
714 Ok(_) => spawned = true,
715 Err(_) => {}
716 }
717 }
718 if spawned {
719 ProbeResult::failed(
720 id,
721 ProbeClass::Soft,
722 format!("{default_bin} is not authenticated"),
723 format!("run {login}"),
724 )
725 } else {
726 ProbeResult::failed(
727 id,
728 ProbeClass::Soft,
729 format!("{default_bin} is not on PATH"),
730 format!("install {label}"),
731 )
732 }
733}
734
735#[must_use]
738pub fn forge_bin(forge: Forge) -> String {
739 std::env::var(forge.cli_override()).unwrap_or_else(|_| forge.cli().to_owned())
740}
741
742const fn version_probe_id(forge: Forge) -> &'static str {
744 match forge {
745 Forge::Github => "gh-version",
746 Forge::Gitlab => "glab-version",
747 }
748}
749
750#[must_use]
755pub fn parse_cli_version(text: &str) -> Option<(u32, u32, u32)> {
756 let bytes = text.as_bytes();
757 let mut start = 0;
758 while start < bytes.len() {
759 if !bytes[start].is_ascii_digit() {
760 start += 1;
761 continue;
762 }
763 let mut end = start;
764 while end < bytes.len() && (bytes[end].is_ascii_digit() || bytes[end] == b'.') {
765 end += 1;
766 }
767 let run = &text[start..end];
768 let mut parts = run.split('.');
769 let parsed = (|| {
770 let major = parts.next()?.parse().ok()?;
771 let minor = parts.next()?.parse().ok()?;
772 let patch = parts.next()?.parse().ok()?;
773 Some((major, minor, patch))
774 })();
775 if let Some(version) = parsed {
776 return Some(version);
777 }
778 start = end.max(start + 1);
779 }
780 None
781}
782
783#[must_use]
787pub fn forge_cli_version(bin: &str) -> Option<(u32, u32, u32)> {
788 let out = Command::new(bin).arg("--version").output().ok()?;
789 if !out.status.success() {
790 return None;
791 }
792 parse_cli_version(&String::from_utf8_lossy(&out.stdout))
793}
794
795fn forge_cli_floor(forge: Forge) -> ProbeResult {
799 let id = version_probe_id(forge);
800 let bin = forge_bin(forge);
801 let name = forge.cli();
802 let floor = forge.cli_floor();
803 match forge_cli_version(&bin) {
804 Some(found) if found >= floor => ProbeResult::ok(
805 id,
806 ProbeClass::Soft,
807 format!("{name} {} is at or above {}", show(found), show(floor)),
808 ),
809 Some(found) => ProbeResult::failed(
810 id,
811 ProbeClass::Soft,
812 format!(
813 "{name} {} is below the {} rk calls",
814 show(found),
815 show(floor)
816 ),
817 forge.cli_upgrade(),
818 ),
819 None => ProbeResult::failed(
820 id,
821 ProbeClass::Soft,
822 format!("{name} does not answer --version with a version"),
823 format!("install {name}"),
824 ),
825 }
826}
827
828fn show((major, minor, patch): (u32, u32, u32)) -> String {
830 format!("{major}.{minor}.{patch}")
831}
832
833pub fn require_forge_cli(forge: Forge) -> Result<(String, (u32, u32, u32)), RkError> {
845 let bin = forge_bin(forge);
846 let name = forge.cli();
847 let floor = forge.cli_floor();
848 let Some(found) = forge_cli_version(&bin) else {
849 return Err(RkError::refusal(
850 Diagnostic::new(
851 Reason::PrerequisiteUnmet,
852 format!("{name} does not answer --version with a version"),
853 )
854 .expected(format!("{name} at or above {} on PATH", show(floor)))
855 .action(format!("install {name}, then rerun"))
856 .target_state("unchanged"),
857 ));
858 };
859 if found < floor {
860 return Err(RkError::refusal(
861 Diagnostic::new(
862 Reason::PrerequisiteUnmet,
863 format!(
864 "{name} {} is below the {} rk calls",
865 show(found),
866 show(floor)
867 ),
868 )
869 .expected(format!("{name} at or above {}", show(floor)))
870 .action(forge.cli_upgrade())
871 .target_state("unchanged"),
872 ));
873 }
874 Ok((bin, found))
875}
876
877#[cfg(test)]
878mod tests {
879 use super::{parse_cli_version, remote_host};
880
881 #[test]
885 fn a_version_line_parses_from_both_forge_clis() {
886 assert_eq!(
887 parse_cli_version("gh version 2.19.0 (2022-10-25)"),
888 Some((2, 19, 0))
889 );
890 assert_eq!(
891 parse_cli_version("glab 1.114.0 (4d7c6cd)\n"),
892 Some((1, 114, 0))
893 );
894 assert_eq!(parse_cli_version("gh version 2.99.0"), Some((2, 99, 0)));
895 assert_eq!(parse_cli_version("no version here"), None);
896 assert_eq!(parse_cli_version("gh version 2.19"), None);
897 }
898
899 #[test]
902 fn a_floor_comparison_orders_by_component() {
903 assert!((2, 100, 0) > (2, 99, 0));
904 assert!((2, 9, 0) < (2, 19, 0));
905 assert!((2, 19, 0) >= (2, 19, 0));
906 }
907
908 #[test]
909 fn a_remote_host_parses_from_both_url_forms() {
910 assert_eq!(
911 remote_host("https://github.com/owner/name.git").as_deref(),
912 Some("github.com")
913 );
914 assert_eq!(
915 remote_host("git@gitlab.com:group/sub/name.git").as_deref(),
916 Some("gitlab.com")
917 );
918 assert_eq!(
919 remote_host("ssh://git@github.com:22/owner/name.git").as_deref(),
920 Some("github.com")
921 );
922 assert_eq!(remote_host("not a url"), None);
923 }
924}