1use std::collections::HashMap;
4use std::path::PathBuf;
5
6use super::{
7 DEFAULT_REGISTRY_URL, OverwriteChoice, SkillsRecord, UpdateState, bytes_sha256, colors,
8 confirm_action, confirm_overwrite, fetch_file, fetch_manifest, file_sha256, icons,
9 is_safe_skill_path, now_iso, print_diff, registry_base_url, resolve_registry_url,
10};
11use crate::cli::{CRT_DRAW_MS, heading, theme};
12
13pub(super) fn skills_local_dir(config_path: &str) -> PathBuf {
14 if let Ok(content) = std::fs::read_to_string(config_path)
15 && let Ok(config) = content.parse::<toml::Value>()
16 && let Some(path) = config
17 .get("skills")
18 .and_then(|s| s.get("skills_dir"))
19 .and_then(|v| v.as_str())
20 {
21 return PathBuf::from(path);
22 }
23 super::roboticus_home().join("skills")
24}
25
26pub(super) async fn apply_skills_update(
29 yes: bool,
30 registry_url: &str,
31 config_path: &str,
32) -> Result<bool, Box<dyn std::error::Error>> {
33 let (DIM, BOLD, _, GREEN, YELLOW, _, _, RESET, MONO) = colors();
34 let (OK, _, WARN, DETAIL, _) = icons();
35 let client = super::http_client()?;
36
37 println!("\n {BOLD}Skills{RESET}\n");
38
39 let manifest = match fetch_manifest(&client, registry_url).await {
40 Ok(m) => m,
41 Err(e) => {
42 println!(" {WARN} Could not fetch registry manifest: {e}");
43 return Ok(false);
44 }
45 };
46
47 let base_url = registry_base_url(registry_url);
48 let state = UpdateState::load();
49 let skills_dir = skills_local_dir(config_path);
50
51 if !skills_dir.exists() {
52 std::fs::create_dir_all(&skills_dir)?;
53 }
54
55 let mut new_files = Vec::new();
56 let mut updated_unmodified = Vec::new();
57 let mut updated_modified = Vec::new();
58 let mut up_to_date = Vec::new();
59
60 for (filename, remote_hash) in &manifest.packs.skills.files {
61 if !is_safe_skill_path(&skills_dir, filename) {
62 tracing::warn!(filename, "skipping manifest entry with suspicious path");
63 continue;
64 }
65
66 let local_file = skills_dir.join(filename);
67 let installed_hash = state
68 .installed_content
69 .skills
70 .as_ref()
71 .and_then(|s| s.files.get(filename))
72 .cloned();
73
74 if !local_file.exists() {
75 new_files.push(filename.clone());
76 continue;
77 }
78
79 let current_hash = file_sha256(&local_file).unwrap_or_default();
80 if ¤t_hash == remote_hash {
81 up_to_date.push(filename.clone());
82 continue;
83 }
84
85 let user_modified = match &installed_hash {
86 Some(ih) => current_hash != *ih,
87 None => true,
88 };
89
90 if user_modified {
91 updated_modified.push(filename.clone());
92 } else {
93 updated_unmodified.push(filename.clone());
94 }
95 }
96
97 if new_files.is_empty() && updated_unmodified.is_empty() && updated_modified.is_empty() {
98 println!(
99 " {OK} All skills are up to date ({} files)",
100 up_to_date.len()
101 );
102 return Ok(false);
103 }
104
105 let total_changes = new_files.len() + updated_unmodified.len() + updated_modified.len();
106 println!(
107 " {total_changes} change(s): {} new, {} updated, {} with local modifications",
108 new_files.len(),
109 updated_unmodified.len(),
110 updated_modified.len()
111 );
112 println!();
113
114 for f in &new_files {
115 println!(" {GREEN}+ {f}{RESET} (new)");
116 }
117 for f in &updated_unmodified {
118 println!(" {DIM} {f}{RESET} (unmodified -- will auto-update)");
119 }
120 for f in &updated_modified {
121 println!(" {YELLOW} {f}{RESET} (YOU MODIFIED THIS FILE)");
122 }
123
124 println!();
125 if !yes && !confirm_action("Apply skill updates?", true) {
126 println!(" Skipped.");
127 return Ok(false);
128 }
129
130 let mut applied = 0u32;
131 let mut file_hashes: HashMap<String, String> = state
132 .installed_content
133 .skills
134 .as_ref()
135 .map(|s| s.files.clone())
136 .unwrap_or_default();
137
138 for filename in new_files.iter().chain(updated_unmodified.iter()) {
139 let remote_content = fetch_file(
140 &client,
141 &base_url,
142 &format!("{}{}", manifest.packs.skills.path, filename),
143 )
144 .await?;
145 let download_hash = bytes_sha256(remote_content.as_bytes());
146 if let Some(expected) = manifest.packs.skills.files.get(filename)
147 && download_hash != *expected
148 {
149 tracing::warn!(
150 filename,
151 expected,
152 actual = %download_hash,
153 "skill download hash mismatch — skipping"
154 );
155 continue;
156 }
157 std::fs::write(skills_dir.join(filename), &remote_content)?;
158 file_hashes.insert(filename.clone(), download_hash);
159 applied += 1;
160 }
161
162 for filename in &updated_modified {
163 let local_file = skills_dir.join(filename);
164 let local_content = std::fs::read_to_string(&local_file).unwrap_or_default();
165 let remote_content = fetch_file(
166 &client,
167 &base_url,
168 &format!("{}{}", manifest.packs.skills.path, filename),
169 )
170 .await?;
171
172 let download_hash = bytes_sha256(remote_content.as_bytes());
173 if let Some(expected) = manifest.packs.skills.files.get(filename.as_str())
174 && download_hash != *expected
175 {
176 tracing::warn!(
177 filename,
178 expected,
179 actual = %download_hash,
180 "skill download hash mismatch — skipping"
181 );
182 continue;
183 }
184
185 println!();
186 println!(" {YELLOW}{filename}{RESET} -- local modifications detected:");
187 print_diff(&local_content, &remote_content);
188
189 match confirm_overwrite(filename) {
190 OverwriteChoice::Overwrite => {
191 std::fs::write(&local_file, &remote_content)?;
192 file_hashes.insert(filename.clone(), download_hash.clone());
193 applied += 1;
194 }
195 OverwriteChoice::Backup => {
196 let backup = local_file.with_extension("md.bak");
197 std::fs::copy(&local_file, &backup)?;
198 println!(" {DETAIL} Backed up to {}", backup.display());
199 std::fs::write(&local_file, &remote_content)?;
200 file_hashes.insert(filename.clone(), download_hash.clone());
201 applied += 1;
202 }
203 OverwriteChoice::Skip => {
204 println!(" Skipped {filename}.");
205 }
206 }
207 }
208
209 let mut state = UpdateState::load();
210 state.installed_content.skills = Some(SkillsRecord {
211 version: manifest.version.clone(),
212 files: file_hashes,
213 installed_at: now_iso(),
214 });
215 state.last_check = now_iso();
216 state
217 .save()
218 .inspect_err(
219 |e| tracing::warn!(error = %e, "failed to save update state after skills install"),
220 )
221 .ok();
222
223 println!();
224 println!(
225 " {OK} Applied {applied} skill update(s) (v{})",
226 manifest.version
227 );
228 Ok(true)
229}
230
231pub(super) fn semver_gte(local: &str, remote: &str) -> bool {
236 fn parse(v: &str) -> (Vec<u64>, bool) {
237 let v = v.trim_start_matches('v');
238 let v = v.split_once('+').map(|(core, _)| core).unwrap_or(v);
239 let (core, has_pre) = match v.split_once('-') {
240 Some((c, _)) => (c, true),
241 None => (v, false),
242 };
243 let parts = core
244 .split('.')
245 .map(|s| s.parse::<u64>().unwrap_or(0))
246 .collect();
247 (parts, has_pre)
248 }
249 let (l, l_pre) = parse(local);
250 let (r, r_pre) = parse(remote);
251 let len = l.len().max(r.len());
252 for i in 0..len {
253 let lv = l.get(i).copied().unwrap_or(0);
254 let rv = r.get(i).copied().unwrap_or(0);
255 match lv.cmp(&rv) {
256 std::cmp::Ordering::Greater => return true,
257 std::cmp::Ordering::Less => return false,
258 std::cmp::Ordering::Equal => {}
259 }
260 }
261 if l_pre && !r_pre {
262 return false;
263 }
264 true
265}
266
267pub(crate) async fn apply_multi_registry_skills_update(
269 yes: bool,
270 cli_registry_override: Option<&str>,
271 config_path: &str,
272) -> Result<bool, Box<dyn std::error::Error>> {
273 let (_, BOLD, _, _, _, _, _, RESET, _) = colors();
274 let (OK, _, WARN, _, _) = icons();
275
276 if let Some(url) = cli_registry_override {
277 return apply_skills_update(yes, url, config_path).await;
278 }
279
280 let registries = match std::fs::read_to_string(config_path).ok().and_then(|raw| {
281 let table: toml::Value = toml::from_str(&raw).ok()?;
282 let update_val = table.get("update")?.clone();
283 let update_cfg: roboticus_core::config::UpdateConfig = update_val.try_into().ok()?;
284 Some(update_cfg.resolve_registries())
285 }) {
286 Some(regs) => regs,
287 None => {
288 let url = resolve_registry_url(None, config_path);
289 return apply_skills_update(yes, &url, config_path).await;
290 }
291 };
292
293 if registries.len() <= 1
294 && registries
295 .first()
296 .map(|r| r.name == "default")
297 .unwrap_or(true)
298 {
299 let url = registries
300 .first()
301 .map(|r| r.url.as_str())
302 .unwrap_or(DEFAULT_REGISTRY_URL);
303 return apply_skills_update(yes, url, config_path).await;
304 }
305
306 let mut sorted = registries.clone();
307 sorted.sort_by(|a, b| b.priority.cmp(&a.priority));
308
309 println!("\n {BOLD}Skills (multi-registry){RESET}\n");
310
311 let non_default: Vec<_> = sorted
312 .iter()
313 .filter(|r| r.enabled && r.name != "default")
314 .collect();
315 if !non_default.is_empty() {
316 for r in &non_default {
317 println!(
318 " {WARN} Non-default registry: {BOLD}{}{RESET} ({})",
319 r.name, r.url
320 );
321 }
322 if !yes && !confirm_action("Install skills from non-default registries?", false) {
323 println!(" Skipped non-default registries.");
324 let url = sorted
325 .iter()
326 .find(|r| r.name == "default")
327 .map(|r| r.url.as_str())
328 .unwrap_or(DEFAULT_REGISTRY_URL);
329 return apply_skills_update(yes, url, config_path).await;
330 }
331 }
332
333 let client = super::http_client()?;
334 let skills_dir = skills_local_dir(config_path);
335 if !skills_dir.exists() {
336 std::fs::create_dir_all(&skills_dir)?;
337 }
338
339 let state = UpdateState::load();
340 let mut any_changed = false;
341 let mut claimed_files: HashMap<String, String> = HashMap::new();
342
343 for reg in &sorted {
344 if !reg.enabled {
345 continue;
346 }
347
348 let manifest = match fetch_manifest(&client, ®.url).await {
349 Ok(m) => m,
350 Err(e) => {
351 println!(
352 " {WARN} [{name}] Could not fetch manifest: {e}",
353 name = reg.name
354 );
355 continue;
356 }
357 };
358
359 let installed_version = state
360 .installed_content
361 .skills
362 .as_ref()
363 .map(|s| s.version.as_str())
364 .unwrap_or("0.0.0");
365 if semver_gte(installed_version, &manifest.version) {
366 let all_match = manifest.packs.skills.files.iter().all(|(fname, hash)| {
367 let local = skills_dir.join(fname);
368 local.exists() && file_sha256(&local).unwrap_or_default() == *hash
369 });
370 if all_match {
371 println!(
372 " {OK} [{name}] All skills are up to date (v{ver})",
373 name = reg.name,
374 ver = manifest.version
375 );
376 continue;
377 }
378 }
379
380 if reg.name.contains("..") || reg.name.contains('/') || reg.name.contains('\\') {
381 tracing::warn!(registry = %reg.name, "skipping registry with suspicious name");
382 continue;
383 }
384 let target_dir = if reg.name == "default" {
385 skills_dir.clone()
386 } else {
387 let ns_dir = skills_dir.join(®.name);
388 if !ns_dir.exists() {
389 std::fs::create_dir_all(&ns_dir)?;
390 }
391 ns_dir
392 };
393
394 let base_url = registry_base_url(®.url);
395 let mut applied = 0u32;
396
397 for (filename, remote_hash) in &manifest.packs.skills.files {
398 if !is_safe_skill_path(&target_dir, filename) {
399 tracing::warn!(
400 registry = %reg.name,
401 filename,
402 "skipping manifest entry with suspicious path"
403 );
404 continue;
405 }
406
407 let resolved_key = target_dir.join(filename).to_string_lossy().to_string();
408 if let Some(owner) = claimed_files.get(&resolved_key)
409 && *owner != reg.name
410 {
411 continue;
412 }
413 claimed_files.insert(resolved_key, reg.name.clone());
414
415 let local_file = target_dir.join(filename);
416 if local_file.exists() {
417 let current_hash = file_sha256(&local_file).unwrap_or_default();
418 if current_hash == *remote_hash {
419 continue;
420 }
421 }
422
423 match fetch_file(
424 &client,
425 &base_url,
426 &format!("{}{}", manifest.packs.skills.path, filename),
427 )
428 .await
429 {
430 Ok(content) => {
431 let download_hash = bytes_sha256(content.as_bytes());
432 if download_hash != *remote_hash {
433 tracing::warn!(
434 registry = %reg.name,
435 filename,
436 expected = %remote_hash,
437 actual = %download_hash,
438 "skill download hash mismatch — skipping"
439 );
440 continue;
441 }
442 std::fs::write(&local_file, &content)?;
443 applied += 1;
444 }
445 Err(e) => {
446 println!(
447 " {WARN} [{name}] Failed to fetch {filename}: {e}",
448 name = reg.name
449 );
450 }
451 }
452 }
453
454 if applied > 0 {
455 any_changed = true;
456 println!(
457 " {OK} [{name}] Applied {applied} skill update(s) (v{ver})",
458 name = reg.name,
459 ver = manifest.version
460 );
461 } else {
462 println!(
463 " {OK} [{name}] All skills are up to date",
464 name = reg.name
465 );
466 }
467 }
468
469 {
470 let mut state = UpdateState::load();
471 state.last_check = now_iso();
472 if any_changed {
473 let mut file_hashes: HashMap<String, String> = state
474 .installed_content
475 .skills
476 .as_ref()
477 .map(|s| s.files.clone())
478 .unwrap_or_default();
479 if let Ok(entries) = std::fs::read_dir(&skills_dir) {
480 for entry in entries.flatten() {
481 let path = entry.path();
482 if path.is_file()
483 && let Some(name) = path.file_name().and_then(|n| n.to_str())
484 && let Ok(hash) = file_sha256(&path)
485 {
486 file_hashes.insert(name.to_string(), hash);
487 }
488 }
489 }
490 let max_version = sorted
491 .iter()
492 .filter(|r| r.enabled)
493 .map(|r| r.name.as_str())
494 .next()
495 .unwrap_or("0.0.0");
496 let _ = max_version;
497 state.installed_content.skills = Some(SkillsRecord {
498 version: "multi".into(),
499 files: file_hashes,
500 installed_at: now_iso(),
501 });
502 }
503 state
504 .save()
505 .inspect_err(
506 |e| tracing::warn!(error = %e, "failed to save update state after multi-registry sync"),
507 )
508 .ok();
509 }
510
511 Ok(any_changed)
512}
513
514pub async fn cmd_update_skills(
517 yes: bool,
518 registry_url_override: Option<&str>,
519 config_path: &str,
520 hygiene_fn: Option<&super::HygieneFn>,
521) -> Result<(), Box<dyn std::error::Error>> {
522 heading("Skills Update");
523 apply_multi_registry_skills_update(yes, registry_url_override, config_path).await?;
524 super::run_oauth_storage_maintenance();
525 super::run_mechanic_checks_maintenance(config_path, hygiene_fn);
526 println!();
527 Ok(())
528}
529
530#[cfg(test)]
531mod tests {
532 use super::*;
533 use crate::test_support::EnvGuard;
534
535 #[test]
536 fn skills_local_dir_fallback_when_config_missing() {
537 let s = skills_local_dir("/no/such/file.toml");
538 assert!(s.ends_with("skills"));
539 }
540
541 #[test]
542 fn semver_gte_equal_versions() {
543 assert!(semver_gte("1.0.0", "1.0.0"));
544 }
545
546 #[test]
547 fn semver_gte_local_newer() {
548 assert!(semver_gte("1.1.0", "1.0.0"));
549 assert!(semver_gte("2.0.0", "1.9.9"));
550 assert!(semver_gte("0.9.6", "0.9.5"));
551 }
552
553 #[test]
554 fn semver_gte_local_older() {
555 assert!(!semver_gte("1.0.0", "1.0.1"));
556 assert!(!semver_gte("0.9.5", "0.9.6"));
557 assert!(!semver_gte("0.8.9", "0.9.0"));
558 }
559
560 #[test]
561 fn semver_gte_different_segment_counts() {
562 assert!(semver_gte("1.0.0", "1.0"));
563 assert!(semver_gte("1.0", "1.0.0"));
564 assert!(!semver_gte("1.0", "1.0.1"));
565 }
566
567 #[test]
568 fn semver_gte_strips_prerelease_and_build_metadata() {
569 assert!(!semver_gte("1.0.0-rc.1", "1.0.0"));
570 assert!(semver_gte("1.0.0", "1.0.0-rc.1"));
571 assert!(semver_gte("1.0.0+build.42", "1.0.0"));
572 assert!(semver_gte("1.0.0", "1.0.0+build.42"));
573 assert!(!semver_gte("1.0.0-rc.1+build.42", "1.0.0"));
574 assert!(!semver_gte("v1.0.0-rc.1", "1.0.0"));
575 assert!(!semver_gte("v0.9.5-beta.1", "0.9.6"));
576 assert!(semver_gte("1.0.0-rc.2", "1.0.0-rc.1"));
577 }
578
579 #[serial_test::serial]
580 #[tokio::test]
581 async fn apply_skills_update_installs_and_then_reports_up_to_date() {
582 let temp = tempfile::tempdir().unwrap();
583 let _home_guard = EnvGuard::set("HOME", temp.path().to_str().unwrap());
584 let skills_dir = temp.path().join("skills");
585 let config_path = temp.path().join("roboticus.toml");
586 std::fs::write(
587 &config_path,
588 format!(
589 "[skills]\nskills_dir = \"{}\"\n",
590 skills_dir.display().to_string().replace('\\', "/")
591 ),
592 )
593 .unwrap();
594
595 let draft = "# draft\nfrom registry\n".to_string();
596 let (registry_url, handle) = crate::cli::update::tests_support::start_mock_registry(
597 "[providers.openai]\nurl=\"https://api.openai.com\"\n".to_string(),
598 draft.clone(),
599 )
600 .await;
601
602 let changed = apply_skills_update(true, ®istry_url, config_path.to_str().unwrap())
603 .await
604 .unwrap();
605 assert!(changed);
606 assert_eq!(
607 std::fs::read_to_string(skills_dir.join("draft.md")).unwrap(),
608 draft
609 );
610
611 let changed_second =
612 apply_skills_update(true, ®istry_url, config_path.to_str().unwrap())
613 .await
614 .unwrap();
615 assert!(!changed_second);
616 handle.abort();
617 }
618
619 #[serial_test::serial]
620 #[tokio::test]
621 async fn multi_registry_namespaces_non_default_skills() {
622 let temp = tempfile::tempdir().unwrap();
623 let _home_guard = EnvGuard::set("HOME", temp.path().to_str().unwrap());
624 let skills_dir = temp.path().join("skills");
625 let config_path = temp.path().join("roboticus.toml");
626
627 let skill_content = "# community skill\nbody\n".to_string();
628 let (registry_url, handle) =
629 crate::cli::update::tests_support::start_namespaced_mock_registry(
630 "community",
631 "helper.md",
632 skill_content.clone(),
633 )
634 .await;
635
636 let config_toml = format!(
637 r#"[skills]
638skills_dir = "{}"
639
640[update]
641registry_url = "{}"
642
643[[update.registries]]
644name = "community"
645url = "{}"
646priority = 40
647enabled = true
648"#,
649 skills_dir.display().to_string().replace('\\', "/"),
650 registry_url,
651 registry_url,
652 );
653 std::fs::write(&config_path, &config_toml).unwrap();
654
655 let changed = apply_multi_registry_skills_update(true, None, config_path.to_str().unwrap())
656 .await
657 .unwrap();
658
659 assert!(changed);
660 let namespaced_path = skills_dir.join("community").join("helper.md");
661 assert!(
662 namespaced_path.exists(),
663 "expected skill at {}, files in skills_dir: {:?}",
664 namespaced_path.display(),
665 std::fs::read_dir(&skills_dir)
666 .map(|rd| rd.flatten().map(|e| e.path()).collect::<Vec<_>>())
667 .unwrap_or_default()
668 );
669 assert_eq!(
670 std::fs::read_to_string(&namespaced_path).unwrap(),
671 skill_content
672 );
673
674 handle.abort();
675 }
676}