rsclaw-runtime 2026.6.26

rsclaw composition root: AppState/RPC handlers (a2a, cmd, cron, gateway, hooks, server, ws) + process entry point
use anyhow::Result;

use super::style::*;
use rsclaw_cli::SkillsCommand;
use rsclaw_config as config;
use rsclaw_skill as skill;

pub async fn cmd_skills(sub: SkillsCommand) -> Result<()> {
    let config = config::load_quiet()?;
    let global_dir = skill::default_global_skills_dir().unwrap_or_default();
    let registry = skill::load_skills(&global_dir, None, config.ext.skills.as_ref())?;
    let language = config.gateway.language.clone();
    match sub {
        SkillsCommand::List => {
            let mut skills: Vec<_> = registry.all().collect();
            skills.sort_by_key(|s| s.name.as_str());
            if skills.is_empty() {
                println!("No skills installed.");
                println!();
                println!("Install skills with: rsclaw skills install <name>");
                println!("Search skills with:  rsclaw skills search <query>");
            } else {
                println!(
                    "{:<24} {:<8} {}",
                    bold("NAME"),
                    bold("TOOLS"),
                    bold("DESCRIPTION")
                );
                for s in skills {
                    let desc = s.description.as_deref().unwrap_or("-");
                    println!("{:<24} {:<8} {}", cyan(&s.name), s.tools.len(), desc);
                }
            }
        }
        SkillsCommand::Info { skill } => match registry.get(&skill) {
            Some(s) => {
                println!("{}", bold(&s.name));
                println!();
                println!(
                    "  {:<14} {}",
                    dim("Version"),
                    s.version.as_deref().unwrap_or("-")
                );
                println!(
                    "  {:<14} {}",
                    dim("Description"),
                    s.description.as_deref().unwrap_or("-")
                );
                if !s.tools.is_empty() {
                    println!();
                    println!("  {} ({})", bold("Tools"), s.tools.len());
                    for t in &s.tools {
                        println!(
                            "    {} {}",
                            cyan(&t.name),
                            dim(&format!("-- {}", t.description))
                        );
                    }
                }
            }
            None => {
                eprintln!("Skill '{}' not found locally.", skill);
                eprintln!(
                    "Use `rsclaw skills search {}` to find it on the registry.",
                    skill
                );
                std::process::exit(1);
            }
        },
        SkillsCommand::Use { skill } => {
            // Mirror the in-agent `skill_use` tool: resolve the skill dir
            // (live registry first, then disk for a just-installed skill),
            // print SKILL.md verbatim, and tell the caller where scripts /
            // references live so a CLI coding agent can follow the SOP.
            let dir = match registry.get(&skill) {
                Some(s) => s.dir.clone(),
                None => {
                    if !skill::valid_slug(&skill) {
                        eprintln!("invalid skill name: {skill:?}");
                        std::process::exit(1);
                    }
                    let disk = config::loader::base_dir().join("skills").join(&skill);
                    if disk.join("SKILL.md").is_file() {
                        disk
                    } else {
                        eprintln!("Skill '{}' not installed.", skill);
                        eprintln!(
                            "Use `rsclaw skills search {}` to find it, then `rsclaw skills install`.",
                            skill
                        );
                        std::process::exit(1);
                    }
                }
            };
            let skill_md = dir.join("SKILL.md");
            match std::fs::read_to_string(&skill_md) {
                Ok(body) => {
                    println!("# Skill: {skill}");
                    println!("# Directory: {}", dir.display());
                    println!("# Follow the playbook below; scripts/references are under the directory above.");
                    println!();
                    println!("{body}");
                }
                Err(e) => {
                    eprintln!("failed to read {}: {e}", skill_md.display());
                    std::process::exit(1);
                }
            }
        }
        SkillsCommand::Check { eligible } => {
            let mut skills: Vec<_> = registry.all().collect();
            skills.sort_by_key(|s| s.name.as_str());
            if skills.is_empty() {
                println!("No skills installed.");
                return Ok(());
            }
            for s in skills {
                let runnable = s.tools.iter().all(|t| !t.command.is_empty());
                if eligible && !runnable {
                    continue;
                }
                if runnable {
                    println!("{} {}", green("ok"), s.name);
                } else {
                    println!("{} {} (missing command)", yellow("!!"), s.name);
                }
            }
        }
        SkillsCommand::Install { name } => {
            let client = skill::clawhub::ClawhubClient::new().with_language(language.clone());
            // Check if already installed before printing "Installing".
            let dir_name = name
                .rsplit_once('@')
                .map(|(_, s)| s)
                .unwrap_or(name.rsplit('/').next().unwrap_or(&name));
            let already = skill::clawhub::ClawhubClient::check_installed(&global_dir, dir_name);
            if already {
                print!("Checking '{}'... ", cyan(&name));
            } else {
                print!("Installing '{}'... ", cyan(&name));
            }
            // Resolve audited-allowlist slugs to their pinned URL first. The
            // desktop "recommended skills" list is built from the allowlist,
            // and some entries (e.g. Anthropic's `pptx`) live ONLY there — no
            // matching slug exists in the public registries, so
            // install_with_fallback would report "not found on clawhub,
            // skillhub, or iwencai". Mirror the plugins-install path: when the
            // bare name is an allowlist slug with a download url, install from
            // that audited url instead. A url/owner-repo/`@` spec the user
            // typed bypasses this and resolves as before.
            let install_spec: String = {
                let looks_like_plain_slug =
                    !name.contains('/') && !name.contains('@') && !name.starts_with("http");
                if looks_like_plain_slug {
                    if rsclaw_skill::allowlist::snapshot().counts().0 == 0 {
                        let _ = rsclaw_skill::allowlist::refresh().await; // lazy; fail-open
                    }
                    rsclaw_skill::allowlist::snapshot()
                        .lookup_skill(&name)
                        .filter(|e| !e.url.is_empty())
                        .map(|e| e.url)
                        .unwrap_or_else(|| name.clone())
                } else {
                    name.clone()
                }
            };
            let locked = match client.install_with_fallback(&install_spec, &global_dir).await {
                Ok(l) => l,
                Err(e) => {
                    // A failed install can leave an empty skill dir behind
                    // (the install paths create_dir_all before downloading).
                    // Remove it if it's empty so a retry / list stays clean.
                    let d = global_dir.join(dir_name);
                    let empty = std::fs::read_dir(&d)
                        .map(|mut it| it.next().is_none())
                        .unwrap_or(false);
                    if empty {
                        let _ = std::fs::remove_dir_all(&d);
                    }
                    println!("{}", red("failed"));
                    return Err(e);
                }
            };
            if already {
                println!(
                    "{}",
                    dim(&format!("already up to date (v{})", locked.version))
                );
            } else {
                println!(
                    "{}",
                    green(&format!(
                        "v{} -> {}",
                        locked.version,
                        locked.install_dir.display()
                    ))
                );
            }
        }
        SkillsCommand::Uninstall { name } => {
            let skill_dir = global_dir.join(&name);
            if !skill_dir.exists() {
                anyhow::bail!("skill '{name}' not found in {}", global_dir.display());
            }
            std::fs::remove_dir_all(&skill_dir)?;

            let mut lock = skill::clawhub::LockFile::read(&global_dir).unwrap_or_default();
            lock.skills.remove(&name);
            lock.write(&global_dir)?;

            println!("Uninstalled '{}'.", cyan(&name));
        }
        SkillsCommand::Search { query } => {
            let client = skill::clawhub::ClawhubClient::new().with_language(language.clone());
            match client.search_with_fallback(&query).await {
                Ok(results) => {
                    if results.is_empty() {
                        println!("No skills found matching '{}'.", query);
                    } else {
                        let has_stats = results.iter().any(|r| {
                            r.downloads.is_some() || r.installs.is_some() || r.stars.is_some()
                        });
                        if has_stats {
                            println!(
                                "{:<36} {:>10} {:>8}  {:<12}  {}",
                                bold("NAME"),
                                bold("INSTALLS"),
                                bold("STARS"),
                                bold("REGISTRY"),
                                bold("DESCRIPTION"),
                            );
                        } else {
                            println!(
                                "{:<36}  {:<12}  {}",
                                bold("NAME"),
                                bold("REGISTRY"),
                                bold("DESCRIPTION"),
                            );
                        }
                        for r in &results {
                            let desc = r.description.as_deref().unwrap_or("-");
                            let desc: String = if desc.chars().count() > 60 {
                                desc.chars().take(57).collect::<String>() + "..."
                            } else {
                                desc.to_string()
                            };
                            let reg = r.registry.as_str();
                            if has_stats {
                                let inst =
                                    r.installs.map(format_count).unwrap_or_else(|| "-".into());
                                let stars = r.stars.map(format_count).unwrap_or_else(|| "-".into());
                                println!(
                                    "{:<36} {:>10} {:>8}  {:<12}  {}",
                                    cyan(&r.slug),
                                    inst,
                                    stars,
                                    dim(reg),
                                    desc,
                                );
                            } else {
                                println!("{:<36}  {:<12}  {}", cyan(&r.slug), dim(reg), desc,);
                            }
                        }
                        println!();
                        println!("Install with: rsclaw skills install <name>");
                    }
                }
                Err(e) => {
                    eprintln!("Search failed: {e:#}");
                    std::process::exit(1);
                }
            }
        }
        SkillsCommand::Update { name } => {
            let client = skill::clawhub::ClawhubClient::new().with_language(language.clone());
            let lock = skill::clawhub::LockFile::read(&global_dir).unwrap_or_default();

            let slugs: Vec<String> = if let Some(name) = name {
                vec![name]
            } else {
                lock.skills.keys().cloned().collect()
            };

            if slugs.is_empty() {
                println!("No skills to update.");
                return Ok(());
            }

            for slug in &slugs {
                print!("Updating '{}'... ", cyan(slug));
                // Use install_with_fallback so re-resolution walks the full
                // chain (clawhub → skillhub → iwencai). Bare `install` only
                // hits clawhub and would mark every iwencai/skillhub skill
                // as a "failed" update.
                match client.install_with_fallback(slug, &global_dir).await {
                    Ok(locked) => println!("{}", green(&format!("v{}", locked.version))),
                    Err(e) => println!("{}", red(&format!("failed: {e:#}"))),
                }
            }
        }
    }
    Ok(())
}

/// Format a count with K/M suffixes for compact display.
fn format_count(n: u64) -> String {
    if n >= 1_000_000 {
        format!("{:.1}M", n as f64 / 1_000_000.0)
    } else if n >= 1_000 {
        format!("{:.1}K", n as f64 / 1_000.0)
    } else {
        n.to_string()
    }
}