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-payload"];
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 devshell 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_payload(),
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 devshell sync fetch with it",
206 &["--version"],
207 ),
208 nix(),
209 direnv(),
210 tool(
211 "cosign",
212 "RK_COSIGN_BIN",
213 "cosign",
214 "cosign; the release verify step checks a GitLab provenance bundle with it",
215 &["version"],
216 ),
217 tool(
218 "pypi-attestations",
219 "RK_PYPI_ATTESTATIONS_BIN",
220 "pypi-attestations",
221 "pypi-attestations; the release verify step checks a PyPI distribution's attestations with it",
222 &["--help"],
223 ),
224 ]
225}
226
227fn tool(
231 id: &'static str,
232 env_override: &str,
233 default_bin: &str,
234 label: &str,
235 args: &[&str],
236) -> ProbeResult {
237 let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
238 match Command::new(&bin).args(args).output() {
239 Ok(out) if out.status.success() => {
240 ProbeResult::ok(id, ProbeClass::Soft, format!("{default_bin} runs"))
241 }
242 Ok(_) => ProbeResult::failed(
243 id,
244 ProbeClass::Soft,
245 format!("{default_bin} does not answer {}", args.join(" ")),
246 format!("repair {label}"),
247 ),
248 Err(_) => ProbeResult::failed(
249 id,
250 ProbeClass::Soft,
251 format!("{default_bin} is not on PATH"),
252 format!("install {label}"),
253 ),
254 }
255}
256
257fn shell() -> ProbeResult {
259 let id = "sh";
260 match Command::new(sh_bin()).args(["-c", "exit 0"]).status() {
261 Ok(status) if status.success() => ProbeResult::ok(id, ProbeClass::Hard, "sh runs"),
262 Ok(status) => ProbeResult::failed(
263 id,
264 ProbeClass::Hard,
265 format!("sh exited {status}"),
266 "repair the POSIX shell on PATH",
267 ),
268 Err(source) => ProbeResult::failed(
269 id,
270 ProbeClass::Hard,
271 format!("sh does not spawn: {source}"),
272 "install a POSIX shell on PATH",
273 ),
274 }
275}
276
277fn git() -> ProbeResult {
280 let id = "git";
281 match Command::new(git_bin()).arg("--version").output() {
282 Ok(out) if out.status.success() => ProbeResult::ok(id, ProbeClass::Hard, "git runs"),
283 Ok(_) => ProbeResult::failed(
284 id,
285 ProbeClass::Hard,
286 "git does not answer --version",
287 "repair the git on PATH, or point RK_GIT_BIN at a working one",
288 ),
289 Err(_) => ProbeResult::failed(id, ProbeClass::Hard, "git is not on PATH", "install git"),
290 }
291}
292
293fn state_root() -> ProbeResult {
296 let id = "state-root";
297 let Some(root) = crate::applog::state_root() else {
298 return ProbeResult::failed(
299 id,
300 ProbeClass::Hard,
301 "neither XDG_STATE_HOME nor HOME is set",
302 "export HOME, or XDG_STATE_HOME",
303 );
304 };
305 let display = root.display().to_string();
306 let probe = root.join(format!(".probe-{}", std::process::id()));
307 let written = std::fs::create_dir_all(&root).and_then(|()| std::fs::write(&probe, b"probe"));
308 let _ = std::fs::remove_file(&probe);
309 match written {
310 Ok(()) => ProbeResult::ok(id, ProbeClass::Hard, format!("{display} is writable")),
311 Err(source) => ProbeResult::failed(
312 id,
313 ProbeClass::Hard,
314 format!("{display} is not writable: {source}"),
315 format!("make {display} writable"),
316 ),
317 }
318}
319
320fn skill_roots() -> ProbeResult {
330 let id = SKILL_PROBES[0];
331 let Ok(home) = crate::skills::home() else {
332 return ProbeResult::failed(
333 id,
334 ProbeClass::Soft,
335 "neither HOME nor USERPROFILE is set, so no skill root resolves",
336 "export HOME",
337 );
338 };
339 let mut refused = Vec::new();
340 for root in [CLAUDE_ROOT, AGENTS_ROOT, SHARED_ROOT] {
341 let root = home.join(root);
342 let Some(existing) = nearest_existing(&root) else {
343 refused.push(format!("no ancestor of {root} exists"));
344 continue;
345 };
346 if let Err(source) = accepts_a_write(&existing) {
347 refused.push(format!("{existing} is not writable: {source}"));
348 }
349 }
350 if refused.is_empty() {
351 ProbeResult::ok(
352 id,
353 ProbeClass::Soft,
354 format!("the skill roots under {home} accept writes"),
355 )
356 } else {
357 ProbeResult::failed(
358 id,
359 ProbeClass::Soft,
360 refused.join("; "),
361 format!("make the skill roots under {home} writable"),
362 )
363 }
364}
365
366fn skill_gate() -> ProbeResult {
376 let id = SKILL_PROBES[1];
377 let Ok(home) = crate::skills::home() else {
378 return ProbeResult::failed(
379 id,
380 ProbeClass::Soft,
381 "neither HOME nor USERPROFILE is set, so the shared root does not resolve",
382 "export HOME",
383 );
384 };
385 let root = home.join(SHARED_ROOT);
386 let record = Record::load(&home.join(RECORD_PATH));
387 let planned: Vec<(Utf8PathBuf, &'static [u8])> = crate::skills::shared()
388 .into_iter()
389 .map(|artifact| (root.join(&artifact.path), artifact.bytes))
390 .collect();
391 let found = judge(planned, &record);
392 if let Some(first) = found.missing.first() {
393 return ProbeResult::failed(
394 id,
395 ProbeClass::Soft,
396 format!("a shared artifact every skill reads before acting is not installed: {first}"),
397 "rk skill install --apply",
398 );
399 }
400 if !found.differing.is_empty() {
401 return ProbeResult::failed(
402 id,
403 ProbeClass::Soft,
404 format!(
405 "{} shared artifact(s) under {root} are not this binary's",
406 found.differing.len()
407 ),
408 reinstall(found.all_recorded),
409 );
410 }
411 ProbeResult::ok(
412 id,
413 ProbeClass::Soft,
414 format!("{root} holds this binary's shared artifacts"),
415 )
416}
417
418fn skill_payload() -> ProbeResult {
426 let id = SKILL_PROBES[2];
427 let Ok(home) = crate::skills::home() else {
428 return ProbeResult::failed(
429 id,
430 ProbeClass::Soft,
431 "neither HOME nor USERPROFILE is set, so no agent root resolves",
432 "export HOME",
433 );
434 };
435 let Ok(skills) = crate::skills::all() else {
436 return ProbeResult::failed(
437 id,
438 ProbeClass::Soft,
439 "this binary's embedded skills do not read",
440 "reinstall rk; the payload it was built from is defective",
441 );
442 };
443 let record = Record::load(&home.join(RECORD_PATH));
444 let mut planned = Vec::new();
445 for root in [CLAUDE_ROOT, AGENTS_ROOT] {
446 let root = home.join(root);
447 if !root.is_dir() {
450 continue;
451 }
452 for skill in &skills {
453 planned.push((
454 root.join(&skill.name).join("SKILL.md"),
455 skill.text.as_bytes(),
456 ));
457 }
458 }
459 if planned.is_empty() {
460 return ProbeResult::failed(
461 id,
462 ProbeClass::Soft,
463 format!("no agent skill root exists under {home}"),
464 "rk skill install --apply",
465 );
466 }
467 let found = judge(planned, &record);
468 if let Some(first) = found.missing.first() {
469 return ProbeResult::failed(
470 id,
471 ProbeClass::Soft,
472 format!(
473 "{} of this binary's skills are not installed, the first at {first}",
474 found.missing.len()
475 ),
476 "rk skill install --apply",
477 );
478 }
479 if !found.differing.is_empty() {
480 return ProbeResult::failed(
481 id,
482 ProbeClass::Soft,
483 format!(
484 "{} installed skill(s) are not this binary's; rk is {}",
485 found.differing.len(),
486 env!("CARGO_PKG_VERSION")
487 ),
488 reinstall(found.all_recorded),
489 );
490 }
491 ProbeResult::ok(
492 id,
493 ProbeClass::Soft,
494 format!(
495 "{} installed skill destination(s) are this binary's",
496 found.matching
497 ),
498 )
499}
500
501struct Installed {
503 missing: Vec<Utf8PathBuf>,
505 differing: Vec<Utf8PathBuf>,
507 matching: usize,
509 all_recorded: bool,
513}
514
515fn judge(planned: Vec<(Utf8PathBuf, &'static [u8])>, record: &Record) -> Installed {
517 let mut found = Installed {
518 missing: Vec::new(),
519 differing: Vec::new(),
520 matching: 0,
521 all_recorded: true,
522 };
523 for (destination, bytes) in planned {
524 match std::fs::read(&destination) {
525 Ok(held) if held == bytes => found.matching += 1,
526 Ok(held) => {
527 if !record.wrote(&destination, &Digest::of(&held)) {
528 found.all_recorded = false;
529 }
530 found.differing.push(destination);
531 }
532 Err(_) => found.missing.push(destination),
533 }
534 }
535 found
536}
537
538const fn reinstall(all_recorded: bool) -> &'static str {
542 if all_recorded {
543 "rk skill install --apply"
544 } else {
545 "rk skill install --apply --force"
546 }
547}
548
549fn nearest_existing(path: &Utf8Path) -> Option<Utf8PathBuf> {
552 let mut current = Some(path);
553 while let Some(dir) = current {
554 if dir.is_dir() {
555 return Some(dir.to_owned());
556 }
557 current = dir.parent();
558 }
559 None
560}
561
562fn accepts_a_write(dir: &Utf8Path) -> std::io::Result<()> {
564 let probe = dir.join(format!(".rk-probe-{}", std::process::id()));
565 let written = std::fs::write(&probe, b"probe");
566 let _ = std::fs::remove_file(&probe);
567 written
568}
569
570fn git_remote() -> ProbeResult {
573 let id = "git-remote";
574 let out = Command::new(git_bin())
575 .args(["remote", "get-url", "origin"])
576 .output();
577 let url = match out {
578 Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_owned(),
579 _ => {
580 return ProbeResult::failed(
581 id,
582 ProbeClass::Soft,
583 "the working directory has no origin remote",
584 "pass --repo <owner/name> where a command needs the slug",
585 );
586 }
587 };
588 remote_host(&url).map_or_else(
592 || {
593 ProbeResult::failed(
594 id,
595 ProbeClass::Soft,
596 "the origin remote does not parse to a host",
597 "pass --repo <owner/name> where a command needs the slug",
598 )
599 },
600 |host| ProbeResult::ok(id, ProbeClass::Soft, format!("origin resolves to {host}")),
601 )
602}
603
604fn remote_host(url: &str) -> Option<String> {
606 if let Some(rest) = url.split_once("://").map(|(_, rest)| rest) {
607 let authority = rest.split('/').next()?;
608 let host = authority
609 .rsplit_once('@')
610 .map_or(authority, |(_, host)| host);
611 let host = host.split(':').next()?;
612 return (!host.is_empty()).then(|| host.to_owned());
613 }
614 let (authority, path) = url.split_once(':')?;
615 let host = authority
616 .rsplit_once('@')
617 .map_or(authority, |(_, host)| host);
618 (!host.is_empty() && !path.is_empty()).then(|| host.to_owned())
619}
620
621fn forge_cli(
627 id: &'static str,
628 env_override: &str,
629 default_bin: &str,
630 label: &str,
631 login: &str,
632 attempts: &[&[&str]],
633) -> ProbeResult {
634 let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
635 let mut spawned = false;
636 for args in attempts {
637 match Command::new(&bin).args(*args).output() {
638 Ok(out) if out.status.success() => {
639 return ProbeResult::ok(
640 id,
641 ProbeClass::Soft,
642 format!("{default_bin} is authenticated"),
643 );
644 }
645 Ok(_) => spawned = true,
646 Err(_) => {}
647 }
648 }
649 if spawned {
650 ProbeResult::failed(
651 id,
652 ProbeClass::Soft,
653 format!("{default_bin} is not authenticated"),
654 format!("run {login}"),
655 )
656 } else {
657 ProbeResult::failed(
658 id,
659 ProbeClass::Soft,
660 format!("{default_bin} is not on PATH"),
661 format!("install {label}"),
662 )
663 }
664}
665
666#[must_use]
669pub fn forge_bin(forge: Forge) -> String {
670 std::env::var(forge.cli_override()).unwrap_or_else(|_| forge.cli().to_owned())
671}
672
673const fn version_probe_id(forge: Forge) -> &'static str {
675 match forge {
676 Forge::Github => "gh-version",
677 Forge::Gitlab => "glab-version",
678 }
679}
680
681#[must_use]
686pub fn parse_cli_version(text: &str) -> Option<(u32, u32, u32)> {
687 let bytes = text.as_bytes();
688 let mut start = 0;
689 while start < bytes.len() {
690 if !bytes[start].is_ascii_digit() {
691 start += 1;
692 continue;
693 }
694 let mut end = start;
695 while end < bytes.len() && (bytes[end].is_ascii_digit() || bytes[end] == b'.') {
696 end += 1;
697 }
698 let run = &text[start..end];
699 let mut parts = run.split('.');
700 let parsed = (|| {
701 let major = parts.next()?.parse().ok()?;
702 let minor = parts.next()?.parse().ok()?;
703 let patch = parts.next()?.parse().ok()?;
704 Some((major, minor, patch))
705 })();
706 if let Some(version) = parsed {
707 return Some(version);
708 }
709 start = end.max(start + 1);
710 }
711 None
712}
713
714#[must_use]
718pub fn forge_cli_version(bin: &str) -> Option<(u32, u32, u32)> {
719 let out = Command::new(bin).arg("--version").output().ok()?;
720 if !out.status.success() {
721 return None;
722 }
723 parse_cli_version(&String::from_utf8_lossy(&out.stdout))
724}
725
726fn forge_cli_floor(forge: Forge) -> ProbeResult {
730 let id = version_probe_id(forge);
731 let bin = forge_bin(forge);
732 let name = forge.cli();
733 let floor = forge.cli_floor();
734 match forge_cli_version(&bin) {
735 Some(found) if found >= floor => ProbeResult::ok(
736 id,
737 ProbeClass::Soft,
738 format!("{name} {} is at or above {}", show(found), show(floor)),
739 ),
740 Some(found) => ProbeResult::failed(
741 id,
742 ProbeClass::Soft,
743 format!(
744 "{name} {} is below the {} rk calls",
745 show(found),
746 show(floor)
747 ),
748 forge.cli_upgrade(),
749 ),
750 None => ProbeResult::failed(
751 id,
752 ProbeClass::Soft,
753 format!("{name} does not answer --version with a version"),
754 format!("install {name}"),
755 ),
756 }
757}
758
759fn show((major, minor, patch): (u32, u32, u32)) -> String {
761 format!("{major}.{minor}.{patch}")
762}
763
764pub fn require_forge_cli(forge: Forge) -> Result<(String, (u32, u32, u32)), RkError> {
776 let bin = forge_bin(forge);
777 let name = forge.cli();
778 let floor = forge.cli_floor();
779 let Some(found) = forge_cli_version(&bin) else {
780 return Err(RkError::refusal(
781 Diagnostic::new(
782 Reason::PrerequisiteUnmet,
783 format!("{name} does not answer --version with a version"),
784 )
785 .expected(format!("{name} at or above {} on PATH", show(floor)))
786 .action(format!("install {name}, then rerun"))
787 .target_state("unchanged"),
788 ));
789 };
790 if found < floor {
791 return Err(RkError::refusal(
792 Diagnostic::new(
793 Reason::PrerequisiteUnmet,
794 format!(
795 "{name} {} is below the {} rk calls",
796 show(found),
797 show(floor)
798 ),
799 )
800 .expected(format!("{name} at or above {}", show(floor)))
801 .action(forge.cli_upgrade())
802 .target_state("unchanged"),
803 ));
804 }
805 Ok((bin, found))
806}
807
808#[cfg(test)]
809mod tests {
810 use super::{parse_cli_version, remote_host};
811
812 #[test]
816 fn a_version_line_parses_from_both_forge_clis() {
817 assert_eq!(
818 parse_cli_version("gh version 2.19.0 (2022-10-25)"),
819 Some((2, 19, 0))
820 );
821 assert_eq!(
822 parse_cli_version("glab 1.114.0 (4d7c6cd)\n"),
823 Some((1, 114, 0))
824 );
825 assert_eq!(parse_cli_version("gh version 2.99.0"), Some((2, 99, 0)));
826 assert_eq!(parse_cli_version("no version here"), None);
827 assert_eq!(parse_cli_version("gh version 2.19"), None);
828 }
829
830 #[test]
833 fn a_floor_comparison_orders_by_component() {
834 assert!((2, 100, 0) > (2, 99, 0));
835 assert!((2, 9, 0) < (2, 19, 0));
836 assert!((2, 19, 0) >= (2, 19, 0));
837 }
838
839 #[test]
840 fn a_remote_host_parses_from_both_url_forms() {
841 assert_eq!(
842 remote_host("https://github.com/owner/name.git").as_deref(),
843 Some("github.com")
844 );
845 assert_eq!(
846 remote_host("git@gitlab.com:group/sub/name.git").as_deref(),
847 Some("gitlab.com")
848 );
849 assert_eq!(
850 remote_host("ssh://git@github.com:22/owner/name.git").as_deref(),
851 Some("github.com")
852 );
853 assert_eq!(remote_host("not a url"), None);
854 }
855}