1use std::process::Command;
11
12use camino::{Utf8Path, Utf8PathBuf};
13use serde::Serialize;
14
15use crate::skills::record::{RECORD_PATH, Record};
16use crate::skills::{AGENTS_ROOT, CLAUDE_ROOT, Digest, SHARED_ROOT};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
20#[serde(rename_all = "kebab-case")]
21pub enum ProbeClass {
22 Hard,
24 Soft,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
30#[serde(rename_all = "kebab-case")]
31pub enum ProbeStatus {
32 Ok,
34 Failed,
36}
37
38#[derive(Debug, Serialize)]
40pub struct ProbeResult {
41 pub id: &'static str,
43 pub class: ProbeClass,
45 pub status: ProbeStatus,
47 pub message: String,
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub remediation: Option<String>,
52}
53
54impl ProbeResult {
55 fn ok(id: &'static str, class: ProbeClass, message: impl Into<String>) -> Self {
56 Self {
57 id,
58 class,
59 status: ProbeStatus::Ok,
60 message: message.into(),
61 remediation: None,
62 }
63 }
64
65 fn failed(
66 id: &'static str,
67 class: ProbeClass,
68 message: impl Into<String>,
69 remediation: impl Into<String>,
70 ) -> Self {
71 Self {
72 id,
73 class,
74 status: ProbeStatus::Failed,
75 message: message.into(),
76 remediation: Some(remediation.into()),
77 }
78 }
79}
80
81pub const SKILL_PROBES: [&str; 3] = ["skill-roots", "skill-gate", "skill-payload"];
88
89pub const HARD_RUNTIME_TOOLS: [(&str, &str); 2] = [("git", "git"), ("sh", "bash")];
97
98#[must_use]
104pub fn git_bin() -> std::ffi::OsString {
105 std::env::var_os("RK_GIT_BIN").unwrap_or_else(|| "git".into())
106}
107
108#[must_use]
111pub fn nix_bin() -> std::ffi::OsString {
112 std::env::var_os("RK_NIX_BIN").unwrap_or_else(|| "nix".into())
113}
114
115#[must_use]
119pub fn direnv_bin() -> std::ffi::OsString {
120 std::env::var_os("RK_DIRENV_BIN").unwrap_or_else(|| "direnv".into())
121}
122
123#[must_use]
127pub fn nix() -> ProbeResult {
128 tool(
129 "nix",
130 "RK_NIX_BIN",
131 "nix",
132 "Nix; rk devshell sync updates and builds the pinned devshell with it",
133 &["--version"],
134 )
135}
136
137#[must_use]
139pub fn direnv() -> ProbeResult {
140 tool(
141 "direnv",
142 "RK_DIRENV_BIN",
143 "direnv",
144 "direnv; it loads the devshell on directory entry",
145 &["version"],
146 )
147}
148
149#[must_use]
154pub fn sh_bin() -> std::ffi::OsString {
155 std::env::var_os("RK_SH_BIN").unwrap_or_else(|| "sh".into())
156}
157
158#[must_use]
160pub fn run_all() -> Vec<ProbeResult> {
161 vec![
162 shell(),
163 git(),
164 state_root(),
165 skill_roots(),
166 skill_gate(),
167 skill_payload(),
168 git_remote(),
169 forge_cli(
170 "gh-auth",
171 "RK_GH_BIN",
172 "gh",
173 "the GitHub CLI",
174 "gh auth login",
175 &[&["auth", "status", "--active"], &["auth", "status"]],
180 ),
181 forge_cli(
182 "glab-auth",
183 "RK_GLAB_BIN",
184 "glab",
185 "the GitLab CLI",
186 "glab auth login",
187 &[&["auth", "status"]],
188 ),
189 tool(
190 "openssl",
191 "RK_OPENSSL_BIN",
192 "openssl",
193 "OpenSSL; install-bot signs the App JWT with it",
194 &["version"],
195 ),
196 tool(
197 "curl",
198 "RK_CURL_BIN",
199 "curl",
200 "curl; install-bot reads the installation, and rk versions --check and rk devshell sync fetch with it",
201 &["--version"],
202 ),
203 nix(),
204 direnv(),
205 tool(
206 "cosign",
207 "RK_COSIGN_BIN",
208 "cosign",
209 "cosign; the release verify step checks a GitLab provenance bundle with it",
210 &["version"],
211 ),
212 tool(
213 "pypi-attestations",
214 "RK_PYPI_ATTESTATIONS_BIN",
215 "pypi-attestations",
216 "pypi-attestations; the release verify step checks a PyPI distribution's attestations with it",
217 &["--help"],
218 ),
219 ]
220}
221
222fn tool(
226 id: &'static str,
227 env_override: &str,
228 default_bin: &str,
229 label: &str,
230 args: &[&str],
231) -> ProbeResult {
232 let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
233 match Command::new(&bin).args(args).output() {
234 Ok(out) if out.status.success() => {
235 ProbeResult::ok(id, ProbeClass::Soft, format!("{default_bin} runs"))
236 }
237 Ok(_) => ProbeResult::failed(
238 id,
239 ProbeClass::Soft,
240 format!("{default_bin} does not answer {}", args.join(" ")),
241 format!("repair {label}"),
242 ),
243 Err(_) => ProbeResult::failed(
244 id,
245 ProbeClass::Soft,
246 format!("{default_bin} is not on PATH"),
247 format!("install {label}"),
248 ),
249 }
250}
251
252fn shell() -> ProbeResult {
254 let id = "sh";
255 match Command::new(sh_bin()).args(["-c", "exit 0"]).status() {
256 Ok(status) if status.success() => ProbeResult::ok(id, ProbeClass::Hard, "sh runs"),
257 Ok(status) => ProbeResult::failed(
258 id,
259 ProbeClass::Hard,
260 format!("sh exited {status}"),
261 "repair the POSIX shell on PATH",
262 ),
263 Err(source) => ProbeResult::failed(
264 id,
265 ProbeClass::Hard,
266 format!("sh does not spawn: {source}"),
267 "install a POSIX shell on PATH",
268 ),
269 }
270}
271
272fn git() -> ProbeResult {
275 let id = "git";
276 match Command::new(git_bin()).arg("--version").output() {
277 Ok(out) if out.status.success() => ProbeResult::ok(id, ProbeClass::Hard, "git runs"),
278 Ok(_) => ProbeResult::failed(
279 id,
280 ProbeClass::Hard,
281 "git does not answer --version",
282 "repair the git on PATH, or point RK_GIT_BIN at a working one",
283 ),
284 Err(_) => ProbeResult::failed(id, ProbeClass::Hard, "git is not on PATH", "install git"),
285 }
286}
287
288fn state_root() -> ProbeResult {
291 let id = "state-root";
292 let Some(root) = crate::applog::state_root() else {
293 return ProbeResult::failed(
294 id,
295 ProbeClass::Hard,
296 "neither XDG_STATE_HOME nor HOME is set",
297 "export HOME, or XDG_STATE_HOME",
298 );
299 };
300 let display = root.display().to_string();
301 let probe = root.join(format!(".probe-{}", std::process::id()));
302 let written = std::fs::create_dir_all(&root).and_then(|()| std::fs::write(&probe, b"probe"));
303 let _ = std::fs::remove_file(&probe);
304 match written {
305 Ok(()) => ProbeResult::ok(id, ProbeClass::Hard, format!("{display} is writable")),
306 Err(source) => ProbeResult::failed(
307 id,
308 ProbeClass::Hard,
309 format!("{display} is not writable: {source}"),
310 format!("make {display} writable"),
311 ),
312 }
313}
314
315fn skill_roots() -> ProbeResult {
325 let id = SKILL_PROBES[0];
326 let Ok(home) = crate::skills::home() else {
327 return ProbeResult::failed(
328 id,
329 ProbeClass::Soft,
330 "neither HOME nor USERPROFILE is set, so no skill root resolves",
331 "export HOME",
332 );
333 };
334 let mut refused = Vec::new();
335 for root in [CLAUDE_ROOT, AGENTS_ROOT, SHARED_ROOT] {
336 let root = home.join(root);
337 let Some(existing) = nearest_existing(&root) else {
338 refused.push(format!("no ancestor of {root} exists"));
339 continue;
340 };
341 if let Err(source) = accepts_a_write(&existing) {
342 refused.push(format!("{existing} is not writable: {source}"));
343 }
344 }
345 if refused.is_empty() {
346 ProbeResult::ok(
347 id,
348 ProbeClass::Soft,
349 format!("the skill roots under {home} accept writes"),
350 )
351 } else {
352 ProbeResult::failed(
353 id,
354 ProbeClass::Soft,
355 refused.join("; "),
356 format!("make the skill roots under {home} writable"),
357 )
358 }
359}
360
361fn skill_gate() -> ProbeResult {
371 let id = SKILL_PROBES[1];
372 let Ok(home) = crate::skills::home() else {
373 return ProbeResult::failed(
374 id,
375 ProbeClass::Soft,
376 "neither HOME nor USERPROFILE is set, so the shared root does not resolve",
377 "export HOME",
378 );
379 };
380 let root = home.join(SHARED_ROOT);
381 let record = Record::load(&home.join(RECORD_PATH));
382 let planned: Vec<(Utf8PathBuf, &'static [u8])> = crate::skills::shared()
383 .into_iter()
384 .map(|artifact| (root.join(&artifact.path), artifact.bytes))
385 .collect();
386 let found = judge(planned, &record);
387 if let Some(first) = found.missing.first() {
388 return ProbeResult::failed(
389 id,
390 ProbeClass::Soft,
391 format!("a shared artifact every skill reads before acting is not installed: {first}"),
392 "rk skill install --apply",
393 );
394 }
395 if !found.differing.is_empty() {
396 return ProbeResult::failed(
397 id,
398 ProbeClass::Soft,
399 format!(
400 "{} shared artifact(s) under {root} are not this binary's",
401 found.differing.len()
402 ),
403 reinstall(found.all_recorded),
404 );
405 }
406 ProbeResult::ok(
407 id,
408 ProbeClass::Soft,
409 format!("{root} holds this binary's shared artifacts"),
410 )
411}
412
413fn skill_payload() -> ProbeResult {
421 let id = SKILL_PROBES[2];
422 let Ok(home) = crate::skills::home() else {
423 return ProbeResult::failed(
424 id,
425 ProbeClass::Soft,
426 "neither HOME nor USERPROFILE is set, so no agent root resolves",
427 "export HOME",
428 );
429 };
430 let Ok(skills) = crate::skills::all() else {
431 return ProbeResult::failed(
432 id,
433 ProbeClass::Soft,
434 "this binary's embedded skills do not read",
435 "reinstall rk; the payload it was built from is defective",
436 );
437 };
438 let record = Record::load(&home.join(RECORD_PATH));
439 let mut planned = Vec::new();
440 for root in [CLAUDE_ROOT, AGENTS_ROOT] {
441 let root = home.join(root);
442 if !root.is_dir() {
445 continue;
446 }
447 for skill in &skills {
448 planned.push((
449 root.join(&skill.name).join("SKILL.md"),
450 skill.text.as_bytes(),
451 ));
452 }
453 }
454 if planned.is_empty() {
455 return ProbeResult::failed(
456 id,
457 ProbeClass::Soft,
458 format!("no agent skill root exists under {home}"),
459 "rk skill install --apply",
460 );
461 }
462 let found = judge(planned, &record);
463 if let Some(first) = found.missing.first() {
464 return ProbeResult::failed(
465 id,
466 ProbeClass::Soft,
467 format!(
468 "{} of this binary's skills are not installed, the first at {first}",
469 found.missing.len()
470 ),
471 "rk skill install --apply",
472 );
473 }
474 if !found.differing.is_empty() {
475 return ProbeResult::failed(
476 id,
477 ProbeClass::Soft,
478 format!(
479 "{} installed skill(s) are not this binary's; rk is {}",
480 found.differing.len(),
481 env!("CARGO_PKG_VERSION")
482 ),
483 reinstall(found.all_recorded),
484 );
485 }
486 ProbeResult::ok(
487 id,
488 ProbeClass::Soft,
489 format!(
490 "{} installed skill destination(s) are this binary's",
491 found.matching
492 ),
493 )
494}
495
496struct Installed {
498 missing: Vec<Utf8PathBuf>,
500 differing: Vec<Utf8PathBuf>,
502 matching: usize,
504 all_recorded: bool,
508}
509
510fn judge(planned: Vec<(Utf8PathBuf, &'static [u8])>, record: &Record) -> Installed {
512 let mut found = Installed {
513 missing: Vec::new(),
514 differing: Vec::new(),
515 matching: 0,
516 all_recorded: true,
517 };
518 for (destination, bytes) in planned {
519 match std::fs::read(&destination) {
520 Ok(held) if held == bytes => found.matching += 1,
521 Ok(held) => {
522 if !record.wrote(&destination, &Digest::of(&held)) {
523 found.all_recorded = false;
524 }
525 found.differing.push(destination);
526 }
527 Err(_) => found.missing.push(destination),
528 }
529 }
530 found
531}
532
533const fn reinstall(all_recorded: bool) -> &'static str {
537 if all_recorded {
538 "rk skill install --apply"
539 } else {
540 "rk skill install --apply --force"
541 }
542}
543
544fn nearest_existing(path: &Utf8Path) -> Option<Utf8PathBuf> {
547 let mut current = Some(path);
548 while let Some(dir) = current {
549 if dir.is_dir() {
550 return Some(dir.to_owned());
551 }
552 current = dir.parent();
553 }
554 None
555}
556
557fn accepts_a_write(dir: &Utf8Path) -> std::io::Result<()> {
559 let probe = dir.join(format!(".rk-probe-{}", std::process::id()));
560 let written = std::fs::write(&probe, b"probe");
561 let _ = std::fs::remove_file(&probe);
562 written
563}
564
565fn git_remote() -> ProbeResult {
568 let id = "git-remote";
569 let out = Command::new(git_bin())
570 .args(["remote", "get-url", "origin"])
571 .output();
572 let url = match out {
573 Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_owned(),
574 _ => {
575 return ProbeResult::failed(
576 id,
577 ProbeClass::Soft,
578 "the working directory has no origin remote",
579 "pass --repo <owner/name> where a command needs the slug",
580 );
581 }
582 };
583 remote_host(&url).map_or_else(
587 || {
588 ProbeResult::failed(
589 id,
590 ProbeClass::Soft,
591 "the origin remote does not parse to a host",
592 "pass --repo <owner/name> where a command needs the slug",
593 )
594 },
595 |host| ProbeResult::ok(id, ProbeClass::Soft, format!("origin resolves to {host}")),
596 )
597}
598
599fn remote_host(url: &str) -> Option<String> {
601 if let Some(rest) = url.split_once("://").map(|(_, rest)| rest) {
602 let authority = rest.split('/').next()?;
603 let host = authority
604 .rsplit_once('@')
605 .map_or(authority, |(_, host)| host);
606 let host = host.split(':').next()?;
607 return (!host.is_empty()).then(|| host.to_owned());
608 }
609 let (authority, path) = url.split_once(':')?;
610 let host = authority
611 .rsplit_once('@')
612 .map_or(authority, |(_, host)| host);
613 (!host.is_empty() && !path.is_empty()).then(|| host.to_owned())
614}
615
616fn forge_cli(
622 id: &'static str,
623 env_override: &str,
624 default_bin: &str,
625 label: &str,
626 login: &str,
627 attempts: &[&[&str]],
628) -> ProbeResult {
629 let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
630 let mut spawned = false;
631 for args in attempts {
632 match Command::new(&bin).args(*args).output() {
633 Ok(out) if out.status.success() => {
634 return ProbeResult::ok(
635 id,
636 ProbeClass::Soft,
637 format!("{default_bin} is authenticated"),
638 );
639 }
640 Ok(_) => spawned = true,
641 Err(_) => {}
642 }
643 }
644 if spawned {
645 ProbeResult::failed(
646 id,
647 ProbeClass::Soft,
648 format!("{default_bin} is not authenticated"),
649 format!("run {login}"),
650 )
651 } else {
652 ProbeResult::failed(
653 id,
654 ProbeClass::Soft,
655 format!("{default_bin} is not on PATH"),
656 format!("install {label}"),
657 )
658 }
659}
660
661#[cfg(test)]
662mod tests {
663 use super::remote_host;
664
665 #[test]
666 fn a_remote_host_parses_from_both_url_forms() {
667 assert_eq!(
668 remote_host("https://github.com/owner/name.git").as_deref(),
669 Some("github.com")
670 );
671 assert_eq!(
672 remote_host("git@gitlab.com:group/sub/name.git").as_deref(),
673 Some("gitlab.com")
674 );
675 assert_eq!(
676 remote_host("ssh://git@github.com:22/owner/name.git").as_deref(),
677 Some("github.com")
678 );
679 assert_eq!(remote_host("not a url"), None);
680 }
681}