roboticus-cli 0.11.4

CLI commands and migration engine for the Roboticus agent runtime
Documentation
//! Skills migration transforms.

use std::collections::HashMap;
use std::fs;
use std::path::Path;

use super::{
    AreaResult, MigrationArea, SafetyVerdict, copy_dir_recursive, err, scan_directory_safety,
};

pub(crate) fn import_skills(oc_root: &Path, ic_root: &Path, no_safety_check: bool) -> AreaResult {
    let skills_dir = oc_root.join("workspace").join("skills");
    if !skills_dir.exists() {
        return AreaResult {
            area: MigrationArea::Skills,
            success: true,
            items_processed: 0,
            warnings: vec!["No skills directory found in Legacy workspace".into()],
            error: None,
        };
    }

    let out_dir = ic_root.join("skills");
    if let Err(e) = fs::create_dir_all(&out_dir) {
        return err(
            MigrationArea::Skills,
            format!("Failed to create skills dir: {e}"),
        );
    }

    let mut warnings = Vec::new();

    if !no_safety_check {
        let report = scan_directory_safety(&skills_dir);

        if let SafetyVerdict::Critical(n) = report.verdict {
            return AreaResult {
                area: MigrationArea::Skills,
                success: false,
                items_processed: 0,
                warnings: vec![format!("{n} critical safety finding(s); import blocked")],
                error: Some(
                    "Skills blocked by safety check. Use --no-safety-check to override.".into(),
                ),
            };
        }
        if let SafetyVerdict::Warnings(n) = report.verdict {
            warnings.push(format!(
                "{n} warning(s) found in skill scripts; review recommended"
            ));
        }
    } else {
        warnings.push("Safety checks skipped (--no-safety-check)".into());
    }

    // Read skills.entries from legacy.json for enabled/disabled state
    let skill_entries: HashMap<String, bool> = fs::read_to_string(oc_root.join("legacy.json"))
        .inspect_err(|e| tracing::warn!("failed to read legacy.json for skill entries: {e}"))
        .ok()
        .and_then(|s| {
            serde_json::from_str::<serde_json::Value>(&s)
                .inspect_err(|e| {
                    tracing::warn!("failed to parse legacy.json for skill entries: {e}")
                })
                .ok()
        })
        .and_then(|v| {
            v.get("skills")?.get("entries")?.as_object().map(|obj| {
                obj.iter()
                    .map(|(k, v)| {
                        let enabled = v.get("enabled").and_then(|e| e.as_bool()).unwrap_or(true);
                        (k.clone(), enabled)
                    })
                    .collect()
            })
        })
        .unwrap_or_default();

    let mut items = 0;
    let mut skill_names: Vec<(String, bool)> = Vec::new();

    if let Ok(entries) = fs::read_dir(&skills_dir) {
        for entry in entries.flatten() {
            let src = entry.path();
            let dest = out_dir.join(entry.file_name());
            let name = entry.file_name().to_string_lossy().to_string();

            if name.starts_with('.') || name == "gateway.log" {
                continue;
            }

            if src.is_file() {
                if let Err(e) = fs::copy(&src, &dest) {
                    warnings.push(format!("Failed to copy {}: {e}", src.display()));
                } else {
                    items += 1;
                }
            } else if src.is_dir() {
                if let Err(e) = copy_dir_recursive(&src, &dest) {
                    warnings.push(format!("Failed to copy dir {}: {e}", src.display()));
                } else {
                    let enabled = skill_entries.get(&name).copied().unwrap_or(true);
                    skill_names.push((name, enabled));
                    items += 1;
                }
            }
        }
    }

    // Register skills in the Roboticus database
    let db_path = ic_root.join("state.db");
    if db_path.exists() && !skill_names.is_empty() {
        match roboticus_db::Database::new(db_path.to_string_lossy().as_ref()) {
            Ok(db) => {
                let mut registered = 0u32;
                let mut disabled_count = 0u32;

                for (name, enabled) in &skill_names {
                    let skill_md = out_dir.join(name).join("SKILL.md");
                    let description = fs::read_to_string(&skill_md)
                        .inspect_err(
                            |e| tracing::warn!(skill = %name, "failed to read SKILL.md: {e}"),
                        )
                        .ok()
                        .and_then(|content| parse_skill_description(&content));

                    let source_path = out_dir.join(name).to_string_lossy().to_string();
                    let content_hash = format!("migrated-{}", chrono::Utc::now().timestamp());

                    let kind = if out_dir.join(name).join("SKILL.md").exists() {
                        "instruction"
                    } else {
                        "scripted"
                    };

                    match roboticus_db::skills::register_skill(
                        &db,
                        name,
                        kind,
                        description.as_deref(),
                        &source_path,
                        &content_hash,
                        None,
                        None,
                        None,
                        None,
                        None,
                    ) {
                        Ok(id) => {
                            registered += 1;
                            if !enabled {
                                let conn = db.conn();
                                if let Err(e) = conn.execute(
                                    "UPDATE skills SET enabled = 0 WHERE id = ?1",
                                    rusqlite::params![id],
                                ) {
                                    warnings.push(format!("Failed to disable skill {name}: {e}"));
                                }
                                disabled_count += 1;
                            }
                        }
                        Err(e) => {
                            warnings.push(format!("Failed to register skill {name}: {e}"));
                        }
                    }
                }

                if registered > 0 {
                    warnings.push(format!(
                        "{registered} skill(s) registered in database ({} enabled, {disabled_count} disabled)",
                        registered - disabled_count
                    ));
                }
            }
            Err(e) => {
                warnings.push(format!(
                    "Could not open database to register skills: {e}. \
                     Run `roboticus skills reload` after migration to register them."
                ));
            }
        }
    } else if !skill_names.is_empty() {
        warnings.push(
            "Database not found; skills copied but not registered. \
             Run `roboticus skills reload` after starting the server."
                .into(),
        );
    }

    AreaResult {
        area: MigrationArea::Skills,
        success: true,
        items_processed: items,
        warnings,
        error: None,
    }
}

