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