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)
266 .args([
267 "-fsSL",
268 "--max-time",
269 "5",
270 "-o",
271 "/dev/null",
272 "https://index.crates.io/config.json",
273 ])
274 .status()
275 .is_ok_and(|status| status.success());
276 if answered {
277 ProbeResult::ok(id, ProbeClass::Soft, "the crates.io index answers")
278 } else {
279 ProbeResult::failed(
280 id,
281 ProbeClass::Soft,
282 "the crates.io index does not answer",
283 "reach the network before rk versions --check or rk self-depend sync; every offline verb runs without it",
284 )
285 }
286}
287
288fn shell() -> ProbeResult {
290 let id = "sh";
291 match Command::new(sh_bin()).args(["-c", "exit 0"]).status() {
292 Ok(status) if status.success() => ProbeResult::ok(id, ProbeClass::Hard, "sh runs"),
293 Ok(status) => ProbeResult::failed(
294 id,
295 ProbeClass::Hard,
296 format!("sh exited {status}"),
297 "repair the POSIX shell on PATH",
298 ),
299 Err(source) => ProbeResult::failed(
300 id,
301 ProbeClass::Hard,
302 format!("sh does not spawn: {source}"),
303 "install a POSIX shell on PATH",
304 ),
305 }
306}
307
308fn git() -> ProbeResult {
311 let id = "git";
312 match Command::new(git_bin()).arg("--version").output() {
313 Ok(out) if out.status.success() => ProbeResult::ok(id, ProbeClass::Hard, "git runs"),
314 Ok(_) => ProbeResult::failed(
315 id,
316 ProbeClass::Hard,
317 "git does not answer --version",
318 "repair the git on PATH, or point RK_GIT_BIN at a working one",
319 ),
320 Err(_) => ProbeResult::failed(id, ProbeClass::Hard, "git is not on PATH", "install git"),
321 }
322}
323
324fn state_root() -> ProbeResult {
327 let id = "state-root";
328 let Some(root) = crate::applog::state_root() else {
329 return ProbeResult::failed(
330 id,
331 ProbeClass::Hard,
332 "neither XDG_STATE_HOME nor HOME is set",
333 "export HOME, or XDG_STATE_HOME",
334 );
335 };
336 let display = root.display().to_string();
337 let probe = root.join(format!(".probe-{}", std::process::id()));
338 let written = std::fs::create_dir_all(&root).and_then(|()| std::fs::write(&probe, b"probe"));
339 let _ = std::fs::remove_file(&probe);
340 match written {
341 Ok(()) => ProbeResult::ok(id, ProbeClass::Hard, format!("{display} is writable")),
342 Err(source) => ProbeResult::failed(
343 id,
344 ProbeClass::Hard,
345 format!("{display} is not writable: {source}"),
346 format!("make {display} writable"),
347 ),
348 }
349}
350
351fn skill_roots() -> ProbeResult {
361 let id = SKILL_PROBES[0];
362 let Ok(home) = crate::skills::home() else {
363 return ProbeResult::failed(
364 id,
365 ProbeClass::Soft,
366 "neither HOME nor USERPROFILE is set, so no skill root resolves",
367 "export HOME",
368 );
369 };
370 let mut refused = Vec::new();
371 for root in [CLAUDE_ROOT, AGENTS_ROOT, SHARED_ROOT] {
372 let root = home.join(root);
373 let Some(existing) = nearest_existing(&root) else {
374 refused.push(format!("no ancestor of {root} exists"));
375 continue;
376 };
377 if let Err(source) = accepts_a_write(&existing) {
378 refused.push(format!("{existing} is not writable: {source}"));
379 }
380 }
381 if refused.is_empty() {
382 ProbeResult::ok(
383 id,
384 ProbeClass::Soft,
385 format!("the skill roots under {home} accept writes"),
386 )
387 } else {
388 ProbeResult::failed(
389 id,
390 ProbeClass::Soft,
391 refused.join("; "),
392 format!("make the skill roots under {home} writable"),
393 )
394 }
395}
396
397fn skill_gate() -> ProbeResult {
407 let id = SKILL_PROBES[1];
408 let Ok(home) = crate::skills::home() else {
409 return ProbeResult::failed(
410 id,
411 ProbeClass::Soft,
412 "neither HOME nor USERPROFILE is set, so the shared root does not resolve",
413 "export HOME",
414 );
415 };
416 let root = home.join(SHARED_ROOT);
417 let record = Record::load(&home.join(RECORD_PATH));
418 let planned: Vec<(Utf8PathBuf, &'static [u8])> = crate::skills::shared()
419 .into_iter()
420 .map(|artifact| (root.join(&artifact.path), artifact.bytes))
421 .collect();
422 let found = judge(planned, &record);
423 if let Some(first) = found.missing.first() {
424 return ProbeResult::failed(
425 id,
426 ProbeClass::Soft,
427 format!("a shared artifact every skill reads before acting is not installed: {first}"),
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 "{} shared artifact(s) under {root} are not this binary's",
437 found.differing.len()
438 ),
439 reinstall(found.all_recorded),
440 );
441 }
442 ProbeResult::ok(
443 id,
444 ProbeClass::Soft,
445 format!("{root} holds this binary's shared artifacts"),
446 )
447}
448
449fn skill_sources() -> ProbeResult {
457 let id = SKILL_PROBES[2];
458 let Ok(home) = crate::skills::home() else {
459 return ProbeResult::failed(
460 id,
461 ProbeClass::Soft,
462 "neither HOME nor USERPROFILE is set, so no agent root resolves",
463 "export HOME",
464 );
465 };
466 let Ok(skills) = crate::skills::all() else {
467 return ProbeResult::failed(
468 id,
469 ProbeClass::Soft,
470 "this binary's embedded skills do not read",
471 "reinstall rk; the sources it was built from are defective",
472 );
473 };
474 let record = Record::load(&home.join(RECORD_PATH));
475 let mut planned = Vec::new();
476 for root in [CLAUDE_ROOT, AGENTS_ROOT] {
477 let root = home.join(root);
478 if !root.is_dir() {
481 continue;
482 }
483 for skill in &skills {
484 planned.push((
485 root.join(&skill.name).join("SKILL.md"),
486 skill.text.as_bytes(),
487 ));
488 }
489 }
490 if planned.is_empty() {
491 return ProbeResult::failed(
492 id,
493 ProbeClass::Soft,
494 format!("no agent skill root exists under {home}"),
495 "rk skill install --apply",
496 );
497 }
498 let found = judge(planned, &record);
499 if let Some(first) = found.missing.first() {
500 return ProbeResult::failed(
501 id,
502 ProbeClass::Soft,
503 format!(
504 "{} of this binary's skills are not installed, the first at {first}",
505 found.missing.len()
506 ),
507 "rk skill install --apply",
508 );
509 }
510 if !found.differing.is_empty() {
511 return ProbeResult::failed(
512 id,
513 ProbeClass::Soft,
514 format!(
515 "{} installed skill(s) are not this binary's; rk is {}",
516 found.differing.len(),
517 env!("CARGO_PKG_VERSION")
518 ),
519 reinstall(found.all_recorded),
520 );
521 }
522 ProbeResult::ok(
523 id,
524 ProbeClass::Soft,
525 format!(
526 "{} installed skill destination(s) are this binary's",
527 found.matching
528 ),
529 )
530}
531
532struct Installed {
534 missing: Vec<Utf8PathBuf>,
536 differing: Vec<Utf8PathBuf>,
538 matching: usize,
540 all_recorded: bool,
544}
545
546fn judge(planned: Vec<(Utf8PathBuf, &'static [u8])>, record: &Record) -> Installed {
548 let mut found = Installed {
549 missing: Vec::new(),
550 differing: Vec::new(),
551 matching: 0,
552 all_recorded: true,
553 };
554 for (destination, bytes) in planned {
555 match std::fs::read(&destination) {
556 Ok(held) if held == bytes => found.matching += 1,
557 Ok(held) => {
558 if !record.wrote(&destination, &Digest::of(&held)) {
559 found.all_recorded = false;
560 }
561 found.differing.push(destination);
562 }
563 Err(_) => found.missing.push(destination),
564 }
565 }
566 found
567}
568
569const fn reinstall(all_recorded: bool) -> &'static str {
573 if all_recorded {
574 "rk skill install --apply"
575 } else {
576 "rk skill install --apply --force"
577 }
578}
579
580fn nearest_existing(path: &Utf8Path) -> Option<Utf8PathBuf> {
583 let mut current = Some(path);
584 while let Some(dir) = current {
585 if dir.is_dir() {
586 return Some(dir.to_owned());
587 }
588 current = dir.parent();
589 }
590 None
591}
592
593fn accepts_a_write(dir: &Utf8Path) -> std::io::Result<()> {
595 let probe = dir.join(format!(".rk-probe-{}", std::process::id()));
596 let written = std::fs::write(&probe, b"probe");
597 let _ = std::fs::remove_file(&probe);
598 written
599}
600
601fn git_remote() -> ProbeResult {
604 let id = "git-remote";
605 let out = Command::new(git_bin())
606 .args(["remote", "get-url", "origin"])
607 .output();
608 let url = match out {
609 Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_owned(),
610 _ => {
611 return ProbeResult::failed(
612 id,
613 ProbeClass::Soft,
614 "the working directory has no origin remote",
615 "pass --repo <owner/name> where a command needs the slug",
616 );
617 }
618 };
619 remote_host(&url).map_or_else(
623 || {
624 ProbeResult::failed(
625 id,
626 ProbeClass::Soft,
627 "the origin remote does not parse to a host",
628 "pass --repo <owner/name> where a command needs the slug",
629 )
630 },
631 |host| ProbeResult::ok(id, ProbeClass::Soft, format!("origin resolves to {host}")),
632 )
633}
634
635fn remote_host(url: &str) -> Option<String> {
637 if let Some(rest) = url.split_once("://").map(|(_, rest)| rest) {
638 let authority = rest.split('/').next()?;
639 let host = authority
640 .rsplit_once('@')
641 .map_or(authority, |(_, host)| host);
642 let host = host.split(':').next()?;
643 return (!host.is_empty()).then(|| host.to_owned());
644 }
645 let (authority, path) = url.split_once(':')?;
646 let host = authority
647 .rsplit_once('@')
648 .map_or(authority, |(_, host)| host);
649 (!host.is_empty() && !path.is_empty()).then(|| host.to_owned())
650}
651
652fn forge_cli(
658 id: &'static str,
659 env_override: &str,
660 default_bin: &str,
661 label: &str,
662 login: &str,
663 attempts: &[&[&str]],
664) -> ProbeResult {
665 let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
666 let mut spawned = false;
667 for args in attempts {
668 match Command::new(&bin).args(*args).output() {
669 Ok(out) if out.status.success() => {
670 return ProbeResult::ok(
671 id,
672 ProbeClass::Soft,
673 format!("{default_bin} is authenticated"),
674 );
675 }
676 Ok(_) => spawned = true,
677 Err(_) => {}
678 }
679 }
680 if spawned {
681 ProbeResult::failed(
682 id,
683 ProbeClass::Soft,
684 format!("{default_bin} is not authenticated"),
685 format!("run {login}"),
686 )
687 } else {
688 ProbeResult::failed(
689 id,
690 ProbeClass::Soft,
691 format!("{default_bin} is not on PATH"),
692 format!("install {label}"),
693 )
694 }
695}
696
697#[must_use]
700pub fn forge_bin(forge: Forge) -> String {
701 std::env::var(forge.cli_override()).unwrap_or_else(|_| forge.cli().to_owned())
702}
703
704const fn version_probe_id(forge: Forge) -> &'static str {
706 match forge {
707 Forge::Github => "gh-version",
708 Forge::Gitlab => "glab-version",
709 }
710}
711
712#[must_use]
717pub fn parse_cli_version(text: &str) -> Option<(u32, u32, u32)> {
718 let bytes = text.as_bytes();
719 let mut start = 0;
720 while start < bytes.len() {
721 if !bytes[start].is_ascii_digit() {
722 start += 1;
723 continue;
724 }
725 let mut end = start;
726 while end < bytes.len() && (bytes[end].is_ascii_digit() || bytes[end] == b'.') {
727 end += 1;
728 }
729 let run = &text[start..end];
730 let mut parts = run.split('.');
731 let parsed = (|| {
732 let major = parts.next()?.parse().ok()?;
733 let minor = parts.next()?.parse().ok()?;
734 let patch = parts.next()?.parse().ok()?;
735 Some((major, minor, patch))
736 })();
737 if let Some(version) = parsed {
738 return Some(version);
739 }
740 start = end.max(start + 1);
741 }
742 None
743}
744
745#[must_use]
749pub fn forge_cli_version(bin: &str) -> Option<(u32, u32, u32)> {
750 let out = Command::new(bin).arg("--version").output().ok()?;
751 if !out.status.success() {
752 return None;
753 }
754 parse_cli_version(&String::from_utf8_lossy(&out.stdout))
755}
756
757fn forge_cli_floor(forge: Forge) -> ProbeResult {
761 let id = version_probe_id(forge);
762 let bin = forge_bin(forge);
763 let name = forge.cli();
764 let floor = forge.cli_floor();
765 match forge_cli_version(&bin) {
766 Some(found) if found >= floor => ProbeResult::ok(
767 id,
768 ProbeClass::Soft,
769 format!("{name} {} is at or above {}", show(found), show(floor)),
770 ),
771 Some(found) => ProbeResult::failed(
772 id,
773 ProbeClass::Soft,
774 format!(
775 "{name} {} is below the {} rk calls",
776 show(found),
777 show(floor)
778 ),
779 forge.cli_upgrade(),
780 ),
781 None => ProbeResult::failed(
782 id,
783 ProbeClass::Soft,
784 format!("{name} does not answer --version with a version"),
785 format!("install {name}"),
786 ),
787 }
788}
789
790fn show((major, minor, patch): (u32, u32, u32)) -> String {
792 format!("{major}.{minor}.{patch}")
793}
794
795pub fn require_forge_cli(forge: Forge) -> Result<(String, (u32, u32, u32)), RkError> {
807 let bin = forge_bin(forge);
808 let name = forge.cli();
809 let floor = forge.cli_floor();
810 let Some(found) = forge_cli_version(&bin) else {
811 return Err(RkError::refusal(
812 Diagnostic::new(
813 Reason::PrerequisiteUnmet,
814 format!("{name} does not answer --version with a version"),
815 )
816 .expected(format!("{name} at or above {} on PATH", show(floor)))
817 .action(format!("install {name}, then rerun"))
818 .target_state("unchanged"),
819 ));
820 };
821 if found < floor {
822 return Err(RkError::refusal(
823 Diagnostic::new(
824 Reason::PrerequisiteUnmet,
825 format!(
826 "{name} {} is below the {} rk calls",
827 show(found),
828 show(floor)
829 ),
830 )
831 .expected(format!("{name} at or above {}", show(floor)))
832 .action(forge.cli_upgrade())
833 .target_state("unchanged"),
834 ));
835 }
836 Ok((bin, found))
837}
838
839#[cfg(test)]
840mod tests {
841 use super::{parse_cli_version, remote_host};
842
843 #[test]
847 fn a_version_line_parses_from_both_forge_clis() {
848 assert_eq!(
849 parse_cli_version("gh version 2.19.0 (2022-10-25)"),
850 Some((2, 19, 0))
851 );
852 assert_eq!(
853 parse_cli_version("glab 1.114.0 (4d7c6cd)\n"),
854 Some((1, 114, 0))
855 );
856 assert_eq!(parse_cli_version("gh version 2.99.0"), Some((2, 99, 0)));
857 assert_eq!(parse_cli_version("no version here"), None);
858 assert_eq!(parse_cli_version("gh version 2.19"), None);
859 }
860
861 #[test]
864 fn a_floor_comparison_orders_by_component() {
865 assert!((2, 100, 0) > (2, 99, 0));
866 assert!((2, 9, 0) < (2, 19, 0));
867 assert!((2, 19, 0) >= (2, 19, 0));
868 }
869
870 #[test]
871 fn a_remote_host_parses_from_both_url_forms() {
872 assert_eq!(
873 remote_host("https://github.com/owner/name.git").as_deref(),
874 Some("github.com")
875 );
876 assert_eq!(
877 remote_host("git@gitlab.com:group/sub/name.git").as_deref(),
878 Some("gitlab.com")
879 );
880 assert_eq!(
881 remote_host("ssh://git@github.com:22/owner/name.git").as_deref(),
882 Some("github.com")
883 );
884 assert_eq!(remote_host("not a url"), None);
885 }
886}