Skip to main content

roboticus_cli/cli/update/
update_skills.rs

1//! Skills update, multi-registry support, manifest parsing/diffing.
2
3use 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
26// ── Single-registry skills update ────────────────────────────
27
28pub(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 &current_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
231// ── Multi-registry support ───────────────────────────────────
232
233/// Compare two semver-style version strings.  Returns `true` when
234/// `local >= remote`, meaning an update is unnecessary.
235pub(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
267/// Apply skills updates from all configured registries.
268pub(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, &reg.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(&reg.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(&reg.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
514// ── CLI entry point ──────────────────────────────────────────
515
516pub 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, &registry_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, &registry_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}