use std::collections::HashMap;
use std::path::PathBuf;
use super::{
DEFAULT_REGISTRY_URL, OverwriteChoice, SkillsRecord, UpdateState, bytes_sha256, colors,
confirm_action, confirm_overwrite, fetch_file, fetch_manifest, file_sha256, icons,
is_safe_skill_path, now_iso, print_diff, registry_base_url, resolve_registry_url,
};
use crate::cli::{CRT_DRAW_MS, heading, theme};
pub(super) fn skills_local_dir(config_path: &str) -> PathBuf {
if let Ok(content) = std::fs::read_to_string(config_path)
&& let Ok(config) = content.parse::<toml::Value>()
&& let Some(path) = config
.get("skills")
.and_then(|s| s.get("skills_dir"))
.and_then(|v| v.as_str())
{
return PathBuf::from(path);
}
super::roboticus_home().join("skills")
}
pub(super) async fn apply_skills_update(
yes: bool,
registry_url: &str,
config_path: &str,
) -> Result<bool, Box<dyn std::error::Error>> {
let (DIM, BOLD, _, GREEN, YELLOW, _, _, RESET, MONO) = colors();
let (OK, _, WARN, DETAIL, _) = icons();
let client = super::http_client()?;
println!("\n {BOLD}Skills{RESET}\n");
let manifest = match fetch_manifest(&client, registry_url).await {
Ok(m) => m,
Err(e) => {
println!(" {WARN} Could not fetch registry manifest: {e}");
return Ok(false);
}
};
let base_url = registry_base_url(registry_url);
let state = UpdateState::load();
let skills_dir = skills_local_dir(config_path);
if !skills_dir.exists() {
std::fs::create_dir_all(&skills_dir)?;
}
let mut new_files = Vec::new();
let mut updated_unmodified = Vec::new();
let mut updated_modified = Vec::new();
let mut up_to_date = Vec::new();
for (filename, remote_hash) in &manifest.packs.skills.files {
if !is_safe_skill_path(&skills_dir, filename) {
tracing::warn!(filename, "skipping manifest entry with suspicious path");
continue;
}
let local_file = skills_dir.join(filename);
let installed_hash = state
.installed_content
.skills
.as_ref()
.and_then(|s| s.files.get(filename))
.cloned();
if !local_file.exists() {
new_files.push(filename.clone());
continue;
}
let current_hash = file_sha256(&local_file).unwrap_or_default();
if ¤t_hash == remote_hash {
up_to_date.push(filename.clone());
continue;
}
let user_modified = match &installed_hash {
Some(ih) => current_hash != *ih,
None => true,
};
if user_modified {
updated_modified.push(filename.clone());
} else {
updated_unmodified.push(filename.clone());
}
}
if new_files.is_empty() && updated_unmodified.is_empty() && updated_modified.is_empty() {
println!(
" {OK} All skills are up to date ({} files)",
up_to_date.len()
);
return Ok(false);
}
let total_changes = new_files.len() + updated_unmodified.len() + updated_modified.len();
println!(
" {total_changes} change(s): {} new, {} updated, {} with local modifications",
new_files.len(),
updated_unmodified.len(),
updated_modified.len()
);
println!();
for f in &new_files {
println!(" {GREEN}+ {f}{RESET} (new)");
}
for f in &updated_unmodified {
println!(" {DIM} {f}{RESET} (unmodified -- will auto-update)");
}
for f in &updated_modified {
println!(" {YELLOW} {f}{RESET} (YOU MODIFIED THIS FILE)");
}
println!();
if !yes && !confirm_action("Apply skill updates?", true) {
println!(" Skipped.");
return Ok(false);
}
let mut applied = 0u32;
let mut file_hashes: HashMap<String, String> = state
.installed_content
.skills
.as_ref()
.map(|s| s.files.clone())
.unwrap_or_default();
for filename in new_files.iter().chain(updated_unmodified.iter()) {
let remote_content = fetch_file(
&client,
&base_url,
&format!("{}{}", manifest.packs.skills.path, filename),
)
.await?;
let download_hash = bytes_sha256(remote_content.as_bytes());
if let Some(expected) = manifest.packs.skills.files.get(filename)
&& download_hash != *expected
{
tracing::warn!(
filename,
expected,
actual = %download_hash,
"skill download hash mismatch — skipping"
);
continue;
}
std::fs::write(skills_dir.join(filename), &remote_content)?;
file_hashes.insert(filename.clone(), download_hash);
applied += 1;
}
for filename in &updated_modified {
let local_file = skills_dir.join(filename);
let local_content = std::fs::read_to_string(&local_file).unwrap_or_default();
let remote_content = fetch_file(
&client,
&base_url,
&format!("{}{}", manifest.packs.skills.path, filename),
)
.await?;
let download_hash = bytes_sha256(remote_content.as_bytes());
if let Some(expected) = manifest.packs.skills.files.get(filename.as_str())
&& download_hash != *expected
{
tracing::warn!(
filename,
expected,
actual = %download_hash,
"skill download hash mismatch — skipping"
);
continue;
}
println!();
println!(" {YELLOW}{filename}{RESET} -- local modifications detected:");
print_diff(&local_content, &remote_content);
match confirm_overwrite(filename) {
OverwriteChoice::Overwrite => {
std::fs::write(&local_file, &remote_content)?;
file_hashes.insert(filename.clone(), download_hash.clone());
applied += 1;
}
OverwriteChoice::Backup => {
let backup = local_file.with_extension("md.bak");
std::fs::copy(&local_file, &backup)?;
println!(" {DETAIL} Backed up to {}", backup.display());
std::fs::write(&local_file, &remote_content)?;
file_hashes.insert(filename.clone(), download_hash.clone());
applied += 1;
}
OverwriteChoice::Skip => {
println!(" Skipped {filename}.");
}
}
}
let mut state = UpdateState::load();
state.installed_content.skills = Some(SkillsRecord {
version: manifest.version.clone(),
files: file_hashes,
installed_at: now_iso(),
});
state.last_check = now_iso();
state
.save()
.inspect_err(
|e| tracing::warn!(error = %e, "failed to save update state after skills install"),
)
.ok();
println!();
println!(
" {OK} Applied {applied} skill update(s) (v{})",
manifest.version
);
Ok(true)
}
pub(super) fn semver_gte(local: &str, remote: &str) -> bool {
fn parse(v: &str) -> (Vec<u64>, bool) {
let v = v.trim_start_matches('v');
let v = v.split_once('+').map(|(core, _)| core).unwrap_or(v);
let (core, has_pre) = match v.split_once('-') {
Some((c, _)) => (c, true),
None => (v, false),
};
let parts = core
.split('.')
.map(|s| s.parse::<u64>().unwrap_or(0))
.collect();
(parts, has_pre)
}
let (l, l_pre) = parse(local);
let (r, r_pre) = parse(remote);
let len = l.len().max(r.len());
for i in 0..len {
let lv = l.get(i).copied().unwrap_or(0);
let rv = r.get(i).copied().unwrap_or(0);
match lv.cmp(&rv) {
std::cmp::Ordering::Greater => return true,
std::cmp::Ordering::Less => return false,
std::cmp::Ordering::Equal => {}
}
}
if l_pre && !r_pre {
return false;
}
true
}
pub(crate) async fn apply_multi_registry_skills_update(
yes: bool,
cli_registry_override: Option<&str>,
config_path: &str,
) -> Result<bool, Box<dyn std::error::Error>> {
let (_, BOLD, _, _, _, _, _, RESET, _) = colors();
let (OK, _, WARN, _, _) = icons();
if let Some(url) = cli_registry_override {
return apply_skills_update(yes, url, config_path).await;
}
let registries = match std::fs::read_to_string(config_path).ok().and_then(|raw| {
let table: toml::Value = toml::from_str(&raw).ok()?;
let update_val = table.get("update")?.clone();
let update_cfg: roboticus_core::config::UpdateConfig = update_val.try_into().ok()?;
Some(update_cfg.resolve_registries())
}) {
Some(regs) => regs,
None => {
let url = resolve_registry_url(None, config_path);
return apply_skills_update(yes, &url, config_path).await;
}
};
if registries.len() <= 1
&& registries
.first()
.map(|r| r.name == "default")
.unwrap_or(true)
{
let url = registries
.first()
.map(|r| r.url.as_str())
.unwrap_or(DEFAULT_REGISTRY_URL);
return apply_skills_update(yes, url, config_path).await;
}
let mut sorted = registries.clone();
sorted.sort_by(|a, b| b.priority.cmp(&a.priority));
println!("\n {BOLD}Skills (multi-registry){RESET}\n");
let non_default: Vec<_> = sorted
.iter()
.filter(|r| r.enabled && r.name != "default")
.collect();
if !non_default.is_empty() {
for r in &non_default {
println!(
" {WARN} Non-default registry: {BOLD}{}{RESET} ({})",
r.name, r.url
);
}
if !yes && !confirm_action("Install skills from non-default registries?", false) {
println!(" Skipped non-default registries.");
let url = sorted
.iter()
.find(|r| r.name == "default")
.map(|r| r.url.as_str())
.unwrap_or(DEFAULT_REGISTRY_URL);
return apply_skills_update(yes, url, config_path).await;
}
}
let client = super::http_client()?;
let skills_dir = skills_local_dir(config_path);
if !skills_dir.exists() {
std::fs::create_dir_all(&skills_dir)?;
}
let state = UpdateState::load();
let mut any_changed = false;
let mut claimed_files: HashMap<String, String> = HashMap::new();
for reg in &sorted {
if !reg.enabled {
continue;
}
let manifest = match fetch_manifest(&client, ®.url).await {
Ok(m) => m,
Err(e) => {
println!(
" {WARN} [{name}] Could not fetch manifest: {e}",
name = reg.name
);
continue;
}
};
let installed_version = state
.installed_content
.skills
.as_ref()
.map(|s| s.version.as_str())
.unwrap_or("0.0.0");
if semver_gte(installed_version, &manifest.version) {
let all_match = manifest.packs.skills.files.iter().all(|(fname, hash)| {
let local = skills_dir.join(fname);
local.exists() && file_sha256(&local).unwrap_or_default() == *hash
});
if all_match {
println!(
" {OK} [{name}] All skills are up to date (v{ver})",
name = reg.name,
ver = manifest.version
);
continue;
}
}
if reg.name.contains("..") || reg.name.contains('/') || reg.name.contains('\\') {
tracing::warn!(registry = %reg.name, "skipping registry with suspicious name");
continue;
}
let target_dir = if reg.name == "default" {
skills_dir.clone()
} else {
let ns_dir = skills_dir.join(®.name);
if !ns_dir.exists() {
std::fs::create_dir_all(&ns_dir)?;
}
ns_dir
};
let base_url = registry_base_url(®.url);
let mut applied = 0u32;
for (filename, remote_hash) in &manifest.packs.skills.files {
if !is_safe_skill_path(&target_dir, filename) {
tracing::warn!(
registry = %reg.name,
filename,
"skipping manifest entry with suspicious path"
);
continue;
}
let resolved_key = target_dir.join(filename).to_string_lossy().to_string();
if let Some(owner) = claimed_files.get(&resolved_key)
&& *owner != reg.name
{
continue;
}
claimed_files.insert(resolved_key, reg.name.clone());
let local_file = target_dir.join(filename);
if local_file.exists() {
let current_hash = file_sha256(&local_file).unwrap_or_default();
if current_hash == *remote_hash {
continue;
}
}
match fetch_file(
&client,
&base_url,
&format!("{}{}", manifest.packs.skills.path, filename),
)
.await
{
Ok(content) => {
let download_hash = bytes_sha256(content.as_bytes());
if download_hash != *remote_hash {
tracing::warn!(
registry = %reg.name,
filename,
expected = %remote_hash,
actual = %download_hash,
"skill download hash mismatch — skipping"
);
continue;
}
std::fs::write(&local_file, &content)?;
applied += 1;
}
Err(e) => {
println!(
" {WARN} [{name}] Failed to fetch {filename}: {e}",
name = reg.name
);
}
}
}
if applied > 0 {
any_changed = true;
println!(
" {OK} [{name}] Applied {applied} skill update(s) (v{ver})",
name = reg.name,
ver = manifest.version
);
} else {
println!(
" {OK} [{name}] All skills are up to date",
name = reg.name
);
}
}
{
let mut state = UpdateState::load();
state.last_check = now_iso();
if any_changed {
let mut file_hashes: HashMap<String, String> = state
.installed_content
.skills
.as_ref()
.map(|s| s.files.clone())
.unwrap_or_default();
if let Ok(entries) = std::fs::read_dir(&skills_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file()
&& let Some(name) = path.file_name().and_then(|n| n.to_str())
&& let Ok(hash) = file_sha256(&path)
{
file_hashes.insert(name.to_string(), hash);
}
}
}
let max_version = sorted
.iter()
.filter(|r| r.enabled)
.map(|r| r.name.as_str())
.next()
.unwrap_or("0.0.0");
let _ = max_version;
state.installed_content.skills = Some(SkillsRecord {
version: "multi".into(),
files: file_hashes,
installed_at: now_iso(),
});
}
state
.save()
.inspect_err(
|e| tracing::warn!(error = %e, "failed to save update state after multi-registry sync"),
)
.ok();
}
Ok(any_changed)
}
pub async fn cmd_update_skills(
yes: bool,
registry_url_override: Option<&str>,
config_path: &str,
hygiene_fn: Option<&super::HygieneFn>,
) -> Result<(), Box<dyn std::error::Error>> {
heading("Skills Update");
apply_multi_registry_skills_update(yes, registry_url_override, config_path).await?;
super::run_oauth_storage_maintenance();
super::run_mechanic_checks_maintenance(config_path, hygiene_fn);
println!();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::EnvGuard;
#[test]
fn skills_local_dir_fallback_when_config_missing() {
let s = skills_local_dir("/no/such/file.toml");
assert!(s.ends_with("skills"));
}
#[test]
fn semver_gte_equal_versions() {
assert!(semver_gte("1.0.0", "1.0.0"));
}
#[test]
fn semver_gte_local_newer() {
assert!(semver_gte("1.1.0", "1.0.0"));
assert!(semver_gte("2.0.0", "1.9.9"));
assert!(semver_gte("0.9.6", "0.9.5"));
}
#[test]
fn semver_gte_local_older() {
assert!(!semver_gte("1.0.0", "1.0.1"));
assert!(!semver_gte("0.9.5", "0.9.6"));
assert!(!semver_gte("0.8.9", "0.9.0"));
}
#[test]
fn semver_gte_different_segment_counts() {
assert!(semver_gte("1.0.0", "1.0"));
assert!(semver_gte("1.0", "1.0.0"));
assert!(!semver_gte("1.0", "1.0.1"));
}
#[test]
fn semver_gte_strips_prerelease_and_build_metadata() {
assert!(!semver_gte("1.0.0-rc.1", "1.0.0"));
assert!(semver_gte("1.0.0", "1.0.0-rc.1"));
assert!(semver_gte("1.0.0+build.42", "1.0.0"));
assert!(semver_gte("1.0.0", "1.0.0+build.42"));
assert!(!semver_gte("1.0.0-rc.1+build.42", "1.0.0"));
assert!(!semver_gte("v1.0.0-rc.1", "1.0.0"));
assert!(!semver_gte("v0.9.5-beta.1", "0.9.6"));
assert!(semver_gte("1.0.0-rc.2", "1.0.0-rc.1"));
}
#[serial_test::serial]
#[tokio::test]
async fn apply_skills_update_installs_and_then_reports_up_to_date() {
let temp = tempfile::tempdir().unwrap();
let _home_guard = EnvGuard::set("HOME", temp.path().to_str().unwrap());
let skills_dir = temp.path().join("skills");
let config_path = temp.path().join("roboticus.toml");
std::fs::write(
&config_path,
format!(
"[skills]\nskills_dir = \"{}\"\n",
skills_dir.display().to_string().replace('\\', "/")
),
)
.unwrap();
let draft = "# draft\nfrom registry\n".to_string();
let (registry_url, handle) = crate::cli::update::tests_support::start_mock_registry(
"[providers.openai]\nurl=\"https://api.openai.com\"\n".to_string(),
draft.clone(),
)
.await;
let changed = apply_skills_update(true, ®istry_url, config_path.to_str().unwrap())
.await
.unwrap();
assert!(changed);
assert_eq!(
std::fs::read_to_string(skills_dir.join("draft.md")).unwrap(),
draft
);
let changed_second =
apply_skills_update(true, ®istry_url, config_path.to_str().unwrap())
.await
.unwrap();
assert!(!changed_second);
handle.abort();
}
#[serial_test::serial]
#[tokio::test]
async fn multi_registry_namespaces_non_default_skills() {
let temp = tempfile::tempdir().unwrap();
let _home_guard = EnvGuard::set("HOME", temp.path().to_str().unwrap());
let skills_dir = temp.path().join("skills");
let config_path = temp.path().join("roboticus.toml");
let skill_content = "# community skill\nbody\n".to_string();
let (registry_url, handle) =
crate::cli::update::tests_support::start_namespaced_mock_registry(
"community",
"helper.md",
skill_content.clone(),
)
.await;
let config_toml = format!(
r#"[skills]
skills_dir = "{}"
[update]
registry_url = "{}"
[[update.registries]]
name = "community"
url = "{}"
priority = 40
enabled = true
"#,
skills_dir.display().to_string().replace('\\', "/"),
registry_url,
registry_url,
);
std::fs::write(&config_path, &config_toml).unwrap();
let changed = apply_multi_registry_skills_update(true, None, config_path.to_str().unwrap())
.await
.unwrap();
assert!(changed);
let namespaced_path = skills_dir.join("community").join("helper.md");
assert!(
namespaced_path.exists(),
"expected skill at {}, files in skills_dir: {:?}",
namespaced_path.display(),
std::fs::read_dir(&skills_dir)
.map(|rd| rd.flatten().map(|e| e.path()).collect::<Vec<_>>())
.unwrap_or_default()
);
assert_eq!(
std::fs::read_to_string(&namespaced_path).unwrap(),
skill_content
);
handle.abort();
}
}