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
89#[must_use]
91pub fn run_all() -> Vec<ProbeResult> {
92 vec![
93 shell(),
94 state_root(),
95 skill_roots(),
96 skill_gate(),
97 skill_payload(),
98 git_remote(),
99 forge_cli(
100 "gh-auth",
101 "RK_GH_BIN",
102 "gh",
103 "the GitHub CLI",
104 "gh auth login",
105 &[&["auth", "status", "--active"], &["auth", "status"]],
110 ),
111 forge_cli(
112 "glab-auth",
113 "RK_GLAB_BIN",
114 "glab",
115 "the GitLab CLI",
116 "glab auth login",
117 &[&["auth", "status"]],
118 ),
119 tool(
120 "openssl",
121 "RK_OPENSSL_BIN",
122 "openssl",
123 "OpenSSL; install-bot signs the App JWT with it",
124 &["version"],
125 ),
126 tool(
127 "curl",
128 "RK_CURL_BIN",
129 "curl",
130 "curl; install-bot reads the installation and rk versions --check fetches with it",
131 &["--version"],
132 ),
133 tool(
134 "cosign",
135 "RK_COSIGN_BIN",
136 "cosign",
137 "cosign; the release verify step checks a GitLab provenance bundle with it",
138 &["version"],
139 ),
140 tool(
141 "pypi-attestations",
142 "RK_PYPI_ATTESTATIONS_BIN",
143 "pypi-attestations",
144 "pypi-attestations; the release verify step checks a PyPI distribution's attestations with it",
145 &["--help"],
146 ),
147 ]
148}
149
150fn tool(
154 id: &'static str,
155 env_override: &str,
156 default_bin: &str,
157 label: &str,
158 args: &[&str],
159) -> ProbeResult {
160 let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
161 match Command::new(&bin).args(args).output() {
162 Ok(out) if out.status.success() => {
163 ProbeResult::ok(id, ProbeClass::Soft, format!("{default_bin} runs"))
164 }
165 Ok(_) => ProbeResult::failed(
166 id,
167 ProbeClass::Soft,
168 format!("{default_bin} does not answer {}", args.join(" ")),
169 format!("repair {label}"),
170 ),
171 Err(_) => ProbeResult::failed(
172 id,
173 ProbeClass::Soft,
174 format!("{default_bin} is not on PATH"),
175 format!("install {label}"),
176 ),
177 }
178}
179
180fn shell() -> ProbeResult {
182 let id = "sh";
183 match Command::new("sh").args(["-c", "exit 0"]).status() {
184 Ok(status) if status.success() => ProbeResult::ok(id, ProbeClass::Hard, "sh runs"),
185 Ok(status) => ProbeResult::failed(
186 id,
187 ProbeClass::Hard,
188 format!("sh exited {status}"),
189 "repair the POSIX shell on PATH",
190 ),
191 Err(source) => ProbeResult::failed(
192 id,
193 ProbeClass::Hard,
194 format!("sh does not spawn: {source}"),
195 "install a POSIX shell on PATH",
196 ),
197 }
198}
199
200fn state_root() -> ProbeResult {
203 let id = "state-root";
204 let Some(root) = crate::applog::state_root() else {
205 return ProbeResult::failed(
206 id,
207 ProbeClass::Hard,
208 "neither XDG_STATE_HOME nor HOME is set",
209 "export HOME, or XDG_STATE_HOME",
210 );
211 };
212 let display = root.display().to_string();
213 let probe = root.join(format!(".probe-{}", std::process::id()));
214 let written = std::fs::create_dir_all(&root).and_then(|()| std::fs::write(&probe, b"probe"));
215 let _ = std::fs::remove_file(&probe);
216 match written {
217 Ok(()) => ProbeResult::ok(id, ProbeClass::Hard, format!("{display} is writable")),
218 Err(source) => ProbeResult::failed(
219 id,
220 ProbeClass::Hard,
221 format!("{display} is not writable: {source}"),
222 format!("make {display} writable"),
223 ),
224 }
225}
226
227fn skill_roots() -> ProbeResult {
237 let id = SKILL_PROBES[0];
238 let Ok(home) = crate::skills::home() else {
239 return ProbeResult::failed(
240 id,
241 ProbeClass::Soft,
242 "neither HOME nor USERPROFILE is set, so no skill root resolves",
243 "export HOME",
244 );
245 };
246 let mut refused = Vec::new();
247 for root in [CLAUDE_ROOT, AGENTS_ROOT, SHARED_ROOT] {
248 let root = home.join(root);
249 let Some(existing) = nearest_existing(&root) else {
250 refused.push(format!("no ancestor of {root} exists"));
251 continue;
252 };
253 if let Err(source) = accepts_a_write(&existing) {
254 refused.push(format!("{existing} is not writable: {source}"));
255 }
256 }
257 if refused.is_empty() {
258 ProbeResult::ok(
259 id,
260 ProbeClass::Soft,
261 format!("the skill roots under {home} accept writes"),
262 )
263 } else {
264 ProbeResult::failed(
265 id,
266 ProbeClass::Soft,
267 refused.join("; "),
268 format!("make the skill roots under {home} writable"),
269 )
270 }
271}
272
273fn skill_gate() -> ProbeResult {
283 let id = SKILL_PROBES[1];
284 let Ok(home) = crate::skills::home() else {
285 return ProbeResult::failed(
286 id,
287 ProbeClass::Soft,
288 "neither HOME nor USERPROFILE is set, so the shared root does not resolve",
289 "export HOME",
290 );
291 };
292 let root = home.join(SHARED_ROOT);
293 let record = Record::load(&home.join(RECORD_PATH));
294 let planned: Vec<(Utf8PathBuf, &'static [u8])> = crate::skills::shared()
295 .into_iter()
296 .map(|artifact| (root.join(&artifact.path), artifact.bytes))
297 .collect();
298 let found = judge(planned, &record);
299 if let Some(first) = found.missing.first() {
300 return ProbeResult::failed(
301 id,
302 ProbeClass::Soft,
303 format!("a shared artifact every skill reads before acting is not installed: {first}"),
304 "rk skill install --apply",
305 );
306 }
307 if !found.differing.is_empty() {
308 return ProbeResult::failed(
309 id,
310 ProbeClass::Soft,
311 format!(
312 "{} shared artifact(s) under {root} are not this binary's",
313 found.differing.len()
314 ),
315 reinstall(found.all_recorded),
316 );
317 }
318 ProbeResult::ok(
319 id,
320 ProbeClass::Soft,
321 format!("{root} holds this binary's shared artifacts"),
322 )
323}
324
325fn skill_payload() -> ProbeResult {
333 let id = SKILL_PROBES[2];
334 let Ok(home) = crate::skills::home() else {
335 return ProbeResult::failed(
336 id,
337 ProbeClass::Soft,
338 "neither HOME nor USERPROFILE is set, so no agent root resolves",
339 "export HOME",
340 );
341 };
342 let Ok(skills) = crate::skills::all() else {
343 return ProbeResult::failed(
344 id,
345 ProbeClass::Soft,
346 "this binary's embedded skills do not read",
347 "reinstall rk; the payload it was built from is defective",
348 );
349 };
350 let record = Record::load(&home.join(RECORD_PATH));
351 let mut planned = Vec::new();
352 for root in [CLAUDE_ROOT, AGENTS_ROOT] {
353 let root = home.join(root);
354 if !root.is_dir() {
357 continue;
358 }
359 for skill in &skills {
360 planned.push((
361 root.join(&skill.name).join("SKILL.md"),
362 skill.text.as_bytes(),
363 ));
364 }
365 }
366 if planned.is_empty() {
367 return ProbeResult::failed(
368 id,
369 ProbeClass::Soft,
370 format!("no agent skill root exists under {home}"),
371 "rk skill install --apply",
372 );
373 }
374 let found = judge(planned, &record);
375 if let Some(first) = found.missing.first() {
376 return ProbeResult::failed(
377 id,
378 ProbeClass::Soft,
379 format!(
380 "{} of this binary's skills are not installed, the first at {first}",
381 found.missing.len()
382 ),
383 "rk skill install --apply",
384 );
385 }
386 if !found.differing.is_empty() {
387 return ProbeResult::failed(
388 id,
389 ProbeClass::Soft,
390 format!(
391 "{} installed skill(s) are not this binary's; rk is {}",
392 found.differing.len(),
393 env!("CARGO_PKG_VERSION")
394 ),
395 reinstall(found.all_recorded),
396 );
397 }
398 ProbeResult::ok(
399 id,
400 ProbeClass::Soft,
401 format!(
402 "{} installed skill destination(s) are this binary's",
403 found.matching
404 ),
405 )
406}
407
408struct Installed {
410 missing: Vec<Utf8PathBuf>,
412 differing: Vec<Utf8PathBuf>,
414 matching: usize,
416 all_recorded: bool,
420}
421
422fn judge(planned: Vec<(Utf8PathBuf, &'static [u8])>, record: &Record) -> Installed {
424 let mut found = Installed {
425 missing: Vec::new(),
426 differing: Vec::new(),
427 matching: 0,
428 all_recorded: true,
429 };
430 for (destination, bytes) in planned {
431 match std::fs::read(&destination) {
432 Ok(held) if held == bytes => found.matching += 1,
433 Ok(held) => {
434 if !record.wrote(&destination, &Digest::of(&held)) {
435 found.all_recorded = false;
436 }
437 found.differing.push(destination);
438 }
439 Err(_) => found.missing.push(destination),
440 }
441 }
442 found
443}
444
445const fn reinstall(all_recorded: bool) -> &'static str {
449 if all_recorded {
450 "rk skill install --apply"
451 } else {
452 "rk skill install --apply --force"
453 }
454}
455
456fn nearest_existing(path: &Utf8Path) -> Option<Utf8PathBuf> {
459 let mut current = Some(path);
460 while let Some(dir) = current {
461 if dir.is_dir() {
462 return Some(dir.to_owned());
463 }
464 current = dir.parent();
465 }
466 None
467}
468
469fn accepts_a_write(dir: &Utf8Path) -> std::io::Result<()> {
471 let probe = dir.join(format!(".rk-probe-{}", std::process::id()));
472 let written = std::fs::write(&probe, b"probe");
473 let _ = std::fs::remove_file(&probe);
474 written
475}
476
477fn git_remote() -> ProbeResult {
480 let id = "git-remote";
481 let out = Command::new("git")
482 .args(["remote", "get-url", "origin"])
483 .output();
484 let url = match out {
485 Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_owned(),
486 _ => {
487 return ProbeResult::failed(
488 id,
489 ProbeClass::Soft,
490 "the working directory has no origin remote",
491 "pass --repo <owner/name> where a command needs the slug",
492 );
493 }
494 };
495 remote_host(&url).map_or_else(
499 || {
500 ProbeResult::failed(
501 id,
502 ProbeClass::Soft,
503 "the origin remote does not parse to a host",
504 "pass --repo <owner/name> where a command needs the slug",
505 )
506 },
507 |host| ProbeResult::ok(id, ProbeClass::Soft, format!("origin resolves to {host}")),
508 )
509}
510
511fn remote_host(url: &str) -> Option<String> {
513 if let Some(rest) = url.split_once("://").map(|(_, rest)| rest) {
514 let authority = rest.split('/').next()?;
515 let host = authority
516 .rsplit_once('@')
517 .map_or(authority, |(_, host)| host);
518 let host = host.split(':').next()?;
519 return (!host.is_empty()).then(|| host.to_owned());
520 }
521 let (authority, path) = url.split_once(':')?;
522 let host = authority
523 .rsplit_once('@')
524 .map_or(authority, |(_, host)| host);
525 (!host.is_empty() && !path.is_empty()).then(|| host.to_owned())
526}
527
528fn forge_cli(
534 id: &'static str,
535 env_override: &str,
536 default_bin: &str,
537 label: &str,
538 login: &str,
539 attempts: &[&[&str]],
540) -> ProbeResult {
541 let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
542 let mut spawned = false;
543 for args in attempts {
544 match Command::new(&bin).args(*args).output() {
545 Ok(out) if out.status.success() => {
546 return ProbeResult::ok(
547 id,
548 ProbeClass::Soft,
549 format!("{default_bin} is authenticated"),
550 );
551 }
552 Ok(_) => spawned = true,
553 Err(_) => {}
554 }
555 }
556 if spawned {
557 ProbeResult::failed(
558 id,
559 ProbeClass::Soft,
560 format!("{default_bin} is not authenticated"),
561 format!("run {login}"),
562 )
563 } else {
564 ProbeResult::failed(
565 id,
566 ProbeClass::Soft,
567 format!("{default_bin} is not on PATH"),
568 format!("install {label}"),
569 )
570 }
571}
572
573#[cfg(test)]
574mod tests {
575 use super::remote_host;
576
577 #[test]
578 fn a_remote_host_parses_from_both_url_forms() {
579 assert_eq!(
580 remote_host("https://github.com/owner/name.git").as_deref(),
581 Some("github.com")
582 );
583 assert_eq!(
584 remote_host("git@gitlab.com:group/sub/name.git").as_deref(),
585 Some("gitlab.com")
586 );
587 assert_eq!(
588 remote_host("ssh://git@github.com:22/owner/name.git").as_deref(),
589 Some("github.com")
590 );
591 assert_eq!(remote_host("not a url"), None);
592 }
593}