Some checks failed
CodeQL Analysis / CodeQL Analysis (push) Has been cancelled
PR Hygiene / nudge-stale-prs (push) Has been cancelled
CI / Detect Change Scope (push) Has been cancelled
Docker / PR Docker Smoke (push) Has been cancelled
Docker / Build and Push Docker Image (push) Has been cancelled
Rust Package Security Audit / Security Audit (push) Has been cancelled
Rust Package Security Audit / License & Supply Chain (push) Has been cancelled
CI / Format & Lint (push) Has been cancelled
CI / Lint Strict Delta (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (Smoke) (push) Has been cancelled
CI / Docs-Only Fast Path (push) Has been cancelled
CI / Non-Rust Fast Path (push) Has been cancelled
CI / Docs Quality (push) Has been cancelled
CI / CI Required Gate (push) Has been cancelled
Stale / stale (push) Has been cancelled
Update Contributors NOTICE / Update NOTICE with new contributors (push) Has been cancelled
- 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
386 lines
13 KiB
Rust
386 lines
13 KiB
Rust
//! SkillForge — Skill auto-discovery, evaluation, and integration engine.
|
|
//!
|
|
//! Pipeline: Scout → Evaluate → Integrate
|
|
//! Discovers skills from external sources, scores them, and generates
|
|
//! ZeroClaw-compatible manifests for qualified candidates.
|
|
|
|
pub mod evaluate;
|
|
pub mod integrate;
|
|
pub mod scout;
|
|
|
|
use anyhow::Result;
|
|
use serde::{Deserialize, Serialize};
|
|
use tracing::{info, warn};
|
|
|
|
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<String>,
|
|
},
|
|
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<String>) -> 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::<String>());
|
|
}
|
|
}
|
|
|
|
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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Clone, Serialize, Deserialize)]
|
|
pub struct SkillForgeConfig {
|
|
#[serde(default)]
|
|
pub enabled: bool,
|
|
#[serde(default = "default_auto_integrate")]
|
|
pub auto_integrate: bool,
|
|
#[serde(default = "default_sources")]
|
|
pub sources: Vec<String>,
|
|
#[serde(default = "default_scan_interval")]
|
|
pub scan_interval_hours: u64,
|
|
#[serde(default = "default_min_score")]
|
|
pub min_score: f64,
|
|
/// Optional GitHub personal-access token for higher rate limits.
|
|
#[serde(default)]
|
|
pub github_token: Option<String>,
|
|
/// Directory where integrated skills are written.
|
|
#[serde(default = "default_output_dir")]
|
|
pub output_dir: String,
|
|
}
|
|
|
|
fn default_auto_integrate() -> bool {
|
|
true
|
|
}
|
|
fn default_sources() -> Vec<String> {
|
|
vec!["github".into(), "clawhub".into()]
|
|
}
|
|
fn default_scan_interval() -> u64 {
|
|
24
|
|
}
|
|
fn default_min_score() -> f64 {
|
|
0.7
|
|
}
|
|
fn default_output_dir() -> String {
|
|
"./skills".into()
|
|
}
|
|
|
|
impl Default for SkillForgeConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: false,
|
|
auto_integrate: default_auto_integrate(),
|
|
sources: default_sources(),
|
|
scan_interval_hours: default_scan_interval(),
|
|
min_score: default_min_score(),
|
|
github_token: None,
|
|
output_dir: default_output_dir(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Debug for SkillForgeConfig {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("SkillForgeConfig")
|
|
.field("enabled", &self.enabled)
|
|
.field("auto_integrate", &self.auto_integrate)
|
|
.field("sources", &self.sources)
|
|
.field("scan_interval_hours", &self.scan_interval_hours)
|
|
.field("min_score", &self.min_score)
|
|
.field("github_token", &self.github_token.as_ref().map(|_| "***"))
|
|
.field("output_dir", &self.output_dir)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ForgeReport — summary of a single pipeline run
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ForgeReport {
|
|
pub discovered: usize,
|
|
pub evaluated: usize,
|
|
pub auto_integrated: usize,
|
|
pub manual_review: usize,
|
|
pub skipped: usize,
|
|
pub results: Vec<EvalResult>,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SkillForge
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub struct SkillForge {
|
|
config: SkillForgeConfig,
|
|
evaluator: Evaluator,
|
|
integrator: Integrator,
|
|
}
|
|
|
|
impl SkillForge {
|
|
pub fn new(config: SkillForgeConfig) -> Self {
|
|
let evaluator = Evaluator::new(config.min_score);
|
|
let integrator = Integrator::new(config.output_dir.clone());
|
|
Self {
|
|
config,
|
|
evaluator,
|
|
integrator,
|
|
}
|
|
}
|
|
|
|
/// Run the full pipeline: Scout → Evaluate → Integrate.
|
|
pub async fn forge(&self) -> Result<ForgeReport> {
|
|
if !self.config.enabled {
|
|
warn!("SkillForge is disabled — skipping");
|
|
return Ok(ForgeReport {
|
|
discovered: 0,
|
|
evaluated: 0,
|
|
auto_integrated: 0,
|
|
manual_review: 0,
|
|
skipped: 0,
|
|
results: vec![],
|
|
});
|
|
}
|
|
|
|
// --- Scout ----------------------------------------------------------
|
|
let mut candidates: Vec<ScoutResult> = Vec::new();
|
|
|
|
for src in &self.config.sources {
|
|
let source: ScoutSource = src.parse().unwrap(); // Infallible
|
|
match source {
|
|
ScoutSource::GitHub => {
|
|
let scout = GitHubScout::new(self.config.github_token.clone());
|
|
match scout.discover().await {
|
|
Ok(mut found) => {
|
|
info!(count = found.len(), "GitHub scout returned candidates");
|
|
candidates.append(&mut found);
|
|
}
|
|
Err(e) => {
|
|
warn!(error = %e, "GitHub scout failed, continuing with other sources");
|
|
}
|
|
}
|
|
}
|
|
ScoutSource::ClawHub | ScoutSource::HuggingFace => {
|
|
info!(
|
|
source = src.as_str(),
|
|
"Source not yet implemented — skipping"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Deduplicate by URL
|
|
scout::dedup(&mut candidates);
|
|
let discovered = candidates.len();
|
|
info!(discovered, "Total unique candidates after dedup");
|
|
|
|
// --- Evaluate -------------------------------------------------------
|
|
let results: Vec<EvalResult> = candidates
|
|
.into_iter()
|
|
.map(|c| self.evaluator.evaluate(c))
|
|
.collect();
|
|
let evaluated = results.len();
|
|
|
|
// --- Integrate ------------------------------------------------------
|
|
let mut auto_integrated = 0usize;
|
|
let mut manual_review = 0usize;
|
|
let mut skipped = 0usize;
|
|
|
|
for res in &results {
|
|
match res.recommendation {
|
|
Recommendation::Auto => {
|
|
if self.config.auto_integrate {
|
|
match self.integrator.integrate(&res.candidate) {
|
|
Ok(_) => {
|
|
auto_integrated += 1;
|
|
}
|
|
Err(e) => {
|
|
warn!(
|
|
skill = res.candidate.name.as_str(),
|
|
error = %e,
|
|
"Integration failed for candidate, continuing"
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
// Count as would-be auto but not actually integrated
|
|
manual_review += 1;
|
|
}
|
|
}
|
|
Recommendation::Manual => {
|
|
manual_review += 1;
|
|
}
|
|
Recommendation::Skip => {
|
|
skipped += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
info!(
|
|
auto_integrated,
|
|
manual_review, skipped, "Forge pipeline complete"
|
|
);
|
|
|
|
Ok(ForgeReport {
|
|
discovered,
|
|
evaluated,
|
|
auto_integrated,
|
|
manual_review,
|
|
skipped,
|
|
results,
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn disabled_forge_returns_empty_report() {
|
|
let cfg = SkillForgeConfig {
|
|
enabled: false,
|
|
..Default::default()
|
|
};
|
|
let forge = SkillForge::new(cfg);
|
|
let report = forge.forge().await.unwrap();
|
|
assert_eq!(report.discovered, 0);
|
|
assert_eq!(report.auto_integrated, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn default_config_values() {
|
|
let cfg = SkillForgeConfig::default();
|
|
assert!(!cfg.enabled);
|
|
assert!(cfg.auto_integrate);
|
|
assert_eq!(cfg.scan_interval_hours, 24);
|
|
assert!((cfg.min_score - 0.7).abs() < f64::EPSILON);
|
|
assert_eq!(cfg.sources, vec!["github", "clawhub"]);
|
|
}
|
|
}
|