From 8c59673eac9838457add8d537c14e51fa6d18c5b Mon Sep 17 00:00:00 2001 From: TopherMayor Date: Wed, 18 Feb 2026 03:37:45 +0000 Subject: [PATCH] feat(cli): add skillforge command for auto-discovery of skills - Add zeroclaw skillforge run --min-score 0.7 --dry-run - Add zeroclaw skillforge scout --query "ai agent" --limit 20 - Add zeroclaw skillforge status to show configuration - Wire SkillForgeConfig into main Config struct - Add new_with_query method to GitHubScout for custom searches --- src/config/schema.rs | 5 ++ src/lib.rs | 1 + src/main.rs | 10 ++++ src/onboard/wizard.rs | 2 + src/skillforge/mod.rs | 130 ++++++++++++++++++++++++++++++++++++++++ src/skillforge/scout.rs | 30 ++++++++++ 6 files changed, 178 insertions(+) diff --git a/src/config/schema.rs b/src/config/schema.rs index 74f5d34..a0d9946 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -92,6 +92,10 @@ pub struct Config { /// Hardware configuration (wizard-driven physical world setup). #[serde(default)] pub hardware: HardwareConfig, + + /// SkillForge auto-discovery configuration. + #[serde(default)] + pub skillforge: crate::skillforge::SkillForgeConfig, } // ── Delegate Agents ────────────────────────────────────────────── @@ -1645,6 +1649,7 @@ impl Default for Config { peripherals: PeripheralsConfig::default(), agents: HashMap::new(), hardware: HardwareConfig::default(), + skillforge: crate::skillforge::SkillForgeConfig::default(), } } } diff --git a/src/lib.rs b/src/lib.rs index 7f4ebb4..96acf26 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -61,6 +61,7 @@ pub mod rag; pub mod runtime; pub mod security; pub mod service; +pub mod skillforge; pub mod skills; pub mod tools; pub mod tunnel; diff --git a/src/main.rs b/src/main.rs index 56cd579..ecf1c0b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -209,6 +209,12 @@ enum Commands { skill_command: SkillCommands, }, + /// Auto-discover and integrate skills from GitHub/ClawHub + Skillforge { + #[command(subcommand)] + skillforge_command: skillforge::SkillforgeCommands, + }, + /// Migrate data from other agent runtimes Migrate { #[command(subcommand)] @@ -563,6 +569,10 @@ async fn main() -> Result<()> { skills::handle_command(skill_command, &config.workspace_dir) } + Commands::Skillforge { skillforge_command } => { + skillforge::handle_command(skillforge_command, &config).await + } + Commands::Migrate { migrate_command } => { migration::handle_command(migrate_command, &config).await } diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index 0422e45..78e349e 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -132,6 +132,7 @@ pub fn run_wizard() -> Result { peripherals: crate::config::PeripheralsConfig::default(), agents: std::collections::HashMap::new(), hardware: hardware_config, + skillforge: crate::skillforge::SkillForgeConfig::default(), }; println!( @@ -346,6 +347,7 @@ pub fn run_quick_setup( peripherals: crate::config::PeripheralsConfig::default(), agents: std::collections::HashMap::new(), hardware: crate::config::HardwareConfig::default(), + skillforge: crate::skillforge::SkillForgeConfig::default(), }; config.save()?; diff --git a/src/skillforge/mod.rs b/src/skillforge/mod.rs index 17c2336..80d21b9 100644 --- a/src/skillforge/mod.rs +++ b/src/skillforge/mod.rs @@ -16,6 +16,136 @@ use self::evaluate::{EvalResult, Evaluator, Recommendation}; use self::integrate::Integrator; use self::scout::{GitHubScout, Scout, ScoutResult, ScoutSource}; +use crate::config::Config; + +#[derive(Debug, Clone, clap::Subcommand)] +pub enum SkillforgeCommands { + Run { + #[arg(long, default_value = "0.7")] + min_score: f64, + #[arg(long)] + dry_run: bool, + #[arg(long)] + source: Option, + }, + Scout { + #[arg(long, default_value = "zeroclaw skill")] + query: String, + #[arg(long, default_value = "20")] + limit: usize, + }, + Status, +} + +pub async fn handle_command(command: SkillforgeCommands, config: &Config) -> Result<()> { + let _forge_config = config.skillforge.clone(); + + match command { + SkillforgeCommands::Run { min_score, dry_run, source } => { + run_forge(config, min_score, dry_run, source).await + } + SkillforgeCommands::Scout { query, limit } => { + run_scout(config, query, limit).await + } + SkillforgeCommands::Status => { + show_status(config) + } + } +} + +async fn run_forge(config: &Config, min_score: f64, dry_run: bool, source: Option) -> Result<()> { + println!("🔮 SkillForge Pipeline"); + println!(" Min Score: {}", min_score); + println!(" Dry Run: {}", dry_run); + println!(" Source: {}", source.as_deref().unwrap_or("all")); + println!(); + + let mut forge_config = config.skillforge.clone(); + forge_config.enabled = true; + forge_config.min_score = min_score; + forge_config.auto_integrate = !dry_run; + + if let Some(src) = source { + forge_config.sources = vec![src]; + } + + let forge = SkillForge::new(forge_config); + let report = forge.forge().await?; + + println!("📊 Results:"); + println!(" Discovered: {}", report.discovered); + println!(" Evaluated: {}", report.evaluated); + println!(" Auto-integrated: {}", report.auto_integrated); + println!(" Manual review: {}", report.manual_review); + println!(" Skipped: {}", report.skipped); + println!(); + + if !report.results.is_empty() { + println!("📋 Top Candidates:"); + for (i, res) in report.results.iter().take(10).enumerate() { + let status = match res.recommendation { + Recommendation::Auto => "✅ AUTO", + Recommendation::Manual => "⚠️ REVIEW", + Recommendation::Skip => "❌ SKIP", + }; + println!(" {}. {} [{:.2}] {}", i + 1, res.candidate.name, res.total_score, status); + println!(" {}", res.candidate.description.chars().take(60).collect::()); + } + } + + Ok(()) +} + +async fn run_scout(config: &Config, query: String, limit: usize) -> Result<()> { + println!("🔍 SkillForge Scout"); + println!(" Query: {}", query); + println!(" Limit: {}", limit); + println!(); + + let github_token = config.skillforge.github_token.clone(); + let scout = GitHubScout::new_with_query(github_token, query.clone()); + + let results = scout.discover().await?; + let limited: Vec<_> = results.into_iter().take(limit).collect(); + + println!("📋 Found {} candidates:", limited.len()); + for (i, candidate) in limited.iter().enumerate() { + println!(" {}. {}", i + 1, candidate.name); + println!(" URL: {}", candidate.url); + println!(" ⭐ {} stars", candidate.stars); + let desc: String = candidate.description.chars().take(60).collect(); + if !desc.is_empty() { + println!(" {}", desc); + } + } + + Ok(()) +} + +fn show_status(config: &Config) -> Result<()> { + println!("🔮 SkillForge Status"); + println!(); + println!("Configuration:"); + println!(" Enabled: {}", config.skillforge.enabled); + println!(" Auto-integrate: {}", config.skillforge.auto_integrate); + println!(" Sources: {:?}", config.skillforge.sources); + println!(" Scan interval: {}h", config.skillforge.scan_interval_hours); + println!(" Min score: {:.2}", config.skillforge.min_score); + println!(" Output directory: {}", config.skillforge.output_dir); + println!(" GitHub token: {}", if config.skillforge.github_token.is_some() { "configured" } else { "not set" }); + + let skills_dir = std::path::Path::new(&config.skillforge.output_dir); + if skills_dir.exists() { + let count = std::fs::read_dir(skills_dir) + .map(|entries| entries.filter_map(|e| e.ok()).count()) + .unwrap_or(0); + println!(); + println!("Integrated skills: {}", count); + } + + Ok(()) +} + // --------------------------------------------------------------------------- // Configuration // --------------------------------------------------------------------------- diff --git a/src/skillforge/scout.rs b/src/skillforge/scout.rs index 1ad8af4..93b8476 100644 --- a/src/skillforge/scout.rs +++ b/src/skillforge/scout.rs @@ -103,6 +103,36 @@ impl GitHubScout { } } + pub fn new_with_query(token: Option, query: String) -> Self { + use std::time::Duration; + + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::ACCEPT, + "application/vnd.github+json".parse().expect("valid header"), + ); + headers.insert( + reqwest::header::USER_AGENT, + "ZeroClaw-SkillForge/0.1".parse().expect("valid header"), + ); + if let Some(ref t) = token { + if let Ok(val) = format!("Bearer {t}").parse() { + headers.insert(reqwest::header::AUTHORIZATION, val); + } + } + + let client = reqwest::Client::builder() + .default_headers(headers) + .timeout(Duration::from_secs(30)) + .build() + .expect("failed to build reqwest client"); + + Self { + client, + queries: vec![query], + } + } + /// Parse the GitHub search/repositories JSON response. fn parse_items(body: &serde_json::Value) -> Vec { let items = match body.get("items").and_then(|v| v.as_array()) {