/// Parse the `description` field from SKILL.md YAML frontmatter.
fn parse_skill_description(content: &str) -> Option<String> {
    let content = content.trim();
    if !content.starts_with("---") {
        return None;
    }
    let rest = &content[3..];
    let end = rest.find("---")?;
    let frontmatter = &rest[..end];
    for line in frontmatter.lines() {
        let line = line.trim();
        if let Some(desc) = line.strip_prefix("description:") {
            let desc = desc.trim().trim_matches('"').trim_matches('\'');
            if !desc.is_empty() {
                return Some(desc.to_string());
            }
        }
    }
    None
}

pub(crate) fn export_skills(ic_root: &Path, oc_root: &Path) -> AreaResult {
    let skills_dir = ic_root.join("skills");
    if !skills_dir.exists() {
        return AreaResult {
            area: MigrationArea::Skills,
            success: true,
            items_processed: 0,
            warnings: vec!["No skills directory found in Roboticus workspace".into()],
            error: None,
        };
    }

    let out_dir = oc_root.join("workspace").join("skills");
    if let Err(e) = fs::create_dir_all(&out_dir) {
        return err(
            MigrationArea::Skills,
            format!("Failed to create output skills dir: {e}"),
        );
    }

    let mut items = 0;
    let mut warnings = Vec::new();
    if let Ok(entries) = fs::read_dir(&skills_dir) {
        for entry in entries.flatten() {
            let src = entry.path();
            let dest = out_dir.join(entry.file_name());
            if src.is_file() {
                if let Err(e) = fs::copy(&src, &dest) {
                    warnings.push(format!("Failed to copy {}: {e}", src.display()));
                } else {
                    items += 1;
                }
            } else if src.is_dir() {
                if let Err(e) = copy_dir_recursive(&src, &dest) {
                    warnings.push(format!("Failed to copy dir {}: {e}", src.display()));
                } else {
                    items += 1;
                }
            }
        }
    }

    AreaResult {
        area: MigrationArea::Skills,
        success: true,
        items_processed: items,
        warnings,
        error: None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_skill_description_extracts_from_frontmatter() {
        let content = "---\nname: my-skill\ndescription: A cool skill\n---\n# Body\nContent here.";
        assert_eq!(
            parse_skill_description(content),
            Some("A cool skill".to_string())
        );
    }

    #[test]
    fn parse_skill_description_handles_quoted_desc() {
        let content = "---\ndescription: \"Quoted description\"\n---\nBody";
        assert_eq!(
            parse_skill_description(content),
            Some("Quoted description".to_string())
        );
    }

    #[test]
    fn parse_skill_description_returns_none_without_frontmatter() {
        assert_eq!(parse_skill_description("Just text"), None);
        assert_eq!(parse_skill_description(""), None);
    }

    #[test]
    fn parse_skill_description_returns_none_for_empty_desc() {
        let content = "---\nname: test\ndescription: \n---\nBody";
        assert_eq!(parse_skill_description(content), None);
    }

    #[test]
    fn parse_skill_description_no_closing_frontmatter() {
        let content = "---\nname: test\ndescription: missing close";
        assert_eq!(parse_skill_description(content), None);
    }
}