Skip to main content

roboticus_cli/cli/
update.rs

1use std::collections::HashMap;
2use std::io::{self, Write};
3#[cfg(unix)]
4use std::os::unix::fs::PermissionsExt;
5#[cfg(windows)]
6use std::os::windows::process::CommandExt;
7use std::path::{Path, PathBuf};
8
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11
12use roboticus_core::home_dir;
13use roboticus_llm::oauth::check_and_repair_oauth_storage;
14
15use super::{colors, heading, icons};
16use crate::cli::{CRT_DRAW_MS, theme};
17
18pub(crate) const DEFAULT_REGISTRY_URL: &str = "https://roboticus.ai/registry/manifest.json";
19const CRATES_IO_API: &str = "https://crates.io/api/v1/crates/roboticus";
20const CRATE_NAME: &str = "roboticus";
21const RELEASE_BASE_URL: &str = "https://github.com/robot-accomplice/roboticus/releases/download";
22const GITHUB_RELEASES_API: &str =
23    "https://api.github.com/repos/robot-accomplice/roboticus/releases?per_page=100";
24
25// ── Registry manifest (remote) ───────────────────────────────
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct RegistryManifest {
29    pub version: String,
30    pub packs: Packs,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct Packs {
35    pub providers: ProviderPack,
36    pub skills: SkillPack,
37    #[serde(default)]
38    pub plugins: Option<roboticus_plugin_sdk::catalog::PluginCatalog>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct ProviderPack {
43    pub sha256: String,
44    pub path: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct SkillPack {
49    pub sha256: Option<String>,
50    pub path: String,
51    pub files: HashMap<String, String>,
52}
53
54// ── Local update state ───────────────────────────────────────
55
56#[derive(Debug, Clone, Serialize, Deserialize, Default)]
57pub struct UpdateState {
58    pub binary_version: String,
59    pub last_check: String,
60    pub registry_url: String,
61    pub installed_content: InstalledContent,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, Default)]
65pub struct InstalledContent {
66    pub providers: Option<ContentRecord>,
67    pub skills: Option<SkillsRecord>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct ContentRecord {
72    pub version: String,
73    pub sha256: String,
74    pub installed_at: String,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct SkillsRecord {
79    pub version: String,
80    pub files: HashMap<String, String>,
81    pub installed_at: String,
82}
83
84impl UpdateState {
85    pub fn load() -> Self {
86        let path = state_path();
87        if path.exists() {
88            match std::fs::read_to_string(&path) {
89                Ok(content) => serde_json::from_str(&content)
90                    .inspect_err(|e| tracing::warn!(error = %e, "corrupted update state file, resetting to default"))
91                    .unwrap_or_default(),
92                Err(e) => {
93                    tracing::warn!(error = %e, "failed to read update state file, resetting to default");
94                    Self::default()
95                }
96            }
97        } else {
98            Self::default()
99        }
100    }
101
102    pub fn save(&self) -> io::Result<()> {
103        let path = state_path();
104        if let Some(parent) = path.parent() {
105            std::fs::create_dir_all(parent)?;
106        }
107        let json = serde_json::to_string_pretty(self).map_err(io::Error::other)?;
108        std::fs::write(&path, json)
109    }
110}
111
112fn state_path() -> PathBuf {
113    home_dir().join(".roboticus").join("update_state.json")
114}
115
116fn roboticus_home() -> PathBuf {
117    home_dir().join(".roboticus")
118}
119
120fn now_iso() -> String {
121    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
122}
123
124// ── Helpers ──────────────────────────────────────────────────
125
126/// Validate that `filename` joined to `base_dir` stays within the base directory.
127///
128/// Defence-in-depth beyond the string-level `..` / `is_absolute` check:
129/// normalize the path components and verify the resolved path still has
130/// `base_dir` as a prefix.  This catches multi-component escapes and
131/// symlinks that might slip past the string-level guard.
132fn is_safe_skill_path(base_dir: &Path, filename: &str) -> bool {
133    // First-pass string guard (kept for clarity)
134    if filename.contains("..") || Path::new(filename).is_absolute() {
135        return false;
136    }
137    // Resolve the effective base: canonicalize if possible (resolves symlinks
138    // such as macOS's /var → /private/var), otherwise normalize components.
139    // Using the same effective base for both joining and prefix-checking avoids
140    // false negatives when the original path and canonical path differ.
141    let effective_base = base_dir.canonicalize().unwrap_or_else(|_| {
142        base_dir.components().fold(PathBuf::new(), |mut acc, c| {
143            acc.push(c);
144            acc
145        })
146    });
147    // Join filename to the effective base, then normalize to resolve `.` and
148    // multi-component oddities without requiring the file to exist.
149    let joined = effective_base.join(filename);
150    let normalized: PathBuf = joined.components().fold(PathBuf::new(), |mut acc, c| {
151        match c {
152            std::path::Component::ParentDir => {
153                acc.pop();
154            }
155            other => acc.push(other),
156        }
157        acc
158    });
159    normalized.starts_with(&effective_base)
160}
161
162pub fn file_sha256(path: &Path) -> io::Result<String> {
163    let bytes = std::fs::read(path)?;
164    let hash = Sha256::digest(&bytes);
165    Ok(hex::encode(hash))
166}
167
168pub fn bytes_sha256(data: &[u8]) -> String {
169    let hash = Sha256::digest(data);
170    hex::encode(hash)
171}
172
173pub(crate) fn resolve_registry_url(cli_override: Option<&str>, config_path: &str) -> String {
174    if let Some(url) = cli_override {
175        return url.to_string();
176    }
177    if let Ok(val) = std::env::var("ROBOTICUS_REGISTRY_URL")
178        && !val.is_empty()
179    {
180        return val;
181    }
182    if let Ok(content) = std::fs::read_to_string(config_path)
183        && let Ok(config) = content.parse::<toml::Value>()
184        && let Some(url) = config
185            .get("update")
186            .and_then(|u| u.get("registry_url"))
187            .and_then(|v| v.as_str())
188        && !url.is_empty()
189    {
190        return url.to_string();
191    }
192    DEFAULT_REGISTRY_URL.to_string()
193}
194
195pub(crate) fn registry_base_url(manifest_url: &str) -> String {
196    if let Some(pos) = manifest_url.rfind('/') {
197        manifest_url[..pos].to_string()
198    } else {
199        manifest_url.to_string()
200    }
201}
202
203fn confirm_action(prompt: &str, default_yes: bool) -> bool {
204    let hint = if default_yes { "[Y/n]" } else { "[y/N]" };
205    print!("    {prompt} {hint} ");
206    io::stdout().flush().ok();
207    let mut input = String::new();
208    if io::stdin().read_line(&mut input).is_err() {
209        return default_yes;
210    }
211    let answer = input.trim().to_lowercase();
212    if answer.is_empty() {
213        return default_yes;
214    }
215    matches!(answer.as_str(), "y" | "yes")
216}
217
218fn confirm_overwrite(filename: &str) -> OverwriteChoice {
219    let (_, _, _, _, YELLOW, _, _, RESET, _) = colors();
220    print!("    Overwrite {YELLOW}{filename}{RESET}? [y/N/backup] ");
221    io::stdout().flush().ok();
222    let mut input = String::new();
223    if io::stdin().read_line(&mut input).is_err() {
224        return OverwriteChoice::Skip;
225    }
226    match input.trim().to_lowercase().as_str() {
227        "y" | "yes" => OverwriteChoice::Overwrite,
228        "b" | "backup" => OverwriteChoice::Backup,
229        _ => OverwriteChoice::Skip,
230    }
231}
232
233#[derive(Debug, PartialEq)]
234enum OverwriteChoice {
235    Overwrite,
236    Backup,
237    Skip,
238}
239
240fn http_client() -> Result<reqwest::Client, Box<dyn std::error::Error>> {
241    Ok(reqwest::Client::builder()
242        .timeout(std::time::Duration::from_secs(15))
243        .user_agent(format!("roboticus/{}", env!("CARGO_PKG_VERSION")))
244        .build()?)
245}
246
247fn run_oauth_storage_maintenance() {
248    let (OK, _, WARN, DETAIL, _) = icons();
249    let oauth_health = check_and_repair_oauth_storage(true);
250    if oauth_health.needs_attention() {
251        if oauth_health.repaired {
252            println!("    {OK} OAuth token storage repaired/migrated");
253        } else if !oauth_health.keystore_available {
254            println!("    {WARN} OAuth migration check skipped (keystore unavailable)");
255            println!(
256                "    {DETAIL} Run `roboticus mechanic --repair` after fixing keystore access."
257            );
258        } else {
259            println!("    {WARN} OAuth token storage requires manual attention");
260            println!("    {DETAIL} Run `roboticus mechanic --repair` to attempt recovery.");
261        }
262    } else {
263        println!("    {OK} OAuth token storage is healthy");
264    }
265}
266
267/// Callback type for state hygiene. The closure receives a config file path
268/// and returns `Ok(Some((changed_rows, subagent, cron_payload, cron_disabled)))`
269/// on success, or an error.
270pub type HygieneFn = Box<
271    dyn Fn(&str) -> Result<Option<(u64, u64, u64, u64)>, Box<dyn std::error::Error>> + Send + Sync,
272>;
273
274/// Callback type for daemon operations (restart after update).
275pub type DaemonOps = Box<dyn Fn() -> Result<(), Box<dyn std::error::Error>> + Send + Sync>;
276
277/// Result of the daemon installed check + restart.
278pub struct DaemonCallbacks {
279    pub is_installed: Box<dyn Fn() -> bool + Send + Sync>,
280    pub restart: DaemonOps,
281}
282
283fn run_mechanic_checks_maintenance(config_path: &str, hygiene_fn: Option<&HygieneFn>) {
284    let (OK, _, WARN, DETAIL, _) = icons();
285    let Some(hygiene) = hygiene_fn else {
286        return;
287    };
288    match hygiene(config_path) {
289        Ok(Some((changed_rows, subagent, cron_payload, cron_disabled))) if changed_rows > 0 => {
290            println!(
291                "    {OK} Mechanic checks repaired {changed_rows} row(s) (subagents={subagent}, cron_payloads={cron_payload}, invalid_cron_disabled={cron_disabled})"
292            );
293        }
294        Ok(_) => println!("    {OK} Mechanic checks found no repairs needed"),
295        Err(e) => {
296            println!("    {WARN} Mechanic checks failed: {e}");
297            println!("    {DETAIL} Run `roboticus mechanic --repair` for detailed diagnostics.");
298        }
299    }
300}
301
302fn apply_removed_legacy_config_migration(
303    config_path: &str,
304) -> Result<(), Box<dyn std::error::Error>> {
305    let path = Path::new(config_path);
306    let (_, _, WARN, DETAIL, _) = icons();
307    if let Some(report) = roboticus_core::config_utils::migrate_removed_legacy_config_file(path)? {
308        println!("    {WARN} Removed legacy config compatibility settings during update");
309        if report.renamed_server_host_to_bind {
310            println!("    {DETAIL} Renamed [server].host to [server].bind");
311        }
312        if report.routing_mode_heuristic_rewritten {
313            println!("    {DETAIL} Rewrote models.routing.mode from heuristic to metascore");
314        }
315        if report.deny_on_empty_allowlist_hardened {
316            println!("    {DETAIL} Hardened security.deny_on_empty_allowlist to true");
317        }
318        if report.removed_credit_cooldown_seconds {
319            println!("    {DETAIL} Removed deprecated circuit_breaker.credit_cooldown_seconds");
320        }
321    }
322    Ok(())
323}
324
325// ── Version comparison ───────────────────────────────────────
326
327fn parse_semver(v: &str) -> (u32, u32, u32) {
328    let v = v.trim_start_matches('v');
329    let v = v.split_once('+').map(|(core, _)| core).unwrap_or(v);
330    let v = v.split_once('-').map(|(core, _)| core).unwrap_or(v);
331    let parts: Vec<&str> = v.split('.').collect();
332    let major = parts
333        .first()
334        .and_then(|s| s.parse().ok())
335        .unwrap_or_else(|| {
336            tracing::warn!(version = v, "failed to parse major version component");
337            0
338        });
339    let minor = parts
340        .get(1)
341        .and_then(|s| s.parse().ok())
342        .unwrap_or_else(|| {
343            tracing::warn!(version = v, "failed to parse minor version component");
344            0
345        });
346    let patch = parts
347        .get(2)
348        .and_then(|s| s.parse().ok())
349        .unwrap_or_else(|| {
350            tracing::warn!(version = v, "failed to parse patch version component");
351            0
352        });
353    (major, minor, patch)
354}
355
356pub(crate) fn is_newer(remote: &str, local: &str) -> bool {
357    parse_semver(remote) > parse_semver(local)
358}
359
360fn platform_archive_name(version: &str) -> Option<String> {
361    let (arch, os, ext) = platform_archive_parts()?;
362    Some(format!("roboticus-{version}-{arch}-{os}.{ext}"))
363}
364
365fn platform_archive_parts() -> Option<(&'static str, &'static str, &'static str)> {
366    let arch = match std::env::consts::ARCH {
367        "x86_64" => "x86_64",
368        "aarch64" => "aarch64",
369        _ => return None,
370    };
371    let os = match std::env::consts::OS {
372        "linux" => "linux",
373        "macos" => "darwin",
374        "windows" => "windows",
375        _ => return None,
376    };
377    let ext = if os == "windows" { "zip" } else { "tar.gz" };
378    Some((arch, os, ext))
379}
380
381fn parse_sha256sums_for_artifact(sha256sums: &str, artifact: &str) -> Option<String> {
382    for raw in sha256sums.lines() {
383        let line = raw.trim();
384        if line.is_empty() || line.starts_with('#') {
385            continue;
386        }
387        let mut parts = line.split_whitespace();
388        let hash = parts.next()?;
389        let file = parts.next()?;
390        if file == artifact {
391            return Some(hash.to_ascii_lowercase());
392        }
393    }
394    None
395}
396
397#[derive(Debug, Clone, Deserialize)]
398struct GitHubRelease {
399    tag_name: String,
400    draft: bool,
401    prerelease: bool,
402    published_at: Option<String>,
403    assets: Vec<GitHubAsset>,
404}
405
406#[derive(Debug, Clone, Deserialize)]
407struct GitHubAsset {
408    name: String,
409}
410
411fn core_version(s: &str) -> &str {
412    let s = s.trim_start_matches('v');
413    let s = s.split_once('+').map(|(core, _)| core).unwrap_or(s);
414    s.split_once('-').map(|(core, _)| core).unwrap_or(s)
415}
416
417fn select_archive_asset_name(release: &GitHubRelease, version: &str) -> Option<String> {
418    let exact = platform_archive_name(version)?;
419    if release.assets.iter().any(|a| a.name == exact) {
420        return Some(exact);
421    }
422    let (arch, os, ext) = platform_archive_parts()?;
423    let suffix = format!("-{arch}-{os}.{ext}");
424    let core_prefix = format!("roboticus-{}", core_version(version));
425    release
426        .assets
427        .iter()
428        .find(|a| a.name.ends_with(&suffix) && a.name.starts_with(&core_prefix))
429        .map(|a| a.name.clone())
430}
431
432fn select_release_for_download(
433    releases: &[GitHubRelease],
434    version: &str,
435) -> Option<(String, String)> {
436    let canonical = format!("v{version}");
437
438    if let Some(exact) = releases
439        .iter()
440        .find(|r| !r.draft && !r.prerelease && r.tag_name == canonical)
441    {
442        let has_sums = exact.assets.iter().any(|a| a.name == "SHA256SUMS.txt");
443        if has_sums && let Some(archive) = select_archive_asset_name(exact, version) {
444            return Some((exact.tag_name.clone(), archive));
445        }
446    }
447
448    releases
449        .iter()
450        .filter(|r| !r.draft && !r.prerelease)
451        .filter(|r| core_version(&r.tag_name) == core_version(version))
452        .filter(|r| r.assets.iter().any(|a| a.name == "SHA256SUMS.txt"))
453        .filter_map(|r| select_archive_asset_name(r, version).map(|archive| (r, archive)))
454        .max_by_key(|(r, _)| r.published_at.as_deref().unwrap_or(""))
455        .map(|(r, archive)| (r.tag_name.clone(), archive))
456}
457
458async fn resolve_download_release(
459    client: &reqwest::Client,
460    version: &str,
461) -> Result<(String, String), Box<dyn std::error::Error>> {
462    let resp = client.get(GITHUB_RELEASES_API).send().await?;
463    if !resp.status().is_success() {
464        return Err(format!("Failed to query GitHub releases: HTTP {}", resp.status()).into());
465    }
466    let releases: Vec<GitHubRelease> = resp.json().await?;
467    select_release_for_download(&releases, version).ok_or_else(|| {
468        format!(
469            "No downloadable release found for v{version} with required platform archive and SHA256SUMS.txt"
470        )
471        .into()
472    })
473}
474
475fn find_file_recursive(root: &Path, filename: &str) -> io::Result<Option<PathBuf>> {
476    find_file_recursive_depth(root, filename, 10)
477}
478
479fn find_file_recursive_depth(
480    root: &Path,
481    filename: &str,
482    remaining_depth: usize,
483) -> io::Result<Option<PathBuf>> {
484    if remaining_depth == 0 {
485        return Ok(None);
486    }
487    for entry in std::fs::read_dir(root)? {
488        let entry = entry?;
489        let path = entry.path();
490        if path.is_dir() {
491            if let Some(found) = find_file_recursive_depth(&path, filename, remaining_depth - 1)? {
492                return Ok(Some(found));
493            }
494        } else if path
495            .file_name()
496            .and_then(|n| n.to_str())
497            .map(|n| n == filename)
498            .unwrap_or(false)
499        {
500            return Ok(Some(path));
501        }
502    }
503    Ok(None)
504}
505
506fn install_binary_bytes(bytes: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
507    #[cfg(windows)]
508    {
509        let exe = std::env::current_exe()?;
510        let staging_dir = std::env::temp_dir().join(format!(
511            "roboticus-update-{}-{}",
512            std::process::id(),
513            chrono::Utc::now().timestamp_millis()
514        ));
515        std::fs::create_dir_all(&staging_dir)?;
516        let staged_exe = staging_dir.join("roboticus-staged.exe");
517        std::fs::write(&staged_exe, bytes)?;
518        let log_file = staging_dir.join("apply-update.log");
519        let script_path = staging_dir.join("apply-update.cmd");
520        // The script retries the copy for up to 60 seconds, logs success/failure,
521        // and cleans up the staging directory on success.
522        let script = format!(
523            "@echo off\r\n\
524             setlocal\r\n\
525             set SRC={src}\r\n\
526             set DST={dst}\r\n\
527             set LOG={log}\r\n\
528             echo [%DATE% %TIME%] Starting binary replacement >> \"%LOG%\"\r\n\
529             for /L %%i in (1,1,60) do (\r\n\
530               copy /Y \"%SRC%\" \"%DST%\" >nul 2>nul && goto :ok\r\n\
531               timeout /t 1 /nobreak >nul\r\n\
532             )\r\n\
533             echo [%DATE% %TIME%] FAILED: could not replace binary after 60 attempts >> \"%LOG%\"\r\n\
534             exit /b 1\r\n\
535             :ok\r\n\
536             echo [%DATE% %TIME%] SUCCESS: binary replaced >> \"%LOG%\"\r\n\
537             del /Q \"%SRC%\" >nul 2>nul\r\n\
538             del /Q \"%~f0\" >nul 2>nul\r\n\
539             exit /b 0\r\n",
540            src = staged_exe.display(),
541            dst = exe.display(),
542            log = log_file.display(),
543        );
544        std::fs::write(&script_path, &script)?;
545        let _child = std::process::Command::new("cmd")
546            .arg("/C")
547            .arg(script_path.to_string_lossy().as_ref())
548            .creation_flags(0x00000008) // DETACHED_PROCESS
549            .spawn()?;
550        #[allow(clippy::needless_return)]
551        return Ok(());
552    }
553
554    #[cfg(not(windows))]
555    {
556        let exe = std::env::current_exe()?;
557        let tmp = exe.with_extension("new");
558        std::fs::write(&tmp, bytes)?;
559        #[cfg(unix)]
560        {
561            let mode = std::fs::metadata(&exe)
562                .map(|m| m.permissions().mode())
563                .unwrap_or(0o755);
564            std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(mode))?;
565        }
566        std::fs::rename(&tmp, &exe)?;
567        Ok(())
568    }
569}
570
571async fn apply_binary_download_update(
572    client: &reqwest::Client,
573    latest: &str,
574) -> Result<(), Box<dyn std::error::Error>> {
575    let _archive_probe = platform_archive_name(latest).ok_or_else(|| {
576        format!(
577            "No release archive mapping for platform {}/{}",
578            std::env::consts::OS,
579            std::env::consts::ARCH
580        )
581    })?;
582    let (tag, archive) = resolve_download_release(client, latest).await?;
583    let sha_url = format!("{RELEASE_BASE_URL}/{tag}/SHA256SUMS.txt");
584    let archive_url = format!("{RELEASE_BASE_URL}/{tag}/{archive}");
585
586    let sha_resp = client.get(&sha_url).send().await?;
587    if !sha_resp.status().is_success() {
588        return Err(format!("Failed to fetch SHA256SUMS.txt: HTTP {}", sha_resp.status()).into());
589    }
590    let sha_body = sha_resp.text().await?;
591    let expected = parse_sha256sums_for_artifact(&sha_body, &archive)
592        .ok_or_else(|| format!("No checksum found for artifact {archive}"))?;
593
594    let archive_resp = client.get(&archive_url).send().await?;
595    if !archive_resp.status().is_success() {
596        return Err(format!(
597            "Failed to download release archive: HTTP {}",
598            archive_resp.status()
599        )
600        .into());
601    }
602    let archive_bytes = archive_resp.bytes().await?.to_vec();
603    let actual = bytes_sha256(&archive_bytes);
604    if actual != expected {
605        return Err(
606            format!("SHA256 mismatch for {archive}: expected {expected}, got {actual}").into(),
607        );
608    }
609
610    let temp_root = std::env::temp_dir().join(format!(
611        "roboticus-update-{}-{}",
612        std::process::id(),
613        chrono::Utc::now().timestamp_millis()
614    ));
615    std::fs::create_dir_all(&temp_root)?;
616    let archive_path = if archive.ends_with(".zip") {
617        temp_root.join("roboticus.zip")
618    } else {
619        temp_root.join("roboticus.tar.gz")
620    };
621    std::fs::write(&archive_path, &archive_bytes)?;
622
623    if archive.ends_with(".zip") {
624        let status = std::process::Command::new("powershell")
625            .args([
626                "-NoProfile",
627                "-ExecutionPolicy",
628                "Bypass",
629                "-Command",
630                &format!(
631                    "Expand-Archive -Path \"{}\" -DestinationPath \"{}\" -Force",
632                    archive_path.display(),
633                    temp_root.display()
634                ),
635            ])
636            .status()?;
637        if !status.success() {
638            // best-effort: temp dir cleanup on extraction failure
639            let _ = std::fs::remove_dir_all(&temp_root);
640            return Err(
641                format!("Failed to extract {archive} with PowerShell Expand-Archive").into(),
642            );
643        }
644    } else {
645        let status = std::process::Command::new("tar")
646            .arg("-xzf")
647            .arg(&archive_path)
648            .arg("-C")
649            .arg(&temp_root)
650            .status()?;
651        if !status.success() {
652            // best-effort: temp dir cleanup on extraction failure
653            let _ = std::fs::remove_dir_all(&temp_root);
654            return Err(format!("Failed to extract {archive} with tar").into());
655        }
656    }
657
658    let bin_name = if std::env::consts::OS == "windows" {
659        "roboticus.exe"
660    } else {
661        "roboticus"
662    };
663    let extracted = find_file_recursive(&temp_root, bin_name)?
664        .ok_or_else(|| format!("Could not locate extracted {bin_name} binary"))?;
665    let bytes = std::fs::read(&extracted)?;
666    install_binary_bytes(&bytes)?;
667    // best-effort: temp dir cleanup after successful install
668    let _ = std::fs::remove_dir_all(&temp_root);
669    Ok(())
670}
671
672fn c_compiler_available() -> bool {
673    #[cfg(windows)]
674    {
675        if std::process::Command::new("cmd")
676            .args(["/C", "where", "cl"])
677            .status()
678            .map(|s| s.success())
679            .unwrap_or(false)
680        {
681            return true;
682        }
683        if std::process::Command::new("gcc")
684            .arg("--version")
685            .status()
686            .map(|s| s.success())
687            .unwrap_or(false)
688        {
689            return true;
690        }
691        #[allow(clippy::needless_return)]
692        return std::process::Command::new("clang")
693            .arg("--version")
694            .status()
695            .map(|s| s.success())
696            .unwrap_or(false);
697    }
698
699    #[cfg(not(windows))]
700    {
701        if std::process::Command::new("cc")
702            .arg("--version")
703            .status()
704            .map(|s| s.success())
705            .unwrap_or(false)
706        {
707            return true;
708        }
709        if std::process::Command::new("clang")
710            .arg("--version")
711            .status()
712            .map(|s| s.success())
713            .unwrap_or(false)
714        {
715            return true;
716        }
717        std::process::Command::new("gcc")
718            .arg("--version")
719            .status()
720            .map(|s| s.success())
721            .unwrap_or(false)
722    }
723}
724
725fn apply_binary_cargo_update(latest: &str) -> bool {
726    let (DIM, _, _, _, _, _, _, RESET, _) = colors();
727    let (OK, _, WARN, DETAIL, ERR) = icons();
728    if !c_compiler_available() {
729        println!("    {WARN} Local build toolchain check failed: no C compiler found in PATH");
730        println!(
731            "    {DETAIL} `--method build` requires a C compiler (and related native build tools)."
732        );
733        println!(
734            "    {DETAIL} Recommended: use `roboticus update binary --method download --yes`."
735        );
736        #[cfg(windows)]
737        {
738            println!(
739                "    {DETAIL} Windows: install Visual Studio Build Tools (MSVC) or clang/gcc."
740            );
741        }
742        #[cfg(target_os = "macos")]
743        {
744            println!("    {DETAIL} macOS: run `xcode-select --install`.");
745        }
746        #[cfg(target_os = "linux")]
747        {
748            println!(
749                "    {DETAIL} Linux: install build tools (for example `build-essential` on Debian/Ubuntu)."
750            );
751        }
752        return false;
753    }
754    println!("    Installing v{latest} via cargo install...");
755    println!("    {DIM}This may take a few minutes.{RESET}");
756
757    let status = std::process::Command::new("cargo")
758        .args(["install", CRATE_NAME])
759        .status();
760
761    match status {
762        Ok(s) if s.success() => {
763            println!("    {OK} Binary updated to v{latest}");
764            true
765        }
766        Ok(s) => {
767            println!(
768                "    {ERR} cargo install exited with code {}",
769                s.code().unwrap_or(-1)
770            );
771            false
772        }
773        Err(e) => {
774            println!("    {ERR} Failed to run cargo install: {e}");
775            println!("    {DIM}Ensure cargo is in your PATH{RESET}");
776            false
777        }
778    }
779}
780
781// ── TOML diff ────────────────────────────────────────────────
782
783pub fn diff_lines(old: &str, new: &str) -> Vec<DiffLine> {
784    let old_lines: Vec<&str> = old.lines().collect();
785    let new_lines: Vec<&str> = new.lines().collect();
786    let mut result = Vec::new();
787
788    let max = old_lines.len().max(new_lines.len());
789    for i in 0..max {
790        match (old_lines.get(i), new_lines.get(i)) {
791            (Some(o), Some(n)) if o == n => {
792                result.push(DiffLine::Same((*o).to_string()));
793            }
794            (Some(o), Some(n)) => {
795                result.push(DiffLine::Removed((*o).to_string()));
796                result.push(DiffLine::Added((*n).to_string()));
797            }
798            (Some(o), None) => {
799                result.push(DiffLine::Removed((*o).to_string()));
800            }
801            (None, Some(n)) => {
802                result.push(DiffLine::Added((*n).to_string()));
803            }
804            (None, None) => {}
805        }
806    }
807    result
808}
809
810#[derive(Debug, PartialEq)]
811pub enum DiffLine {
812    Same(String),
813    Added(String),
814    Removed(String),
815}
816
817fn print_diff(old: &str, new: &str) {
818    let (DIM, _, _, GREEN, _, RED, _, RESET, _) = colors();
819    let lines = diff_lines(old, new);
820    let changes: Vec<&DiffLine> = lines
821        .iter()
822        .filter(|l| !matches!(l, DiffLine::Same(_)))
823        .collect();
824
825    if changes.is_empty() {
826        println!("      {DIM}(no changes){RESET}");
827        return;
828    }
829
830    for line in &changes {
831        match line {
832            DiffLine::Removed(s) => println!("      {RED}- {s}{RESET}"),
833            DiffLine::Added(s) => println!("      {GREEN}+ {s}{RESET}"),
834            DiffLine::Same(_) => {}
835        }
836    }
837}
838
839// ── Binary update ────────────────────────────────────────────
840
841pub(crate) async fn check_binary_version(
842    client: &reqwest::Client,
843) -> Result<Option<String>, Box<dyn std::error::Error>> {
844    let resp = client.get(CRATES_IO_API).send().await?;
845    if !resp.status().is_success() {
846        return Ok(None);
847    }
848    let body: serde_json::Value = resp.json().await?;
849    let latest = body
850        .pointer("/crate/max_version")
851        .and_then(|v| v.as_str())
852        .map(String::from);
853    Ok(latest)
854}
855
856async fn apply_binary_update(yes: bool, method: &str) -> Result<bool, Box<dyn std::error::Error>> {
857    let (DIM, BOLD, _, GREEN, _, _, _, RESET, MONO) = colors();
858    let (OK, _, WARN, DETAIL, ERR) = icons();
859    let current = env!("CARGO_PKG_VERSION");
860    let client = http_client()?;
861    let method = method.to_ascii_lowercase();
862
863    println!("\n  {BOLD}Binary Update{RESET}\n");
864    println!("    Current version: {MONO}v{current}{RESET}");
865
866    let latest = match check_binary_version(&client).await? {
867        Some(v) => v,
868        None => {
869            println!("    {WARN} Could not reach crates.io");
870            return Ok(false);
871        }
872    };
873
874    println!("    Latest version:  {MONO}v{latest}{RESET}");
875
876    if !is_newer(&latest, current) {
877        println!("    {OK} Already on latest version");
878        return Ok(false);
879    }
880
881    println!("    {GREEN}New version available: v{latest}{RESET}");
882    println!();
883
884    if std::env::consts::OS == "windows" && method == "build" {
885        println!("    {WARN} Build method is not supported in-process on Windows");
886        println!(
887            "    {DETAIL} Running executables are file-locked. Use `--method download` (recommended),"
888        );
889        println!(
890            "    {DETAIL} or run `cargo install {CRATE_NAME} --force` from a separate PowerShell session."
891        );
892        return Ok(false);
893    }
894
895    if !yes && !confirm_action("Proceed with binary update?", true) {
896        println!("    Skipped.");
897        return Ok(false);
898    }
899
900    let mut updated = false;
901    if method == "download" {
902        println!("    Attempting platform binary download + fingerprint verification...");
903        match apply_binary_download_update(&client, &latest).await {
904            Ok(()) => {
905                println!("    {OK} Binary downloaded and verified (SHA256)");
906                if std::env::consts::OS == "windows" {
907                    println!(
908                        "    {DETAIL} Update staged. The replacement finalizes after this process exits."
909                    );
910                    println!(
911                        "    {DETAIL} Re-run `roboticus version` in a few seconds to confirm."
912                    );
913                }
914                updated = true;
915            }
916            Err(e) => {
917                println!("    {WARN} Download update failed: {e}");
918                if std::env::consts::OS == "windows" {
919                    println!(
920                        "    {DETAIL} On Windows, fallback build-in-place is blocked by executable locks."
921                    );
922                    println!(
923                        "    {DETAIL} Retry `roboticus update binary --method download` or run build update from a separate shell."
924                    );
925                } else if confirm_action(
926                    "Download failed. Fall back to cargo build update? (slower, compiles from source)",
927                    true,
928                ) {
929                    // BUG-020: Always prompt for build fallback regardless of --yes flag.
930                    // The user chose download method explicitly; silently switching to a
931                    // cargo build is a different operation (slower, requires Rust toolchain).
932                    updated = apply_binary_cargo_update(&latest);
933                } else {
934                    println!("    Skipped fallback build.");
935                }
936            }
937        }
938    } else {
939        updated = apply_binary_cargo_update(&latest);
940    }
941
942    if updated {
943        println!("    {OK} Binary updated to v{latest}");
944        let mut state = UpdateState::load();
945        state.binary_version = latest;
946        state.last_check = now_iso();
947        state
948            .save()
949            .inspect_err(
950                |e| tracing::warn!(error = %e, "failed to save update state after version check"),
951            )
952            .ok();
953        Ok(true)
954    } else {
955        if method == "download" {
956            println!("    {ERR} Binary update did not complete");
957        }
958        Ok(false)
959    }
960}
961
962// ── Content update (providers + skills) ──────────────────────
963
964pub(crate) async fn fetch_manifest(
965    client: &reqwest::Client,
966    registry_url: &str,
967) -> Result<RegistryManifest, Box<dyn std::error::Error>> {
968    let resp = client.get(registry_url).send().await?;
969    if !resp.status().is_success() {
970        return Err(format!("Registry returned HTTP {}", resp.status()).into());
971    }
972    let manifest: RegistryManifest = resp.json().await?;
973    Ok(manifest)
974}
975
976async fn fetch_file(
977    client: &reqwest::Client,
978    base_url: &str,
979    relative_path: &str,
980) -> Result<String, Box<dyn std::error::Error>> {
981    let url = format!("{base_url}/{relative_path}");
982    let resp = client.get(&url).send().await?;
983    if !resp.status().is_success() {
984        return Err(format!("Failed to fetch {relative_path}: HTTP {}", resp.status()).into());
985    }
986    Ok(resp.text().await?)
987}
988
989async fn apply_providers_update(
990    yes: bool,
991    registry_url: &str,
992    config_path: &str,
993) -> Result<bool, Box<dyn std::error::Error>> {
994    let (DIM, BOLD, _, GREEN, YELLOW, _, _, RESET, MONO) = colors();
995    let (OK, _, WARN, DETAIL, _) = icons();
996    let client = http_client()?;
997
998    println!("\n  {BOLD}Provider Configs{RESET}\n");
999
1000    let manifest = match fetch_manifest(&client, registry_url).await {
1001        Ok(m) => m,
1002        Err(e) => {
1003            println!("    {WARN} Could not fetch registry manifest: {e}");
1004            return Ok(false);
1005        }
1006    };
1007
1008    let base_url = registry_base_url(registry_url);
1009    let remote_content = match fetch_file(&client, &base_url, &manifest.packs.providers.path).await
1010    {
1011        Ok(c) => c,
1012        Err(e) => {
1013            println!("    {WARN} Could not fetch providers.toml: {e}");
1014            return Ok(false);
1015        }
1016    };
1017
1018    let remote_hash = bytes_sha256(remote_content.as_bytes());
1019    let state = UpdateState::load();
1020
1021    let local_path = providers_local_path(config_path);
1022    let local_exists = local_path.exists();
1023    let local_content = if local_exists {
1024        std::fs::read_to_string(&local_path).unwrap_or_default()
1025    } else {
1026        String::new()
1027    };
1028
1029    if local_exists {
1030        let local_hash = bytes_sha256(local_content.as_bytes());
1031        if local_hash == remote_hash {
1032            println!("    {OK} Provider configs are up to date");
1033            return Ok(false);
1034        }
1035    }
1036
1037    let user_modified = if let Some(ref record) = state.installed_content.providers {
1038        if local_exists {
1039            let current_hash = file_sha256(&local_path).unwrap_or_default();
1040            current_hash != record.sha256
1041        } else {
1042            false
1043        }
1044    } else {
1045        local_exists
1046    };
1047
1048    if !local_exists {
1049        println!("    {GREEN}+ New provider configuration available{RESET}");
1050        print_diff("", &remote_content);
1051    } else if user_modified {
1052        println!("    {YELLOW}Provider config has been modified locally{RESET}");
1053        println!("    Changes from registry:");
1054        print_diff(&local_content, &remote_content);
1055    } else {
1056        println!("    Updated provider configuration available");
1057        print_diff(&local_content, &remote_content);
1058    }
1059
1060    println!();
1061
1062    if user_modified {
1063        match confirm_overwrite("providers config") {
1064            OverwriteChoice::Overwrite => {}
1065            OverwriteChoice::Backup => {
1066                let backup = local_path.with_extension("toml.bak");
1067                std::fs::copy(&local_path, &backup)?;
1068                println!("    {DETAIL} Backed up to {}", backup.display());
1069            }
1070            OverwriteChoice::Skip => {
1071                println!("    Skipped.");
1072                return Ok(false);
1073            }
1074        }
1075    } else if !yes && !confirm_action("Apply provider updates?", true) {
1076        println!("    Skipped.");
1077        return Ok(false);
1078    }
1079
1080    if let Some(parent) = local_path.parent() {
1081        std::fs::create_dir_all(parent)?;
1082    }
1083    std::fs::write(&local_path, &remote_content)?;
1084
1085    let mut state = UpdateState::load();
1086    state.installed_content.providers = Some(ContentRecord {
1087        version: manifest.version.clone(),
1088        sha256: remote_hash,
1089        installed_at: now_iso(),
1090    });
1091    state.last_check = now_iso();
1092    state
1093        .save()
1094        .inspect_err(
1095            |e| tracing::warn!(error = %e, "failed to save update state after provider install"),
1096        )
1097        .ok();
1098
1099    println!("    {OK} Provider configs updated to v{}", manifest.version);
1100    Ok(true)
1101}
1102
1103fn providers_local_path(config_path: &str) -> PathBuf {
1104    if let Ok(content) = std::fs::read_to_string(config_path)
1105        && let Ok(config) = content.parse::<toml::Value>()
1106        && let Some(path) = config.get("providers_file").and_then(|v| v.as_str())
1107    {
1108        return PathBuf::from(path);
1109    }
1110    roboticus_home().join("providers.toml")
1111}
1112
1113async fn apply_skills_update(
1114    yes: bool,
1115    registry_url: &str,
1116    config_path: &str,
1117) -> Result<bool, Box<dyn std::error::Error>> {
1118    let (DIM, BOLD, _, GREEN, YELLOW, _, _, RESET, MONO) = colors();
1119    let (OK, _, WARN, DETAIL, _) = icons();
1120    let client = http_client()?;
1121
1122    println!("\n  {BOLD}Skills{RESET}\n");
1123
1124    let manifest = match fetch_manifest(&client, registry_url).await {
1125        Ok(m) => m,
1126        Err(e) => {
1127            println!("    {WARN} Could not fetch registry manifest: {e}");
1128            return Ok(false);
1129        }
1130    };
1131
1132    let base_url = registry_base_url(registry_url);
1133    let state = UpdateState::load();
1134    let skills_dir = skills_local_dir(config_path);
1135
1136    if !skills_dir.exists() {
1137        std::fs::create_dir_all(&skills_dir)?;
1138    }
1139
1140    let mut new_files = Vec::new();
1141    let mut updated_unmodified = Vec::new();
1142    let mut updated_modified = Vec::new();
1143    let mut up_to_date = Vec::new();
1144
1145    for (filename, remote_hash) in &manifest.packs.skills.files {
1146        // Path traversal guard: string check + component normalization.
1147        if !is_safe_skill_path(&skills_dir, filename) {
1148            tracing::warn!(filename, "skipping manifest entry with suspicious path");
1149            continue;
1150        }
1151
1152        let local_file = skills_dir.join(filename);
1153        let installed_hash = state
1154            .installed_content
1155            .skills
1156            .as_ref()
1157            .and_then(|s| s.files.get(filename))
1158            .cloned();
1159
1160        if !local_file.exists() {
1161            new_files.push(filename.clone());
1162            continue;
1163        }
1164
1165        let current_hash = file_sha256(&local_file).unwrap_or_default();
1166        if &current_hash == remote_hash {
1167            up_to_date.push(filename.clone());
1168            continue;
1169        }
1170
1171        let user_modified = match &installed_hash {
1172            Some(ih) => current_hash != *ih,
1173            None => true,
1174        };
1175
1176        if user_modified {
1177            updated_modified.push(filename.clone());
1178        } else {
1179            updated_unmodified.push(filename.clone());
1180        }
1181    }
1182
1183    if new_files.is_empty() && updated_unmodified.is_empty() && updated_modified.is_empty() {
1184        println!(
1185            "    {OK} All skills are up to date ({} files)",
1186            up_to_date.len()
1187        );
1188        return Ok(false);
1189    }
1190
1191    let total_changes = new_files.len() + updated_unmodified.len() + updated_modified.len();
1192    println!(
1193        "    {total_changes} change(s): {} new, {} updated, {} with local modifications",
1194        new_files.len(),
1195        updated_unmodified.len(),
1196        updated_modified.len()
1197    );
1198    println!();
1199
1200    for f in &new_files {
1201        println!("    {GREEN}+ {f}{RESET} (new)");
1202    }
1203    for f in &updated_unmodified {
1204        println!("    {DIM}  {f}{RESET} (unmodified -- will auto-update)");
1205    }
1206    for f in &updated_modified {
1207        println!("    {YELLOW}  {f}{RESET} (YOU MODIFIED THIS FILE)");
1208    }
1209
1210    println!();
1211    if !yes && !confirm_action("Apply skill updates?", true) {
1212        println!("    Skipped.");
1213        return Ok(false);
1214    }
1215
1216    let mut applied = 0u32;
1217    let mut file_hashes: HashMap<String, String> = state
1218        .installed_content
1219        .skills
1220        .as_ref()
1221        .map(|s| s.files.clone())
1222        .unwrap_or_default();
1223
1224    for filename in new_files.iter().chain(updated_unmodified.iter()) {
1225        let remote_content = fetch_file(
1226            &client,
1227            &base_url,
1228            &format!("{}{}", manifest.packs.skills.path, filename),
1229        )
1230        .await?;
1231        // Verify downloaded content matches the manifest hash before writing to disk.
1232        let download_hash = bytes_sha256(remote_content.as_bytes());
1233        if let Some(expected) = manifest.packs.skills.files.get(filename)
1234            && download_hash != *expected
1235        {
1236            tracing::warn!(
1237                filename,
1238                expected,
1239                actual = %download_hash,
1240                "skill download hash mismatch — skipping"
1241            );
1242            continue;
1243        }
1244        std::fs::write(skills_dir.join(filename), &remote_content)?;
1245        file_hashes.insert(filename.clone(), download_hash);
1246        applied += 1;
1247    }
1248
1249    for filename in &updated_modified {
1250        let local_file = skills_dir.join(filename);
1251        let local_content = std::fs::read_to_string(&local_file).unwrap_or_default();
1252        let remote_content = fetch_file(
1253            &client,
1254            &base_url,
1255            &format!("{}{}", manifest.packs.skills.path, filename),
1256        )
1257        .await?;
1258
1259        // Verify downloaded content matches the manifest hash before offering to the user.
1260        let download_hash = bytes_sha256(remote_content.as_bytes());
1261        if let Some(expected) = manifest.packs.skills.files.get(filename.as_str())
1262            && download_hash != *expected
1263        {
1264            tracing::warn!(
1265                filename,
1266                expected,
1267                actual = %download_hash,
1268                "skill download hash mismatch — skipping"
1269            );
1270            continue;
1271        }
1272
1273        println!();
1274        println!("    {YELLOW}{filename}{RESET} -- local modifications detected:");
1275        print_diff(&local_content, &remote_content);
1276
1277        match confirm_overwrite(filename) {
1278            OverwriteChoice::Overwrite => {
1279                std::fs::write(&local_file, &remote_content)?;
1280                file_hashes.insert(filename.clone(), download_hash.clone());
1281                applied += 1;
1282            }
1283            OverwriteChoice::Backup => {
1284                let backup = local_file.with_extension("md.bak");
1285                std::fs::copy(&local_file, &backup)?;
1286                println!("    {DETAIL} Backed up to {}", backup.display());
1287                std::fs::write(&local_file, &remote_content)?;
1288                file_hashes.insert(filename.clone(), download_hash.clone());
1289                applied += 1;
1290            }
1291            OverwriteChoice::Skip => {
1292                println!("    Skipped {filename}.");
1293            }
1294        }
1295    }
1296
1297    let mut state = UpdateState::load();
1298    state.installed_content.skills = Some(SkillsRecord {
1299        version: manifest.version.clone(),
1300        files: file_hashes,
1301        installed_at: now_iso(),
1302    });
1303    state.last_check = now_iso();
1304    state
1305        .save()
1306        .inspect_err(
1307            |e| tracing::warn!(error = %e, "failed to save update state after skills install"),
1308        )
1309        .ok();
1310
1311    println!();
1312    println!(
1313        "    {OK} Applied {applied} skill update(s) (v{})",
1314        manifest.version
1315    );
1316    Ok(true)
1317}
1318
1319// ── Multi-registry support ───────────────────────────────────
1320
1321/// Compare two semver-style version strings.  Returns `true` when
1322/// `local >= remote`, meaning an update is unnecessary.  Gracefully
1323/// falls back to string comparison for non-numeric segments.
1324fn semver_gte(local: &str, remote: &str) -> bool {
1325    /// Decompose a version string into (core_parts, has_pre_release).
1326    /// Per semver, a pre-release version has *lower* precedence than the
1327    /// same core version without a pre-release suffix: 1.0.0-rc.1 < 1.0.0.
1328    fn parse(v: &str) -> (Vec<u64>, bool) {
1329        let v = v.trim_start_matches('v');
1330        // Strip build metadata first (has no effect on precedence).
1331        let v = v.split_once('+').map(|(core, _)| core).unwrap_or(v);
1332        // Detect and strip pre-release suffix.
1333        let (core, has_pre) = match v.split_once('-') {
1334            Some((c, _)) => (c, true),
1335            None => (v, false),
1336        };
1337        let parts = core
1338            .split('.')
1339            .map(|s| s.parse::<u64>().unwrap_or(0))
1340            .collect();
1341        (parts, has_pre)
1342    }
1343    let (l, l_pre) = parse(local);
1344    let (r, r_pre) = parse(remote);
1345    let len = l.len().max(r.len());
1346    for i in 0..len {
1347        let lv = l.get(i).copied().unwrap_or(0);
1348        let rv = r.get(i).copied().unwrap_or(0);
1349        match lv.cmp(&rv) {
1350            std::cmp::Ordering::Greater => return true,
1351            std::cmp::Ordering::Less => return false,
1352            std::cmp::Ordering::Equal => {}
1353        }
1354    }
1355    // Core versions are equal.  A pre-release is *less than* the release:
1356    // local=1.0.0-rc.1 vs remote=1.0.0  →  local < remote  →  false
1357    // local=1.0.0      vs remote=1.0.0-rc.1 → local > remote → true
1358    if l_pre && !r_pre {
1359        return false;
1360    }
1361    true
1362}
1363
1364/// Apply skills updates from all configured registries.
1365///
1366/// Registries are processed in priority order (highest first). When two
1367/// registries publish a skill with the same filename, the higher-priority
1368/// one wins.  Non-default registries are namespaced into subdirectories
1369/// (e.g. `skills/community/`) so they coexist with the default set.
1370pub(crate) async fn apply_multi_registry_skills_update(
1371    yes: bool,
1372    cli_registry_override: Option<&str>,
1373    config_path: &str,
1374) -> Result<bool, Box<dyn std::error::Error>> {
1375    let (_, BOLD, _, _, _, _, _, RESET, _) = colors();
1376    let (OK, _, WARN, _, _) = icons();
1377
1378    // If the user supplied a CLI override, fall through to the single-registry path.
1379    if let Some(url) = cli_registry_override {
1380        return apply_skills_update(yes, url, config_path).await;
1381    }
1382
1383    // Parse just the [update] section to avoid requiring a full valid config.
1384    let registries = match std::fs::read_to_string(config_path).ok().and_then(|raw| {
1385        let table: toml::Value = toml::from_str(&raw).ok()?;
1386        let update_val = table.get("update")?.clone();
1387        let update_cfg: roboticus_core::config::UpdateConfig = update_val.try_into().ok()?;
1388        Some(update_cfg.resolve_registries())
1389    }) {
1390        Some(regs) => regs,
1391        None => {
1392            // Fallback: single default registry from legacy resolution.
1393            let url = resolve_registry_url(None, config_path);
1394            return apply_skills_update(yes, &url, config_path).await;
1395        }
1396    };
1397
1398    // Only one "default" registry (the common case) — delegate directly.
1399    // Non-default registries always need the namespace logic below.
1400    if registries.len() <= 1
1401        && registries
1402            .first()
1403            .map(|r| r.name == "default")
1404            .unwrap_or(true)
1405    {
1406        let url = registries
1407            .first()
1408            .map(|r| r.url.as_str())
1409            .unwrap_or(DEFAULT_REGISTRY_URL);
1410        return apply_skills_update(yes, url, config_path).await;
1411    }
1412
1413    // Multiple registries — process in priority order (highest first).
1414    let mut sorted = registries.clone();
1415    sorted.sort_by(|a, b| b.priority.cmp(&a.priority));
1416
1417    println!("\n  {BOLD}Skills (multi-registry){RESET}\n");
1418
1419    // Show configured registries and prompt before fetching from non-default sources.
1420    let non_default: Vec<_> = sorted
1421        .iter()
1422        .filter(|r| r.enabled && r.name != "default")
1423        .collect();
1424    if !non_default.is_empty() {
1425        for r in &non_default {
1426            println!(
1427                "    {WARN} Non-default registry: {BOLD}{}{RESET} ({})",
1428                r.name, r.url
1429            );
1430        }
1431        if !yes && !confirm_action("Install skills from non-default registries?", false) {
1432            println!("    Skipped non-default registries.");
1433            // Fall back to default-only.
1434            let url = sorted
1435                .iter()
1436                .find(|r| r.name == "default")
1437                .map(|r| r.url.as_str())
1438                .unwrap_or(DEFAULT_REGISTRY_URL);
1439            return apply_skills_update(yes, url, config_path).await;
1440        }
1441    }
1442
1443    let client = http_client()?;
1444    let skills_dir = skills_local_dir(config_path);
1445    if !skills_dir.exists() {
1446        std::fs::create_dir_all(&skills_dir)?;
1447    }
1448
1449    let state = UpdateState::load();
1450    let mut any_changed = false;
1451    // Track claimed filenames to resolve cross-registry conflicts.
1452    let mut claimed_files: HashMap<String, String> = HashMap::new();
1453
1454    for reg in &sorted {
1455        if !reg.enabled {
1456            continue;
1457        }
1458
1459        let manifest = match fetch_manifest(&client, &reg.url).await {
1460            Ok(m) => m,
1461            Err(e) => {
1462                println!(
1463                    "    {WARN} [{name}] Could not fetch manifest: {e}",
1464                    name = reg.name
1465                );
1466                continue;
1467            }
1468        };
1469
1470        // Version-based skip: if local installed version >= remote, skip.
1471        let installed_version = state
1472            .installed_content
1473            .skills
1474            .as_ref()
1475            .map(|s| s.version.as_str())
1476            .unwrap_or("0.0.0");
1477        if semver_gte(installed_version, &manifest.version) {
1478            // Also verify all file hashes still match before declaring up-to-date.
1479            let all_match = manifest.packs.skills.files.iter().all(|(fname, hash)| {
1480                let local = skills_dir.join(fname);
1481                local.exists() && file_sha256(&local).unwrap_or_default() == *hash
1482            });
1483            if all_match {
1484                println!(
1485                    "    {OK} [{name}] All skills are up to date (v{ver})",
1486                    name = reg.name,
1487                    ver = manifest.version
1488                );
1489                continue;
1490            }
1491        }
1492
1493        // Determine the target directory for this registry's files.
1494        // Guard: registry names must not contain path traversal components.
1495        if reg.name.contains("..") || reg.name.contains('/') || reg.name.contains('\\') {
1496            tracing::warn!(registry = %reg.name, "skipping registry with suspicious name");
1497            continue;
1498        }
1499        let target_dir = if reg.name == "default" {
1500            skills_dir.clone()
1501        } else {
1502            let ns_dir = skills_dir.join(&reg.name);
1503            if !ns_dir.exists() {
1504                std::fs::create_dir_all(&ns_dir)?;
1505            }
1506            ns_dir
1507        };
1508
1509        let base_url = registry_base_url(&reg.url);
1510        let mut applied = 0u32;
1511
1512        for (filename, remote_hash) in &manifest.packs.skills.files {
1513            // Path traversal guard: string check + component normalization.
1514            if !is_safe_skill_path(&target_dir, filename) {
1515                tracing::warn!(
1516                    registry = %reg.name,
1517                    filename,
1518                    "skipping manifest entry with suspicious path"
1519                );
1520                continue;
1521            }
1522
1523            // Cross-registry conflict: key on the resolved file path so that
1524            // different namespaced registries writing to different directories
1525            // don't falsely collide on the same bare filename.
1526            let resolved_key = target_dir.join(filename).to_string_lossy().to_string();
1527            if let Some(owner) = claimed_files.get(&resolved_key)
1528                && *owner != reg.name
1529            {
1530                continue;
1531            }
1532            claimed_files.insert(resolved_key, reg.name.clone());
1533
1534            let local_file = target_dir.join(filename);
1535            if local_file.exists() {
1536                let current_hash = file_sha256(&local_file).unwrap_or_default();
1537                if current_hash == *remote_hash {
1538                    continue; // Already up to date.
1539                }
1540            }
1541
1542            // Fetch and write the file, verifying hash matches manifest.
1543            match fetch_file(
1544                &client,
1545                &base_url,
1546                &format!("{}{}", manifest.packs.skills.path, filename),
1547            )
1548            .await
1549            {
1550                Ok(content) => {
1551                    let download_hash = bytes_sha256(content.as_bytes());
1552                    if download_hash != *remote_hash {
1553                        tracing::warn!(
1554                            registry = %reg.name,
1555                            filename,
1556                            expected = %remote_hash,
1557                            actual = %download_hash,
1558                            "skill download hash mismatch — skipping"
1559                        );
1560                        continue;
1561                    }
1562                    std::fs::write(&local_file, &content)?;
1563                    applied += 1;
1564                }
1565                Err(e) => {
1566                    println!(
1567                        "    {WARN} [{name}] Failed to fetch {filename}: {e}",
1568                        name = reg.name
1569                    );
1570                }
1571            }
1572        }
1573
1574        if applied > 0 {
1575            any_changed = true;
1576            println!(
1577                "    {OK} [{name}] Applied {applied} skill update(s) (v{ver})",
1578                name = reg.name,
1579                ver = manifest.version
1580            );
1581        } else {
1582            println!(
1583                "    {OK} [{name}] All skills are up to date",
1584                name = reg.name
1585            );
1586        }
1587    }
1588
1589    // Save updated state — record file hashes so the next run can skip unchanged files.
1590    // Without persisting `installed_content.skills`, the multi-registry path would
1591    // re-download every file on every run because it couldn't prove they're up-to-date.
1592    {
1593        let mut state = UpdateState::load();
1594        state.last_check = now_iso();
1595        if any_changed {
1596            // Build a merged file-hash map across all registries.
1597            let mut file_hashes: HashMap<String, String> = state
1598                .installed_content
1599                .skills
1600                .as_ref()
1601                .map(|s| s.files.clone())
1602                .unwrap_or_default();
1603            // Walk skills_dir to capture current on-disk hashes.
1604            if let Ok(entries) = std::fs::read_dir(&skills_dir) {
1605                for entry in entries.flatten() {
1606                    let path = entry.path();
1607                    if path.is_file()
1608                        && let Some(name) = path.file_name().and_then(|n| n.to_str())
1609                        && let Ok(hash) = file_sha256(&path)
1610                    {
1611                        file_hashes.insert(name.to_string(), hash);
1612                    }
1613                }
1614            }
1615            // Use the highest manifest version across registries.
1616            let max_version = sorted
1617                .iter()
1618                .filter(|r| r.enabled)
1619                .map(|r| r.name.as_str())
1620                .next()
1621                .unwrap_or("0.0.0");
1622            let _ = max_version; // We don't have per-registry versions cached; use "multi".
1623            state.installed_content.skills = Some(SkillsRecord {
1624                version: "multi".into(),
1625                files: file_hashes,
1626                installed_at: now_iso(),
1627            });
1628        }
1629        state
1630            .save()
1631            .inspect_err(
1632                |e| tracing::warn!(error = %e, "failed to save update state after multi-registry sync"),
1633            )
1634            .ok();
1635    }
1636
1637    Ok(any_changed)
1638}
1639
1640fn skills_local_dir(config_path: &str) -> PathBuf {
1641    if let Ok(content) = std::fs::read_to_string(config_path)
1642        && let Ok(config) = content.parse::<toml::Value>()
1643        && let Some(path) = config
1644            .get("skills")
1645            .and_then(|s| s.get("skills_dir"))
1646            .and_then(|v| v.as_str())
1647    {
1648        return PathBuf::from(path);
1649    }
1650    roboticus_home().join("skills")
1651}
1652
1653// ── Public CLI entry points ──────────────────────────────────
1654
1655pub async fn cmd_update_check(
1656    channel: &str,
1657    registry_url_override: Option<&str>,
1658    config_path: &str,
1659) -> Result<(), Box<dyn std::error::Error>> {
1660    let (DIM, BOLD, _, GREEN, _, _, _, RESET, MONO) = colors();
1661    let (OK, _, WARN, _, _) = icons();
1662
1663    heading("Update Check");
1664    let current = env!("CARGO_PKG_VERSION");
1665    let client = http_client()?;
1666
1667    // Binary
1668    println!("\n  {BOLD}Binary{RESET}");
1669    println!("    Current: {MONO}v{current}{RESET}");
1670    println!("    Channel: {DIM}{channel}{RESET}");
1671
1672    match check_binary_version(&client).await? {
1673        Some(latest) => {
1674            if is_newer(&latest, current) {
1675                println!("    Latest:  {GREEN}v{latest}{RESET} (update available)");
1676            } else {
1677                println!("    {OK} Up to date (v{current})");
1678            }
1679        }
1680        None => println!("    {WARN} Could not check crates.io"),
1681    }
1682
1683    // Content packs — resolve all configured registries (multi-registry aware).
1684    let registries: Vec<roboticus_core::config::RegistrySource> =
1685        if let Some(url) = registry_url_override {
1686            // CLI override → single registry.
1687            vec![roboticus_core::config::RegistrySource {
1688                name: "cli-override".into(),
1689                url: url.to_string(),
1690                priority: 100,
1691                enabled: true,
1692            }]
1693        } else {
1694            std::fs::read_to_string(config_path)
1695                .ok()
1696                .and_then(|raw| {
1697                    let table: toml::Value = toml::from_str(&raw).ok()?;
1698                    let update_val = table.get("update")?.clone();
1699                    let update_cfg: roboticus_core::config::UpdateConfig =
1700                        update_val.try_into().ok()?;
1701                    Some(update_cfg.resolve_registries())
1702                })
1703                .unwrap_or_else(|| {
1704                    // Fallback: legacy single-URL resolution.
1705                    let url = resolve_registry_url(None, config_path);
1706                    vec![roboticus_core::config::RegistrySource {
1707                        name: "default".into(),
1708                        url,
1709                        priority: 50,
1710                        enabled: true,
1711                    }]
1712                })
1713        };
1714
1715    let enabled: Vec<_> = registries.iter().filter(|r| r.enabled).collect();
1716
1717    println!("\n  {BOLD}Content Packs{RESET}");
1718    if enabled.len() == 1 {
1719        println!("    Registry: {DIM}{}{RESET}", enabled[0].url);
1720    } else {
1721        for reg in &enabled {
1722            println!("    Registry: {DIM}{}{RESET} ({})", reg.url, reg.name);
1723        }
1724    }
1725
1726    // Check the primary (first enabled) registry for providers + skills status.
1727    let primary_url = enabled
1728        .first()
1729        .map(|r| r.url.as_str())
1730        .unwrap_or(DEFAULT_REGISTRY_URL);
1731
1732    match fetch_manifest(&client, primary_url).await {
1733        Ok(manifest) => {
1734            let state = UpdateState::load();
1735            println!("    Pack version: {MONO}v{}{RESET}", manifest.version);
1736
1737            // Providers
1738            let providers_path = providers_local_path(config_path);
1739            if providers_path.exists() {
1740                let local_hash = file_sha256(&providers_path).unwrap_or_default();
1741                if local_hash == manifest.packs.providers.sha256 {
1742                    println!("    {OK} Providers: up to date");
1743                } else {
1744                    println!("    {GREEN}\u{25b6}{RESET} Providers: update available");
1745                }
1746            } else {
1747                println!("    {GREEN}+{RESET} Providers: new (not yet installed locally)");
1748            }
1749
1750            // Skills
1751            let skills_dir = skills_local_dir(config_path);
1752            let mut skills_new = 0u32;
1753            let mut skills_changed = 0u32;
1754            let mut skills_ok = 0u32;
1755            for (filename, remote_hash) in &manifest.packs.skills.files {
1756                let local_file = skills_dir.join(filename);
1757                if !local_file.exists() {
1758                    skills_new += 1;
1759                } else {
1760                    let local_hash = file_sha256(&local_file).unwrap_or_default();
1761                    if local_hash == *remote_hash {
1762                        skills_ok += 1;
1763                    } else {
1764                        skills_changed += 1;
1765                    }
1766                }
1767            }
1768
1769            if skills_new == 0 && skills_changed == 0 {
1770                println!("    {OK} Skills: up to date ({skills_ok} files)");
1771            } else {
1772                println!(
1773                    "    {GREEN}\u{25b6}{RESET} Skills: {skills_new} new, {skills_changed} changed, {skills_ok} current"
1774                );
1775            }
1776
1777            // Check additional non-default registries for reachability.
1778            for reg in enabled.iter().skip(1) {
1779                match fetch_manifest(&client, &reg.url).await {
1780                    Ok(m) => println!("    {OK} {}: reachable (v{})", reg.name, m.version),
1781                    Err(e) => println!("    {WARN} {}: unreachable ({e})", reg.name),
1782                }
1783            }
1784
1785            if let Some(ref providers) = state.installed_content.providers {
1786                println!(
1787                    "\n    {DIM}Last content update: {}{RESET}",
1788                    providers.installed_at
1789                );
1790            }
1791        }
1792        Err(e) => {
1793            println!("    {WARN} Could not reach registry: {e}");
1794        }
1795    }
1796
1797    println!();
1798    Ok(())
1799}
1800
1801pub async fn cmd_update_all(
1802    channel: &str,
1803    yes: bool,
1804    no_restart: bool,
1805    registry_url_override: Option<&str>,
1806    config_path: &str,
1807    hygiene_fn: Option<&HygieneFn>,
1808    daemon_cbs: Option<&DaemonCallbacks>,
1809) -> Result<(), Box<dyn std::error::Error>> {
1810    let (_, BOLD, _, _, _, _, _, RESET, _) = colors();
1811    let (OK, _, WARN, DETAIL, _) = icons();
1812    heading("Roboticus Update");
1813
1814    // ── Liability Waiver ──────────────────────────────────────────
1815    println!();
1816    println!("    {BOLD}IMPORTANT — PLEASE READ{RESET}");
1817    println!();
1818    println!("    Roboticus is an autonomous AI agent that can execute actions,");
1819    println!("    interact with external services, and manage digital assets");
1820    println!("    including cryptocurrency wallets and on-chain transactions.");
1821    println!();
1822    println!("    THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND.");
1823    println!("    The developers and contributors bear {BOLD}no responsibility{RESET} for:");
1824    println!();
1825    println!("      - Actions taken by the agent, whether intended or unintended");
1826    println!("      - Loss of funds, income, cryptocurrency, or other digital assets");
1827    println!("      - Security vulnerabilities, compromises, or unauthorized access");
1828    println!("      - Damages arising from the agent's use, misuse, or malfunction");
1829    println!("      - Any financial, legal, or operational consequences whatsoever");
1830    println!();
1831    println!("    By proceeding, you acknowledge that you use Roboticus entirely");
1832    println!("    at your own risk and accept full responsibility for its operation.");
1833    println!();
1834    if !yes && !confirm_action("I understand and accept these terms", true) {
1835        println!("\n    Update cancelled.\n");
1836        return Ok(());
1837    }
1838
1839    let binary_updated = apply_binary_update(yes, "download").await?;
1840
1841    let registry_url = resolve_registry_url(registry_url_override, config_path);
1842    apply_providers_update(yes, &registry_url, config_path).await?;
1843    apply_multi_registry_skills_update(yes, registry_url_override, config_path).await?;
1844    run_oauth_storage_maintenance();
1845    run_mechanic_checks_maintenance(config_path, hygiene_fn);
1846    if let Err(e) = apply_removed_legacy_config_migration(config_path) {
1847        println!("    {WARN} Legacy config migration skipped: {e}");
1848    }
1849
1850    // ── Post-upgrade security config migration ─────────────────────
1851    // Detect pre-RBAC configs (no [security] section) and warn about
1852    // the breaking change: empty allow-lists now deny all messages.
1853    if let Err(e) = apply_security_config_migration(config_path) {
1854        println!("    {WARN} Security config migration skipped: {e}");
1855    }
1856
1857    // Restart the daemon if a binary update was applied and --no-restart was not passed.
1858    if let Some(daemon) = daemon_cbs {
1859        if binary_updated && !no_restart && (daemon.is_installed)() {
1860            println!("\n    Restarting daemon to apply update...");
1861            match (daemon.restart)() {
1862                Ok(()) => println!("    {OK} Daemon restarted"),
1863                Err(e) => {
1864                    println!("    {WARN} Could not restart daemon: {e}");
1865                    println!("    {DETAIL} Run `roboticus daemon restart` manually.");
1866                }
1867            }
1868        } else if binary_updated && no_restart {
1869            println!("\n    {DETAIL} Skipping daemon restart (--no-restart).");
1870            println!("    {DETAIL} Run `roboticus daemon restart` to apply the update.");
1871        }
1872    }
1873
1874    println!("\n  {BOLD}Update complete.{RESET}\n");
1875    Ok(())
1876}
1877
1878pub async fn cmd_update_binary(
1879    _channel: &str,
1880    yes: bool,
1881    method: &str,
1882    hygiene_fn: Option<&HygieneFn>,
1883) -> Result<(), Box<dyn std::error::Error>> {
1884    heading("Roboticus Binary Update");
1885    apply_binary_update(yes, method).await?;
1886    run_oauth_storage_maintenance();
1887    let config_path = roboticus_core::config::resolve_config_path(None)
1888        .unwrap_or_else(|| home_dir().join(".roboticus").join("roboticus.toml"));
1889    run_mechanic_checks_maintenance(&config_path.to_string_lossy(), hygiene_fn);
1890    println!();
1891    Ok(())
1892}
1893
1894pub async fn cmd_update_providers(
1895    yes: bool,
1896    registry_url_override: Option<&str>,
1897    config_path: &str,
1898    hygiene_fn: Option<&HygieneFn>,
1899) -> Result<(), Box<dyn std::error::Error>> {
1900    heading("Provider Config Update");
1901    let registry_url = resolve_registry_url(registry_url_override, config_path);
1902    apply_providers_update(yes, &registry_url, config_path).await?;
1903    run_oauth_storage_maintenance();
1904    run_mechanic_checks_maintenance(config_path, hygiene_fn);
1905    println!();
1906    Ok(())
1907}
1908
1909pub async fn cmd_update_skills(
1910    yes: bool,
1911    registry_url_override: Option<&str>,
1912    config_path: &str,
1913    hygiene_fn: Option<&HygieneFn>,
1914) -> Result<(), Box<dyn std::error::Error>> {
1915    heading("Skills Update");
1916    apply_multi_registry_skills_update(yes, registry_url_override, config_path).await?;
1917    run_oauth_storage_maintenance();
1918    run_mechanic_checks_maintenance(config_path, hygiene_fn);
1919    println!();
1920    Ok(())
1921}
1922
1923// ── Security config migration ────────────────────────────────
1924
1925/// Detect pre-RBAC config files (missing `[security]` section) and auto-append
1926/// the section with explicit defaults. Also prints a breaking-change warning
1927/// about the new deny-by-default behavior for empty channel allow-lists.
1928fn apply_security_config_migration(config_path: &str) -> Result<(), Box<dyn std::error::Error>> {
1929    let path = Path::new(config_path);
1930    if !path.exists() {
1931        return Ok(());
1932    }
1933
1934    let raw = std::fs::read_to_string(path)?;
1935    // Normalize line endings for reliable section detection.
1936    let normalized = raw.replace("\r\n", "\n").replace('\r', "\n");
1937
1938    // Check if [security] section already exists (line-anchored, not substring).
1939    let has_security = normalized.lines().any(|line| line.trim() == "[security]");
1940
1941    if has_security {
1942        return Ok(());
1943    }
1944
1945    // ── Breaking change warning ──────────────────────────────
1946    let (_, BOLD, _, _, _, _, _, RESET, _) = super::colors();
1947    let (_, ERR, WARN, DETAIL, _) = super::icons();
1948
1949    println!();
1950    println!("  {ERR} {BOLD}SECURITY MODEL CHANGE{RESET}");
1951    println!();
1952    println!(
1953        "    Empty channel allow-lists now {BOLD}DENY all messages{RESET} (previously allowed all)."
1954    );
1955    println!(
1956        "    This is a critical security fix — your agent was previously open to the internet."
1957    );
1958    println!();
1959
1960    // Parse the config to show per-channel status.
1961    if let Ok(config) = roboticus_core::RoboticusConfig::from_file(path) {
1962        let channels_status = describe_channel_allowlists(&config);
1963        if !channels_status.is_empty() {
1964            println!("    Your current configuration:");
1965            for line in &channels_status {
1966                println!("      {line}");
1967            }
1968            println!();
1969        }
1970
1971        if config.channels.trusted_sender_ids.is_empty() {
1972            println!("    {WARN} trusted_sender_ids = [] (no Creator-level users configured)");
1973            println!();
1974        }
1975    }
1976
1977    println!("    Run {BOLD}roboticus mechanic --repair{RESET} for guided security setup.");
1978    println!();
1979
1980    // ── Auto-append [security] section with explicit defaults ─
1981    let security_section = r#"
1982# Security: Claim-based RBAC authority resolution.
1983# See `roboticus mechanic` for guided configuration.
1984[security]
1985deny_on_empty_allowlist = true  # empty allow-lists deny all messages (secure default)
1986allowlist_authority = "Peer"     # allow-listed senders get Peer authority
1987trusted_authority = "Creator"    # trusted_sender_ids get Creator authority
1988api_authority = "Creator"        # HTTP API callers get Creator authority
1989threat_caution_ceiling = "External"  # threat-flagged inputs are capped at External
1990"#;
1991
1992    // Backup before modifying.
1993    let backup = path.with_extension("toml.bak");
1994    if !backup.exists() {
1995        std::fs::copy(path, &backup)?;
1996    }
1997
1998    let mut content = normalized;
1999    content.push_str(security_section);
2000
2001    let tmp = path.with_extension("toml.tmp");
2002    std::fs::write(&tmp, &content)?;
2003    std::fs::rename(&tmp, path)?;
2004
2005    println!("    {DETAIL} Added [security] section to {config_path} (backup: .toml.bak)");
2006    println!();
2007
2008    Ok(())
2009}
2010
2011/// Produce human-readable status lines for each configured channel's allow-list.
2012fn describe_channel_allowlists(config: &roboticus_core::RoboticusConfig) -> Vec<String> {
2013    let mut lines = Vec::new();
2014
2015    if let Some(ref tg) = config.channels.telegram {
2016        if tg.allowed_chat_ids.is_empty() {
2017            lines.push("Telegram: allowed_chat_ids = [] (was: open to all → now: deny all)".into());
2018        } else {
2019            lines.push(format!(
2020                "Telegram: {} chat ID(s) configured",
2021                tg.allowed_chat_ids.len()
2022            ));
2023        }
2024    }
2025
2026    if let Some(ref dc) = config.channels.discord {
2027        if dc.allowed_guild_ids.is_empty() {
2028            lines.push("Discord: allowed_guild_ids = [] (was: open to all → now: deny all)".into());
2029        } else {
2030            lines.push(format!(
2031                "Discord: {} guild ID(s) configured",
2032                dc.allowed_guild_ids.len()
2033            ));
2034        }
2035    }
2036
2037    if let Some(ref wa) = config.channels.whatsapp {
2038        if wa.allowed_numbers.is_empty() {
2039            lines.push("WhatsApp: allowed_numbers = [] (was: open to all → now: deny all)".into());
2040        } else {
2041            lines.push(format!(
2042                "WhatsApp: {} number(s) configured",
2043                wa.allowed_numbers.len()
2044            ));
2045        }
2046    }
2047
2048    if let Some(ref sig) = config.channels.signal {
2049        if sig.allowed_numbers.is_empty() {
2050            lines.push("Signal: allowed_numbers = [] (was: open to all → now: deny all)".into());
2051        } else {
2052            lines.push(format!(
2053                "Signal: {} number(s) configured",
2054                sig.allowed_numbers.len()
2055            ));
2056        }
2057    }
2058
2059    if !config.channels.email.allowed_senders.is_empty() {
2060        lines.push(format!(
2061            "Email: {} sender(s) configured",
2062            config.channels.email.allowed_senders.len()
2063        ));
2064    } else if config.channels.email.enabled {
2065        lines.push("Email: allowed_senders = [] (was: open to all → now: deny all)".into());
2066    }
2067
2068    lines
2069}
2070
2071// ── Tests ────────────────────────────────────────────────────
2072
2073#[cfg(test)]
2074mod tests {
2075    use super::*;
2076    use crate::test_support::EnvGuard;
2077    use axum::{Json, Router, extract::State, routing::get};
2078    use tokio::net::TcpListener;
2079
2080    #[derive(Clone)]
2081    struct MockRegistry {
2082        manifest: String,
2083        providers: String,
2084        skill_payload: String,
2085    }
2086
2087    async fn start_mock_registry(
2088        providers: String,
2089        skill_draft: String,
2090    ) -> (String, tokio::task::JoinHandle<()>) {
2091        let providers_hash = bytes_sha256(providers.as_bytes());
2092        let draft_hash = bytes_sha256(skill_draft.as_bytes());
2093        let manifest = serde_json::json!({
2094            "version": "0.8.0",
2095            "packs": {
2096                "providers": {
2097                    "sha256": providers_hash,
2098                    "path": "registry/providers.toml"
2099                },
2100                "skills": {
2101                    "sha256": null,
2102                    "path": "registry/skills/",
2103                    "files": {
2104                        "draft.md": draft_hash
2105                    }
2106                }
2107            }
2108        })
2109        .to_string();
2110
2111        let state = MockRegistry {
2112            manifest,
2113            providers,
2114            skill_payload: skill_draft,
2115        };
2116
2117        async fn manifest_h(State(st): State<MockRegistry>) -> Json<serde_json::Value> {
2118            Json(serde_json::from_str(&st.manifest).unwrap())
2119        }
2120        async fn providers_h(State(st): State<MockRegistry>) -> String {
2121            st.providers
2122        }
2123        async fn skill_h(State(st): State<MockRegistry>) -> String {
2124            st.skill_payload
2125        }
2126
2127        let app = Router::new()
2128            .route("/manifest.json", get(manifest_h))
2129            .route("/registry/providers.toml", get(providers_h))
2130            .route("/registry/skills/draft.md", get(skill_h))
2131            .with_state(state);
2132        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2133        let addr = listener.local_addr().unwrap();
2134        let handle = tokio::spawn(async move {
2135            axum::serve(listener, app).await.unwrap();
2136        });
2137        (
2138            format!("http://{}:{}/manifest.json", addr.ip(), addr.port()),
2139            handle,
2140        )
2141    }
2142
2143    #[test]
2144    fn update_state_serde_roundtrip() {
2145        let state = UpdateState {
2146            binary_version: "0.2.0".into(),
2147            last_check: "2026-02-20T00:00:00Z".into(),
2148            registry_url: DEFAULT_REGISTRY_URL.into(),
2149            installed_content: InstalledContent {
2150                providers: Some(ContentRecord {
2151                    version: "0.2.0".into(),
2152                    sha256: "abc123".into(),
2153                    installed_at: "2026-02-20T00:00:00Z".into(),
2154                }),
2155                skills: Some(SkillsRecord {
2156                    version: "0.2.0".into(),
2157                    files: {
2158                        let mut m = HashMap::new();
2159                        m.insert("draft.md".into(), "hash1".into());
2160                        m.insert("rust.md".into(), "hash2".into());
2161                        m
2162                    },
2163                    installed_at: "2026-02-20T00:00:00Z".into(),
2164                }),
2165            },
2166        };
2167
2168        let json = serde_json::to_string_pretty(&state).unwrap();
2169        let parsed: UpdateState = serde_json::from_str(&json).unwrap();
2170        assert_eq!(parsed.binary_version, "0.2.0");
2171        assert_eq!(
2172            parsed.installed_content.providers.as_ref().unwrap().sha256,
2173            "abc123"
2174        );
2175        assert_eq!(
2176            parsed
2177                .installed_content
2178                .skills
2179                .as_ref()
2180                .unwrap()
2181                .files
2182                .len(),
2183            2
2184        );
2185    }
2186
2187    #[test]
2188    fn update_state_default_is_empty() {
2189        let state = UpdateState::default();
2190        assert_eq!(state.binary_version, "");
2191        assert!(state.installed_content.providers.is_none());
2192        assert!(state.installed_content.skills.is_none());
2193    }
2194
2195    #[test]
2196    fn file_sha256_computes_correctly() {
2197        let dir = tempfile::tempdir().unwrap();
2198        let path = dir.path().join("test.txt");
2199        std::fs::write(&path, "hello world\n").unwrap();
2200
2201        let hash = file_sha256(&path).unwrap();
2202        assert_eq!(hash.len(), 64);
2203
2204        let expected = bytes_sha256(b"hello world\n");
2205        assert_eq!(hash, expected);
2206    }
2207
2208    #[test]
2209    fn file_sha256_error_on_missing() {
2210        let result = file_sha256(Path::new("/nonexistent/file.txt"));
2211        assert!(result.is_err());
2212    }
2213
2214    #[test]
2215    fn bytes_sha256_deterministic() {
2216        let h1 = bytes_sha256(b"test data");
2217        let h2 = bytes_sha256(b"test data");
2218        assert_eq!(h1, h2);
2219        assert_ne!(bytes_sha256(b"different"), h1);
2220    }
2221
2222    #[test]
2223    fn modification_detection_unmodified() {
2224        let dir = tempfile::tempdir().unwrap();
2225        let path = dir.path().join("providers.toml");
2226        let content = "[providers.openai]\nurl = \"https://api.openai.com\"\n";
2227        std::fs::write(&path, content).unwrap();
2228
2229        let installed_hash = bytes_sha256(content.as_bytes());
2230        let current_hash = file_sha256(&path).unwrap();
2231        assert_eq!(current_hash, installed_hash);
2232    }
2233
2234    #[test]
2235    fn modification_detection_modified() {
2236        let dir = tempfile::tempdir().unwrap();
2237        let path = dir.path().join("providers.toml");
2238        let original = "[providers.openai]\nurl = \"https://api.openai.com\"\n";
2239        let modified = "[providers.openai]\nurl = \"https://custom.endpoint.com\"\n";
2240
2241        let installed_hash = bytes_sha256(original.as_bytes());
2242        std::fs::write(&path, modified).unwrap();
2243
2244        let current_hash = file_sha256(&path).unwrap();
2245        assert_ne!(current_hash, installed_hash);
2246    }
2247
2248    #[test]
2249    fn manifest_parse() {
2250        let json = r#"{
2251            "version": "0.2.0",
2252            "packs": {
2253                "providers": { "sha256": "abc123", "path": "registry/providers.toml" },
2254                "skills": {
2255                    "sha256": null,
2256                    "path": "registry/skills/",
2257                    "files": {
2258                        "draft.md": "hash1",
2259                        "rust.md": "hash2"
2260                    }
2261                }
2262            }
2263        }"#;
2264        let manifest: RegistryManifest = serde_json::from_str(json).unwrap();
2265        assert_eq!(manifest.version, "0.2.0");
2266        assert_eq!(manifest.packs.providers.sha256, "abc123");
2267        assert_eq!(manifest.packs.skills.files.len(), 2);
2268        assert_eq!(manifest.packs.skills.files["draft.md"], "hash1");
2269    }
2270
2271    #[test]
2272    fn diff_lines_identical() {
2273        let result = diff_lines("a\nb\nc", "a\nb\nc");
2274        assert!(result.iter().all(|l| matches!(l, DiffLine::Same(_))));
2275    }
2276
2277    #[test]
2278    fn diff_lines_changed() {
2279        let result = diff_lines("a\nb\nc", "a\nB\nc");
2280        assert_eq!(result.len(), 4);
2281        assert_eq!(result[0], DiffLine::Same("a".into()));
2282        assert_eq!(result[1], DiffLine::Removed("b".into()));
2283        assert_eq!(result[2], DiffLine::Added("B".into()));
2284        assert_eq!(result[3], DiffLine::Same("c".into()));
2285    }
2286
2287    #[test]
2288    fn diff_lines_added() {
2289        let result = diff_lines("a\nb", "a\nb\nc");
2290        assert_eq!(result.len(), 3);
2291        assert_eq!(result[2], DiffLine::Added("c".into()));
2292    }
2293
2294    #[test]
2295    fn diff_lines_removed() {
2296        let result = diff_lines("a\nb\nc", "a\nb");
2297        assert_eq!(result.len(), 3);
2298        assert_eq!(result[2], DiffLine::Removed("c".into()));
2299    }
2300
2301    #[test]
2302    fn diff_lines_empty_to_content() {
2303        let result = diff_lines("", "a\nb");
2304        assert!(result.iter().any(|l| matches!(l, DiffLine::Added(_))));
2305    }
2306
2307    #[test]
2308    fn semver_parse_basic() {
2309        assert_eq!(parse_semver("1.2.3"), (1, 2, 3));
2310        assert_eq!(parse_semver("v0.1.0"), (0, 1, 0));
2311        assert_eq!(parse_semver("10.20.30"), (10, 20, 30));
2312    }
2313
2314    #[test]
2315    fn is_newer_works() {
2316        assert!(is_newer("0.2.0", "0.1.0"));
2317        assert!(is_newer("1.0.0", "0.9.9"));
2318        assert!(!is_newer("0.1.0", "0.1.0"));
2319        assert!(!is_newer("0.1.0", "0.2.0"));
2320    }
2321
2322    #[test]
2323    fn registry_base_url_strips_filename() {
2324        let url = "https://roboticus.ai/registry/manifest.json";
2325        assert_eq!(registry_base_url(url), "https://roboticus.ai/registry");
2326    }
2327
2328    #[test]
2329    fn resolve_registry_url_cli_override() {
2330        let result = resolve_registry_url(
2331            Some("https://custom.registry/manifest.json"),
2332            "nonexistent.toml",
2333        );
2334        assert_eq!(result, "https://custom.registry/manifest.json");
2335    }
2336
2337    #[test]
2338    fn resolve_registry_url_default() {
2339        let result = resolve_registry_url(None, "nonexistent.toml");
2340        assert_eq!(result, DEFAULT_REGISTRY_URL);
2341    }
2342
2343    #[test]
2344    fn resolve_registry_url_from_config() {
2345        let dir = tempfile::tempdir().unwrap();
2346        let config = dir.path().join("roboticus.toml");
2347        std::fs::write(
2348            &config,
2349            "[update]\nregistry_url = \"https://my.registry/manifest.json\"\n",
2350        )
2351        .unwrap();
2352
2353        let result = resolve_registry_url(None, config.to_str().unwrap());
2354        assert_eq!(result, "https://my.registry/manifest.json");
2355    }
2356
2357    #[test]
2358    fn update_state_save_load_roundtrip() {
2359        let dir = tempfile::tempdir().unwrap();
2360        let path = dir.path().join("update_state.json");
2361
2362        let state = UpdateState {
2363            binary_version: "0.3.0".into(),
2364            last_check: "2026-03-01T12:00:00Z".into(),
2365            registry_url: "https://example.com/manifest.json".into(),
2366            installed_content: InstalledContent::default(),
2367        };
2368
2369        let json = serde_json::to_string_pretty(&state).unwrap();
2370        std::fs::write(&path, &json).unwrap();
2371
2372        let loaded: UpdateState =
2373            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2374        assert_eq!(loaded.binary_version, "0.3.0");
2375        assert_eq!(loaded.registry_url, "https://example.com/manifest.json");
2376    }
2377
2378    #[test]
2379    fn bytes_sha256_empty_input() {
2380        let hash = bytes_sha256(b"");
2381        assert_eq!(hash.len(), 64);
2382        assert_eq!(
2383            hash,
2384            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
2385        );
2386    }
2387
2388    #[test]
2389    fn parse_semver_partial_version() {
2390        assert_eq!(parse_semver("1"), (1, 0, 0));
2391        assert_eq!(parse_semver("1.2"), (1, 2, 0));
2392    }
2393
2394    #[test]
2395    fn parse_semver_empty() {
2396        assert_eq!(parse_semver(""), (0, 0, 0));
2397    }
2398
2399    #[test]
2400    fn parse_semver_with_v_prefix() {
2401        assert_eq!(parse_semver("v1.2.3"), (1, 2, 3));
2402    }
2403
2404    #[test]
2405    fn parse_semver_ignores_build_and_prerelease_metadata() {
2406        assert_eq!(parse_semver("0.9.4+hotfix.1"), (0, 9, 4));
2407        assert_eq!(parse_semver("v1.2.3-rc.1"), (1, 2, 3));
2408    }
2409
2410    #[test]
2411    fn is_newer_patch_bump() {
2412        assert!(is_newer("1.0.1", "1.0.0"));
2413        assert!(!is_newer("1.0.0", "1.0.1"));
2414    }
2415
2416    #[test]
2417    fn is_newer_same_version() {
2418        assert!(!is_newer("1.0.0", "1.0.0"));
2419    }
2420
2421    #[test]
2422    fn platform_archive_name_supported() {
2423        let name = platform_archive_name("1.2.3");
2424        if let Some(n) = name {
2425            assert!(n.contains("roboticus-1.2.3-"));
2426        }
2427    }
2428
2429    #[test]
2430    fn diff_lines_both_empty() {
2431        let result = diff_lines("", "");
2432        assert!(result.is_empty() || result.iter().all(|l| matches!(l, DiffLine::Same(_))));
2433    }
2434
2435    #[test]
2436    fn diff_lines_content_to_empty() {
2437        let result = diff_lines("a\nb", "");
2438        assert!(result.iter().any(|l| matches!(l, DiffLine::Removed(_))));
2439    }
2440
2441    #[test]
2442    fn registry_base_url_no_slash() {
2443        assert_eq!(registry_base_url("manifest.json"), "manifest.json");
2444    }
2445
2446    #[test]
2447    fn registry_base_url_nested() {
2448        assert_eq!(
2449            registry_base_url("https://cdn.example.com/v1/registry/manifest.json"),
2450            "https://cdn.example.com/v1/registry"
2451        );
2452    }
2453
2454    #[test]
2455    fn installed_content_default_is_empty() {
2456        let ic = InstalledContent::default();
2457        assert!(ic.skills.is_none());
2458        assert!(ic.providers.is_none());
2459    }
2460
2461    #[test]
2462    fn parse_sha256sums_for_artifact_finds_exact_entry() {
2463        let sums = "\
2464abc123  roboticus-0.8.0-darwin-aarch64.tar.gz\n\
2465def456  roboticus-0.8.0-linux-x86_64.tar.gz\n";
2466        let hash = parse_sha256sums_for_artifact(sums, "roboticus-0.8.0-linux-x86_64.tar.gz");
2467        assert_eq!(hash.as_deref(), Some("def456"));
2468    }
2469
2470    #[test]
2471    fn find_file_recursive_finds_nested_target() {
2472        let dir = tempfile::tempdir().unwrap();
2473        let nested = dir.path().join("a").join("b");
2474        std::fs::create_dir_all(&nested).unwrap();
2475        let target = nested.join("needle.txt");
2476        std::fs::write(&target, "x").unwrap();
2477        let found = find_file_recursive(dir.path(), "needle.txt").unwrap();
2478        assert_eq!(found.as_deref(), Some(target.as_path()));
2479    }
2480
2481    #[test]
2482    fn local_path_helpers_fallback_when_config_missing() {
2483        let p = providers_local_path("/no/such/file.toml");
2484        let s = skills_local_dir("/no/such/file.toml");
2485        assert!(p.ends_with("providers.toml"));
2486        assert!(s.ends_with("skills"));
2487    }
2488
2489    #[test]
2490    fn parse_sha256sums_for_artifact_returns_none_when_missing() {
2491        let sums = "abc123  file-a.tar.gz\n";
2492        assert!(parse_sha256sums_for_artifact(sums, "file-b.tar.gz").is_none());
2493    }
2494
2495    #[test]
2496    fn select_release_for_download_prefers_exact_tag() {
2497        let archive = platform_archive_name("0.9.4").unwrap();
2498        let releases = vec![
2499            GitHubRelease {
2500                tag_name: "v0.9.4+hotfix.1".into(),
2501                draft: false,
2502                prerelease: false,
2503                published_at: Some("2026-03-05T11:36:51Z".into()),
2504                assets: vec![
2505                    GitHubAsset {
2506                        name: "SHA256SUMS.txt".into(),
2507                    },
2508                    GitHubAsset {
2509                        name: format!(
2510                            "roboticus-0.9.4+hotfix.1-{}",
2511                            &archive["roboticus-0.9.4-".len()..]
2512                        ),
2513                    },
2514                ],
2515            },
2516            GitHubRelease {
2517                tag_name: "v0.9.4".into(),
2518                draft: false,
2519                prerelease: false,
2520                published_at: Some("2026-03-05T10:00:00Z".into()),
2521                assets: vec![
2522                    GitHubAsset {
2523                        name: "SHA256SUMS.txt".into(),
2524                    },
2525                    GitHubAsset {
2526                        name: archive.clone(),
2527                    },
2528                ],
2529            },
2530        ];
2531
2532        let selected = select_release_for_download(&releases, "0.9.4");
2533        assert_eq!(
2534            selected.as_ref().map(|(tag, _)| tag.as_str()),
2535            Some("v0.9.4")
2536        );
2537    }
2538
2539    #[test]
2540    fn select_release_for_download_falls_back_to_hotfix_tag() {
2541        let archive = platform_archive_name("0.9.4").unwrap();
2542        let suffix = &archive["roboticus-0.9.4-".len()..];
2543        let releases = vec![
2544            GitHubRelease {
2545                tag_name: "v0.9.4".into(),
2546                draft: false,
2547                prerelease: false,
2548                published_at: Some("2026-03-05T10:00:00Z".into()),
2549                assets: vec![GitHubAsset {
2550                    name: "PROVENANCE.json".into(),
2551                }],
2552            },
2553            GitHubRelease {
2554                tag_name: "v0.9.4+hotfix.2".into(),
2555                draft: false,
2556                prerelease: false,
2557                published_at: Some("2026-03-05T12:00:00Z".into()),
2558                assets: vec![
2559                    GitHubAsset {
2560                        name: "SHA256SUMS.txt".into(),
2561                    },
2562                    GitHubAsset {
2563                        name: format!("roboticus-0.9.4+hotfix.2-{suffix}"),
2564                    },
2565                ],
2566            },
2567        ];
2568
2569        let selected = select_release_for_download(&releases, "0.9.4");
2570        let expected_archive = format!("roboticus-0.9.4+hotfix.2-{suffix}");
2571        assert_eq!(
2572            selected.as_ref().map(|(tag, _)| tag.as_str()),
2573            Some("v0.9.4+hotfix.2")
2574        );
2575        assert_eq!(
2576            selected
2577                .as_ref()
2578                .map(|(_, archive_name)| archive_name.as_str()),
2579            Some(expected_archive.as_str())
2580        );
2581    }
2582
2583    #[test]
2584    fn find_file_recursive_returns_none_when_not_found() {
2585        let dir = tempfile::tempdir().unwrap();
2586        let found = find_file_recursive(dir.path(), "does-not-exist.txt").unwrap();
2587        assert!(found.is_none());
2588    }
2589
2590    #[serial_test::serial]
2591    #[tokio::test]
2592    async fn apply_providers_update_fetches_and_writes_local_file() {
2593        let temp = tempfile::tempdir().unwrap();
2594        let _home_guard = EnvGuard::set("HOME", temp.path().to_str().unwrap());
2595        let config_path = temp.path().join("roboticus.toml");
2596        let providers_path = temp.path().join("providers.toml");
2597        std::fs::write(
2598            &config_path,
2599            format!(
2600                "providers_file = \"{}\"\n",
2601                providers_path.display().to_string().replace('\\', "/")
2602            ),
2603        )
2604        .unwrap();
2605
2606        let providers = "[providers.openai]\nurl = \"https://api.openai.com\"\n".to_string();
2607        let (registry_url, handle) =
2608            start_mock_registry(providers.clone(), "# hello\nbody\n".to_string()).await;
2609
2610        let changed = apply_providers_update(true, &registry_url, config_path.to_str().unwrap())
2611            .await
2612            .unwrap();
2613        assert!(changed);
2614        assert_eq!(std::fs::read_to_string(&providers_path).unwrap(), providers);
2615
2616        let changed_second =
2617            apply_providers_update(true, &registry_url, config_path.to_str().unwrap())
2618                .await
2619                .unwrap();
2620        assert!(!changed_second);
2621        handle.abort();
2622    }
2623
2624    #[serial_test::serial]
2625    #[tokio::test]
2626    async fn apply_skills_update_installs_and_then_reports_up_to_date() {
2627        let temp = tempfile::tempdir().unwrap();
2628        let _home_guard = EnvGuard::set("HOME", temp.path().to_str().unwrap());
2629        let skills_dir = temp.path().join("skills");
2630        let config_path = temp.path().join("roboticus.toml");
2631        std::fs::write(
2632            &config_path,
2633            format!(
2634                "[skills]\nskills_dir = \"{}\"\n",
2635                skills_dir.display().to_string().replace('\\', "/")
2636            ),
2637        )
2638        .unwrap();
2639
2640        let draft = "# draft\nfrom registry\n".to_string();
2641        let (registry_url, handle) = start_mock_registry(
2642            "[providers.openai]\nurl=\"https://api.openai.com\"\n".to_string(),
2643            draft.clone(),
2644        )
2645        .await;
2646
2647        let changed = apply_skills_update(true, &registry_url, config_path.to_str().unwrap())
2648            .await
2649            .unwrap();
2650        assert!(changed);
2651        assert_eq!(
2652            std::fs::read_to_string(skills_dir.join("draft.md")).unwrap(),
2653            draft
2654        );
2655
2656        let changed_second =
2657            apply_skills_update(true, &registry_url, config_path.to_str().unwrap())
2658                .await
2659                .unwrap();
2660        assert!(!changed_second);
2661        handle.abort();
2662    }
2663
2664    // ── semver_gte tests ────────────────────────────────────────
2665
2666    #[test]
2667    fn semver_gte_equal_versions() {
2668        assert!(semver_gte("1.0.0", "1.0.0"));
2669    }
2670
2671    #[test]
2672    fn semver_gte_local_newer() {
2673        assert!(semver_gte("1.1.0", "1.0.0"));
2674        assert!(semver_gte("2.0.0", "1.9.9"));
2675        assert!(semver_gte("0.9.6", "0.9.5"));
2676    }
2677
2678    #[test]
2679    fn semver_gte_local_older() {
2680        assert!(!semver_gte("1.0.0", "1.0.1"));
2681        assert!(!semver_gte("0.9.5", "0.9.6"));
2682        assert!(!semver_gte("0.8.9", "0.9.0"));
2683    }
2684
2685    #[test]
2686    fn semver_gte_different_segment_counts() {
2687        assert!(semver_gte("1.0.0", "1.0"));
2688        assert!(semver_gte("1.0", "1.0.0"));
2689        assert!(!semver_gte("1.0", "1.0.1"));
2690    }
2691
2692    #[test]
2693    fn semver_gte_strips_prerelease_and_build_metadata() {
2694        // Per semver spec: pre-release has LOWER precedence than its release.
2695        // 1.0.0-rc.1 < 1.0.0
2696        assert!(!semver_gte("1.0.0-rc.1", "1.0.0"));
2697        assert!(semver_gte("1.0.0", "1.0.0-rc.1"));
2698        // Build metadata: "1.0.0+hotfix.1" should compare as 1.0.0
2699        assert!(semver_gte("1.0.0+build.42", "1.0.0"));
2700        assert!(semver_gte("1.0.0", "1.0.0+build.42"));
2701        // Combined: pre-release + build metadata → still pre-release < release
2702        assert!(!semver_gte("1.0.0-rc.1+build.42", "1.0.0"));
2703        // v prefix with pre-release
2704        assert!(!semver_gte("v1.0.0-rc.1", "1.0.0"));
2705        assert!(!semver_gte("v0.9.5-beta.1", "0.9.6"));
2706        // Two pre-releases with same core version — both are pre-release, so equal core → true
2707        assert!(semver_gte("1.0.0-rc.2", "1.0.0-rc.1"));
2708    }
2709
2710    // ── Multi-registry test ─────────────────────────────────────
2711
2712    /// Helper to start a mock registry that serves skills under a given namespace.
2713    async fn start_namespaced_mock_registry(
2714        registry_name: &str,
2715        skill_filename: &str,
2716        skill_content: String,
2717    ) -> (String, tokio::task::JoinHandle<()>) {
2718        let content_hash = bytes_sha256(skill_content.as_bytes());
2719        let manifest = serde_json::json!({
2720            "version": "1.0.0",
2721            "packs": {
2722                "providers": {
2723                    "sha256": "unused",
2724                    "path": "registry/providers.toml"
2725                },
2726                "skills": {
2727                    "sha256": null,
2728                    "path": format!("registry/{registry_name}/"),
2729                    "files": {
2730                        skill_filename: content_hash
2731                    }
2732                }
2733            }
2734        })
2735        .to_string();
2736
2737        let skill_route = format!("/registry/{registry_name}/{skill_filename}");
2738
2739        let state = MockRegistry {
2740            manifest,
2741            providers: String::new(),
2742            skill_payload: skill_content,
2743        };
2744
2745        async fn manifest_h(State(st): State<MockRegistry>) -> Json<serde_json::Value> {
2746            Json(serde_json::from_str(&st.manifest).unwrap())
2747        }
2748        async fn providers_h(State(st): State<MockRegistry>) -> String {
2749            st.providers.clone()
2750        }
2751        async fn skill_h(State(st): State<MockRegistry>) -> String {
2752            st.skill_payload.clone()
2753        }
2754
2755        let app = Router::new()
2756            .route("/manifest.json", get(manifest_h))
2757            .route("/registry/providers.toml", get(providers_h))
2758            .route(&skill_route, get(skill_h))
2759            .with_state(state);
2760        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2761        let addr = listener.local_addr().unwrap();
2762        let handle = tokio::spawn(async move {
2763            axum::serve(listener, app).await.unwrap();
2764        });
2765        (
2766            format!("http://{}:{}/manifest.json", addr.ip(), addr.port()),
2767            handle,
2768        )
2769    }
2770
2771    #[serial_test::serial]
2772    #[tokio::test]
2773    async fn multi_registry_namespaces_non_default_skills() {
2774        let temp = tempfile::tempdir().unwrap();
2775        let _home_guard = EnvGuard::set("HOME", temp.path().to_str().unwrap());
2776        let skills_dir = temp.path().join("skills");
2777        let config_path = temp.path().join("roboticus.toml");
2778
2779        let skill_content = "# community skill\nbody\n".to_string();
2780        let (registry_url, handle) =
2781            start_namespaced_mock_registry("community", "helper.md", skill_content.clone()).await;
2782
2783        // Write a config file with a multi-registry setup.
2784        let config_toml = format!(
2785            r#"[skills]
2786skills_dir = "{}"
2787
2788[update]
2789registry_url = "{}"
2790
2791[[update.registries]]
2792name = "community"
2793url = "{}"
2794priority = 40
2795enabled = true
2796"#,
2797            skills_dir.display().to_string().replace('\\', "/"),
2798            registry_url,
2799            registry_url,
2800        );
2801        std::fs::write(&config_path, &config_toml).unwrap();
2802
2803        let changed = apply_multi_registry_skills_update(true, None, config_path.to_str().unwrap())
2804            .await
2805            .unwrap();
2806
2807        assert!(changed);
2808        // Skill should be namespaced under community/ subdirectory.
2809        let namespaced_path = skills_dir.join("community").join("helper.md");
2810        assert!(
2811            namespaced_path.exists(),
2812            "expected skill at {}, files in skills_dir: {:?}",
2813            namespaced_path.display(),
2814            std::fs::read_dir(&skills_dir)
2815                .map(|rd| rd.flatten().map(|e| e.path()).collect::<Vec<_>>())
2816                .unwrap_or_default()
2817        );
2818        assert_eq!(
2819            std::fs::read_to_string(&namespaced_path).unwrap(),
2820            skill_content
2821        );
2822
2823        handle.abort();
2824    }
2825}