From 34b1680a07b4fbdadc3bd29eaa88771e6fcc9767 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yuniel=20Acosta=20P=C3=A9rez?= <33158051+yacosta738@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:52:19 +0200 Subject: [PATCH 1/3] fix: harden catalog skill installation validation --- .github/workflows/catalog-e2e.yml | 53 ++++ README.md | 18 ++ src/commands/skill.rs | 42 +++ src/skills/catalog.rs | 44 ++- src/skills/catalog.v1.toml | 480 ++++------------------------- src/skills/provider.rs | 45 ++- src/skills/suggest.rs | 30 +- tests/integration/skill_suggest.rs | 3 + tests/test_catalog_integration.rs | 395 +++++++----------------- tests/unit/provider.rs | 30 ++ tests/unit/suggest_catalog.rs | 3 + tests/unit/suggest_install.rs | 97 ++++++ 12 files changed, 515 insertions(+), 725 deletions(-) create mode 100644 .github/workflows/catalog-e2e.yml diff --git a/.github/workflows/catalog-e2e.yml b/.github/workflows/catalog-e2e.yml new file mode 100644 index 00000000..fe3ff727 --- /dev/null +++ b/.github/workflows/catalog-e2e.yml @@ -0,0 +1,53 @@ +name: Catalog E2E + +on: + workflow_dispatch: + schedule: + - cron: "0 8 * * 1" + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + RUN_E2E: 1 + +jobs: + catalog-installation: + name: Verify catalog skill installation + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Checkout dallay skills repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + repository: dallay/agents-skills + path: vendor/agents-skills + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: stable + + - name: Cache cargo registry + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-catalog-e2e-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-catalog-e2e- + ${{ runner.os }}-cargo-test- + + - name: Run catalog installation E2E + env: + AGENTSYNC_LOCAL_SKILLS_REPO: ${{ github.workspace }}/vendor/agents-skills + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: cargo test --test test_catalog_integration -- --ignored --nocapture diff --git a/README.md b/README.md index 027743e9..a2bfa5e2 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![CI](https://github.com/dallay/agentsync/actions/workflows/ci.yml/badge.svg)](https://github.com/dallay/agentsync/actions/workflows/ci.yml) [![Release](https://github.com/dallay/agentsync/actions/workflows/release.yml/badge.svg)](https://github.com/dallay/agentsync/actions/workflows/release.yml) +[![Catalog E2E](https://github.com/dallay/agentsync/actions/workflows/catalog-e2e.yml/badge.svg)](https://github.com/dallay/agentsync/actions/workflows/catalog-e2e.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![GitHub release](https://img.shields.io/github/v/release/dallay/agentsync)](https://github.com/dallay/agentsync/releases) [![Codecov](https://codecov.io/gh/dallay/agentsync/graph/badge.svg)](https://codecov.io/gh/dallay/agentsync) @@ -50,6 +51,23 @@ locations. - ⚡ **Fast** - Single static binary, no runtime dependencies - 🧩 **Curated skills** - Install from the [dallay/agents-skills](https://github.com/dallay/agents-skills) collection or external providers +### Catalog validation + +AgentSync ships a full catalog installation E2E check that validates every skill entry can still be +resolved, installed, and registered correctly. + +- GitHub Actions workflow: `Catalog E2E` +- Manual run: Actions → **Catalog E2E** → **Run workflow** +- Scheduled run: every Monday at 08:00 UTC +- Local run: + +```bash +RUN_E2E=1 cargo test --test test_catalog_integration -- --ignored --nocapture +``` + +This check is intentionally separate from normal CI because it depends on external networks and +third-party skill repositories. + ## Installation ### Node.js Package Managers (Recommended) diff --git a/src/commands/skill.rs b/src/commands/skill.rs index 2d5ae19f..4c852949 100644 --- a/src/commands/skill.rs +++ b/src/commands/skill.rs @@ -1,4 +1,5 @@ use crate::commands::skill_fmt::{self, HumanFormatter, LabelKind, OutputMode}; +use agentsync::skills::catalog::EmbeddedSkillCatalog; use agentsync::skills::provider::{Provider, SkillsShProvider}; use agentsync::skills::registry; use agentsync::skills::suggest::{ @@ -924,6 +925,14 @@ impl Provider for SuggestInstallProvider { } fn resolve(&self, id: &str) -> Result { + let catalog = EmbeddedSkillCatalog::default(); + if let Some(install_source) = catalog.get_install_source(id) { + return Ok(agentsync::skills::provider::SkillInstallInfo { + download_url: install_source.to_string(), + format: infer_install_source_format(install_source), + }); + } + if let Ok(source_root) = std::env::var("AGENTSYNC_TEST_SKILL_SOURCE_DIR") { // The id may be a qualified provider_skill_id (e.g., "dallay/agents-skills/foo") // or a simple local name. Extract the last segment to find the local source directory. @@ -1032,6 +1041,27 @@ fn resolve_source(skill_id: &str, source_arg: Option) -> Result // If it doesn't look like a URL or a path, try to resolve via skills.sh if !skill_id.contains("://") && !skill_id.starts_with('/') && !skill_id.starts_with('.') { + let catalog = EmbeddedSkillCatalog::default(); + if let Some(definition) = catalog.get_skill_definition_by_local_id(skill_id) { + if let Some(install_source) = definition.install_source.as_deref() { + return Ok(install_source.to_string()); + } + + let provider = SkillsShProvider; + return provider + .resolve(&definition.provider_skill_id) + .map(|info| info.download_url) + .map_err(|e| { + tracing::warn!(skill_id = %skill_id, provider_skill_id = %definition.provider_skill_id, ?e, "Failed to resolve catalog skill via skills provider"); + anyhow::anyhow!( + "failed to resolve skill '{}' via provider '{}': {}", + skill_id, + definition.provider_skill_id, + e + ) + }); + } + let provider = SkillsShProvider; match provider.resolve(skill_id) { Ok(info) => Ok(info.download_url), @@ -1049,6 +1079,18 @@ fn resolve_source(skill_id: &str, source_arg: Option) -> Result } } +fn infer_install_source_format(source: &str) -> String { + if source.starts_with("http://") || source.starts_with("https://") { + if source.ends_with(".tar.gz") || source.ends_with(".tgz") { + return "tar.gz".to_string(); + } + + return "zip".to_string(); + } + + "dir".to_string() +} + /// Attempts to convert a GitHub URL to a downloadable ZIP URL. /// /// Supports the following GitHub URL formats: diff --git a/src/skills/catalog.rs b/src/skills/catalog.rs index 8cc18009..abc5e2fd 100644 --- a/src/skills/catalog.rs +++ b/src/skills/catalog.rs @@ -172,6 +172,9 @@ pub struct CatalogSkillDefinition { pub local_skill_id: String, pub title: String, pub summary: String, + pub archive_subpath: Option, + pub legacy_local_skill_ids: Vec, + pub install_source: Option, } #[derive(Debug, Clone, PartialEq)] @@ -221,6 +224,25 @@ impl ResolvedSkillCatalog { self.skill_definitions.get(provider_skill_id) } + pub fn get_archive_subpath(&self, provider_skill_id: &str) -> Option<&str> { + self.get_skill_definition(provider_skill_id) + .and_then(|definition| definition.archive_subpath.as_deref()) + } + + pub fn get_install_source(&self, provider_skill_id: &str) -> Option<&str> { + self.get_skill_definition(provider_skill_id) + .and_then(|definition| definition.install_source.as_deref()) + } + + pub fn get_skill_definition_by_local_id( + &self, + skill_id: &str, + ) -> Option<&CatalogSkillDefinition> { + self.skill_definitions + .values() + .find(|definition| definition.local_skill_id == skill_id) + } + pub fn get_technology(&self, technology: &TechnologyId) -> Option<&CatalogTechnologyEntry> { self.technologies.get(technology) } @@ -288,6 +310,12 @@ struct RawCatalogSkill { local_skill_id: String, title: String, summary: String, + #[serde(default)] + archive_subpath: Option, + #[serde(default)] + legacy_local_skill_ids: Vec, + #[serde(default)] + install_source: Option, } #[derive(Debug, Clone, Deserialize)] @@ -327,6 +355,9 @@ impl From for RawCatalogDocument { local_skill_id: skill.local_skill_id, title: skill.title, summary: skill.summary, + archive_subpath: skill.archive_subpath, + legacy_local_skill_ids: skill.legacy_local_skill_ids, + install_source: skill.install_source, }) .collect(), technologies: metadata @@ -458,7 +489,10 @@ pub fn recommend_skills( let suggestion = suggestions .entry(definition.local_skill_id.clone()) - .or_insert_with(|| SkillSuggestion::new(&metadata, catalog)); + .or_insert_with(|| { + SkillSuggestion::new(&metadata, catalog) + .with_legacy_local_skill_ids(&definition.legacy_local_skill_ids) + }); suggestion.add_match(detection, &entry.reason_template); } @@ -495,7 +529,10 @@ pub fn recommend_skills( let suggestion = suggestions .entry(definition.local_skill_id.clone()) - .or_insert_with(|| SkillSuggestion::new(&metadata, catalog)); + .or_insert_with(|| { + SkillSuggestion::new(&metadata, catalog) + .with_legacy_local_skill_ids(&definition.legacy_local_skill_ids) + }); let reason = combo .reason_template @@ -824,6 +861,9 @@ fn normalize_skill_definition(raw_skill: &RawCatalogSkill) -> Result Result; @@ -41,6 +43,12 @@ pub struct ProviderCatalogSkill { pub local_skill_id: String, pub title: String, pub summary: String, + #[serde(default)] + pub archive_subpath: Option, + #[serde(default)] + pub legacy_local_skill_ids: Vec, + #[serde(default)] + pub install_source: Option, } #[derive(Debug, Clone, Deserialize)] @@ -84,6 +92,14 @@ pub struct SkillsShProvider; /// Well-known repo names where skills live in a `skills/` subdirectory. const SKILLS_REPO_NAMES: &[&str] = &["skills", "agent-skills", "agentic-skills", "agents-skills"]; +fn repo_uses_skills_subdirectory(repo: &str) -> bool { + SKILLS_REPO_NAMES.contains(&repo) + || repo.ends_with("-skills") + || repo.ends_with("-agent-skills") + || repo.ends_with("-agentic-skills") + || repo.ends_with("-agents-skills") +} + impl SkillsShProvider { /// Resolve a catalog-style `owner/repo/skill-name` ID deterministically by /// constructing the GitHub download URL directly — no network call needed. @@ -105,16 +121,23 @@ impl SkillsShProvider { anyhow::bail!("invalid skill id (empty component): {}", id); } - // Construct the subpath fragment for the archive unpacker. - // For repos named "skills", "agent-skills", etc., the skill typically - // lives under a `skills/` directory inside the repo. - let subpath = if SKILLS_REPO_NAMES.contains(&repo) { - format!("skills/{}", skill_name) - } else { - skill_name.to_string() - }; - - let final_url = format!("https://github.com/{owner}/{repo}/archive/HEAD.zip#{subpath}"); + let embedded_catalog = EmbeddedSkillCatalog::default(); + let subpath = embedded_catalog + .get_archive_subpath(id) + .map(str::to_string) + .unwrap_or_else(|| { + if repo_uses_skills_subdirectory(repo) { + format!("skills/{skill_name}") + } else { + skill_name.to_string() + } + }); + + let mut final_url = format!("https://github.com/{owner}/{repo}/archive/HEAD.zip"); + if !subpath.is_empty() { + final_url.push('#'); + final_url.push_str(&subpath); + } Ok(SkillInstallInfo { download_url: final_url, @@ -158,7 +181,7 @@ impl SkillsShProvider { // If the repo name is a well-known skills repo, prefix 'skills/' let final_subpath = if !subpath.is_empty() && !subpath.starts_with("skills/") { let repo_name = skill.source.split('/').next_back().unwrap_or(""); - if SKILLS_REPO_NAMES.contains(&repo_name) { + if repo_uses_skills_subdirectory(repo_name) { format!("skills/{}", subpath) } else { subpath diff --git a/src/skills/suggest.rs b/src/skills/suggest.rs index bd443db8..0d092e68 100644 --- a/src/skills/suggest.rs +++ b/src/skills/suggest.rs @@ -94,6 +94,7 @@ pub struct SkillSuggestion { pub provider_skill_id: String, pub title: String, pub summary: String, + legacy_local_skill_ids: Vec, pub reasons: Vec, pub matched_technologies: Vec, pub installed: bool, @@ -108,6 +109,7 @@ impl SkillSuggestion { provider_skill_id: metadata.provider_skill_id.clone(), title: metadata.title.clone(), summary: metadata.summary.clone(), + legacy_local_skill_ids: Vec::new(), reasons: Vec::new(), matched_technologies: Vec::new(), installed: false, @@ -148,6 +150,11 @@ impl SkillSuggestion { let _ = combo_name; } + pub(crate) fn with_legacy_local_skill_ids(mut self, legacy_local_skill_ids: &[String]) -> Self { + self.legacy_local_skill_ids = legacy_local_skill_ids.to_vec(); + self + } + pub fn annotate_installed_state(&mut self, installed_skill: Option<&InstalledSkillState>) { if let Some(installed_skill) = installed_skill { self.installed = installed_skill.installed; @@ -159,6 +166,20 @@ impl SkillSuggestion { } } +fn installed_state_for_recommendation<'a>( + installed_skill_states: &'a BTreeMap, + recommendation: &SkillSuggestion, +) -> Option<&'a InstalledSkillState> { + std::iter::once(recommendation.skill_id.as_str()) + .chain( + recommendation + .legacy_local_skill_ids + .iter() + .map(std::string::String::as_str), + ) + .find_map(|skill_id| installed_skill_states.get(skill_id)) +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct SuggestSummary { pub detected_count: usize, @@ -307,8 +328,10 @@ impl SuggestionService { let mut recommendations = recommend_skills(catalog, &detections); for recommendation in &mut recommendations { - recommendation - .annotate_installed_state(installed_skill_states.get(&recommendation.skill_id)); + recommendation.annotate_installed_state(installed_state_for_recommendation( + &installed_skill_states, + recommendation, + )); } let summary = SuggestSummary { @@ -442,8 +465,7 @@ impl SuggestionService { .get(skill_id.as_str()) .expect("skill_id should be in recommendation_map - this is a bug"); - if installed_state - .get(&recommendation.skill_id) + if installed_state_for_recommendation(&installed_state, recommendation) .is_some_and(|state| state.installed) { reporter.on_event(SuggestInstallProgressEvent::SkippedAlreadyInstalled { diff --git a/tests/integration/skill_suggest.rs b/tests/integration/skill_suggest.rs index c850cf2e..ae868165 100644 --- a/tests/integration/skill_suggest.rs +++ b/tests/integration/skill_suggest.rs @@ -438,6 +438,9 @@ impl Provider for CanonicalOverlayProvider { local_skill_id: "custom-rust".to_string(), title: "Custom Rust".to_string(), summary: "Custom Rust guidance".to_string(), + archive_subpath: None, + legacy_local_skill_ids: Vec::new(), + install_source: None, }], technologies: vec![ProviderCatalogTechnology { id: "rust".to_string(), diff --git a/tests/test_catalog_integration.rs b/tests/test_catalog_integration.rs index 5abd1d86..1663e708 100644 --- a/tests/test_catalog_integration.rs +++ b/tests/test_catalog_integration.rs @@ -1,324 +1,157 @@ -//! Integration tests for skill catalog entries. +//! End-to-end catalog installation verification. //! -//! These tests verify that skills from the embedded catalog can be installed. -//! By default, only a subset is tested to keep CI fast. Set environment variables to run more: -//! -//! - `E2E_RUN_ALL_CATALOG_SKILLS=1` - Run all skills (slow, requires network) -//! - `E2E_CATALOG_SKILL_LIMIT=N` - Run only first N skills -//! -//! Skills are tested against the skills.sh provider (external provider). -//! Local dallay skills are tested by installing from the local `.agents/skills/` directory. - -use std::fs; +//! This suite is intentionally opt-in because it exercises every catalog entry, +//! including external providers that depend on network availability and third-party +//! repositories staying valid. + +use agentsync::skills::catalog::EmbeddedSkillCatalog; +use agentsync::skills::install::blocking_fetch_and_install_skill; +use agentsync::skills::provider::{Provider, SkillsShProvider}; +use agentsync::skills::registry::read_registry; use std::path::{Path, PathBuf}; -use std::process::{Command, Output}; +use std::thread; +use std::time::Duration; use tempfile::TempDir; -/// Represents a skill from the catalog to test. -#[derive(Debug, Clone)] -struct CatalogSkill { - /// The provider skill ID (e.g., "dallay/agents-skills/accessibility") - provider_skill_id: String, - /// The local skill ID (e.g., "accessibility") - local_skill_id: String, - /// Whether this skill is from dallay (local) or external - is_local: bool, -} +const DALLAY_SKILLS_PREFIX: &str = "dallay/agents-skills/"; -/// TOML structure for deserializing catalog entries -#[derive(Debug, serde::Deserialize)] -struct CatalogFile { - skills: Vec, +fn project_root() -> &'static Path { + Path::new(env!("CARGO_MANIFEST_DIR")) } -#[derive(Debug, serde::Deserialize)] -struct CatalogEntry { - provider_skill_id: String, - local_skill_id: String, -} - -/// Parse the catalog.v1.toml and extract skills to test. -/// Uses proper TOML deserialization. -fn extract_catalog_skills() -> Vec { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - - let catalog_path = if manifest_dir.join("src").exists() { - manifest_dir - .join("src") - .join("skills") - .join("catalog.v1.toml") - } else { - std::env::current_dir() - .unwrap_or_else(|_| PathBuf::from(".")) - .join("src") - .join("skills") - .join("catalog.v1.toml") - }; - - let content = fs::read_to_string(&catalog_path).expect("Failed to read catalog.v1.toml"); - - // Parse using toml - let catalog: CatalogFile = toml::from_str(&content).expect("Failed to parse catalog.v1.toml"); - - catalog - .skills - .into_iter() - .map(|entry| { - let is_local = entry.provider_skill_id.starts_with("dallay/"); - CatalogSkill { - provider_skill_id: entry.provider_skill_id, - local_skill_id: entry.local_skill_id, - is_local, - } - }) - .collect() -} - -/// Determine if we should run this test based on environment variables. -fn should_run_skill_test(skill_index: usize, _total_skills: usize) -> bool { - // Check if we should run all - if std::env::var("E2E_RUN_ALL_CATALOG_SKILLS").is_ok() { - return true; +fn local_skill_source_dir(local_skill_id: &str) -> PathBuf { + if let Ok(path) = std::env::var("AGENTSYNC_LOCAL_SKILLS_REPO") { + return PathBuf::from(path).join("skills").join(local_skill_id); } - // Check if we have a limit - if let Ok(limit) = std::env::var("E2E_CATALOG_SKILL_LIMIT") - && let Ok(n) = limit.parse::() + let sibling_repo = project_root().parent().map(|parent| { + parent + .join("agents-skills") + .join("skills") + .join(local_skill_id) + }); + if let Some(path) = sibling_repo + && path.exists() { - return skill_index < n; + return path; } - // Default: run only first 5 skills for quick sanity check - skill_index < 5 + project_root() + .join(".agents") + .join("skills") + .join(local_skill_id) } -/// Initialize a temporary project with agentsync. -/// Returns Ok(()) on success, or an error string on failure. -fn init_temp_project(root: &Path) -> Result<(), String> { - let output = Command::new("cargo") - .args(["run", "--", "init", "--path"]) - .arg(root) - .output() - .map_err(|e| format!("failed to execute init: {}", e))?; - - if output.status.success() { - Ok(()) - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - Err(format!("init failed: {}", stderr)) +fn resolve_install_source( + provider: &SkillsShProvider, + provider_skill_id: &str, + local_skill_id: &str, +) -> anyhow::Result { + let catalog = EmbeddedSkillCatalog::default(); + if let Some(install_source) = catalog.get_install_source(provider_skill_id) { + return Ok(install_source.to_string()); } -} -/// Install a skill in the given project. -/// Returns the command output, or an error string on failure. -fn install_skill(root: &Path, skill_id: &str, source: Option<&str>) -> Result { - let mut cmd = Command::new("cargo"); - cmd.args(["run", "--", "skill"]); - - if let Some(source_path) = source { - cmd.args(["install", skill_id, "--source", source_path]); - } else { - cmd.args(["install", skill_id]); + if provider_skill_id.starts_with(DALLAY_SKILLS_PREFIX) { + return Ok(local_skill_source_dir(local_skill_id) + .to_string_lossy() + .into_owned()); } - cmd.arg("--project-root").arg(root); - - cmd.output() - .map_err(|e| format!("failed to execute install: {}", e)) + Ok(provider.resolve(provider_skill_id)?.download_url) } -/// Verify a skill is installed in the given project. -fn verify_skill_installed(root: &Path, skill_id: &str) -> bool { - let skill_dir = root.join(".agents/skills").join(skill_id); - skill_dir.exists() && skill_dir.join("SKILL.md").exists() -} - -/// Test installation of a skill from the catalog. -/// This test is parameterized - can run many skills. -#[test] -fn test_install_skill_from_catalog() { - let skills = extract_catalog_skills(); - - // Filter skills to test based on environment - let skills_to_test: Vec<_> = skills - .iter() - .enumerate() - .filter(|(idx, _)| should_run_skill_test(*idx, skills.len())) - .collect(); - - println!( - "Testing {} out of {} skills from catalog", - skills_to_test.len(), - skills.len() - ); - - for (idx, (_, skill)) in skills_to_test.into_iter().enumerate() { - println!( - "[{}/{}] Testing skill: {} ({})", - idx + 1, - skills.len(), - skill.local_skill_id, - skill.provider_skill_id - ); - - let temp = TempDir::new().unwrap(); - let root = temp.path(); - - // 1. Init agentsync - if let Err(e) = init_temp_project(root) { - eprintln!(" ⚠️ Init failed: {}, skipping skill", e); - continue; - } - - // 2. Try to install the skill - let result = install_skill(root, &skill.local_skill_id, None); - - match result { - Ok(output) => { - if output.status.success() { - // 3. Verify installation - if verify_skill_installed(root, &skill.local_skill_id) { - println!(" ✅ Installed successfully"); - } else { - println!( - " ⚠️ Command succeeded but skill files not found at {:?}", - root.join(".agents/skills").join(&skill.local_skill_id) - ); - } - } else { - // Installation failed - could be network issue or skill doesn't exist - let stderr = String::from_utf8_lossy(&output.stderr); - println!( - " ⚠️ Install failed: {}", - if stderr.len() > 200 { - format!("{}...", &stderr[..200]) - } else { - stderr.to_string() - } - ); - } - } - Err(e) => { - println!(" ⚠️ Failed to run install command: {}", e); - } +fn install_with_retry(skill_id: &str, source: &str, target_root: &Path) -> anyhow::Result<()> { + match blocking_fetch_and_install_skill(skill_id, source, target_root) { + Ok(()) => Ok(()), + Err(first_error) => { + thread::sleep(Duration::from_secs(2)); + blocking_fetch_and_install_skill(skill_id, source, target_root).map_err( + |second_error| { + anyhow::anyhow!("first attempt: {first_error}; retry: {second_error}") + }, + ) } } - - // Test passes as long as we tried - actual verification happens via logs - println!("✅ Catalog skill installation test completed"); } -/// Test that local dallay skills can be installed from the local .agents/skills directory. -/// These are the skills that come bundled with agentsync itself. #[test] -fn test_install_local_dallay_skills() { - // Get the agentsync project root (where this test is running) - let project_root = Path::new(env!("CARGO_MANIFEST_DIR")); - - // Check if local skills exist - let local_skills_dir = project_root.join(".agents").join("skills"); - if !local_skills_dir.exists() { - println!( - "⚠️ Local skills directory not found at {:?}", - local_skills_dir - ); +#[ignore] +fn every_catalog_skill_installs_successfully() { + if std::env::var("RUN_E2E").is_err() { + eprintln!("Skipping catalog installation test (set RUN_E2E=1 to enable)"); return; } - // Read the catalog to find local dallay skills - let skills = extract_catalog_skills(); - let local_skills: Vec<_> = skills - .iter() - .filter(|s| s.is_local && !s.local_skill_id.is_empty()) - .collect(); - - println!("Testing {} local dallay skills", local_skills.len()); + let catalog = EmbeddedSkillCatalog::default(); + let provider = SkillsShProvider; + let mut failures = Vec::new(); + + for definition in catalog.skill_definitions() { + let temp = TempDir::new().expect("temp dir should be created"); + let target_root = temp.path().join(".agents").join("skills"); + std::fs::create_dir_all(&target_root).expect("target root should be created"); + + let source = match resolve_install_source( + &provider, + &definition.provider_skill_id, + &definition.local_skill_id, + ) { + Ok(source) => source, + Err(error) => { + failures.push(format!( + "{} [{}] failed to resolve source: {}", + definition.local_skill_id, definition.provider_skill_id, error + )); + continue; + } + }; - for skill in local_skills { - let source_dir = local_skills_dir.join(&skill.local_skill_id); - if !source_dir.exists() { - println!( - " ⚠️ Local skill not found: {} at {:?}", - skill.local_skill_id, source_dir - ); + if let Err(error) = install_with_retry(&definition.local_skill_id, &source, &target_root) { + failures.push(format!( + "{} [{}] failed to install from {}: {}", + definition.local_skill_id, definition.provider_skill_id, source, error + )); continue; } - println!(" Testing local skill: {}", skill.local_skill_id); - - // Create temp project and install from local source - let temp = TempDir::new().unwrap(); - let root = temp.path(); - - // Init - if let Err(e) = init_temp_project(root) { - println!(" ⚠️ Init failed for {}: {}", skill.local_skill_id, e); + let skill_dir = target_root.join(&definition.local_skill_id); + let manifest_path = skill_dir.join("SKILL.md"); + if !manifest_path.exists() { + failures.push(format!( + "{} [{}] installed without SKILL.md at {}", + definition.local_skill_id, + definition.provider_skill_id, + manifest_path.display() + )); continue; } - // Install from local path - fix the flag order here - let result = install_skill( - root, - &skill.local_skill_id, - Some(source_dir.to_str().unwrap()), - ); - - match result { - Ok(output) => { - if output.status.success() { - if verify_skill_installed(root, &skill.local_skill_id) { - println!(" ✅ Installed successfully"); - } else { - println!(" ⚠️ Install succeeded but files missing"); - } - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - println!( - " ⚠️ Install failed: {}", - if stderr.len() > 100 { - format!("{}...", &stderr[..100]) - } else { - stderr.to_string() - } - ); + let registry_path = target_root.join("registry.json"); + match read_registry(®istry_path) { + Ok(registry) => { + let has_entry = registry + .skills + .unwrap_or_default() + .contains_key(&definition.local_skill_id); + if !has_entry { + failures.push(format!( + "{} [{}] installed but registry.json is missing its canonical key", + definition.local_skill_id, definition.provider_skill_id + )); } } - Err(e) => { - println!(" ⚠️ Command error: {}", e); - } + Err(error) => failures.push(format!( + "{} [{}] installed but registry.json could not be read: {}", + definition.local_skill_id, definition.provider_skill_id, error + )), } } - println!("✅ Local dallay skills test completed"); -} - -/// Quick sanity check: verify catalog is readable and has expected structure. -#[test] -fn test_catalog_structure() { - let skills = extract_catalog_skills(); - - // Should have at least 100 skills in the catalog assert!( - skills.len() >= 100, - "Expected at least 100 skills in catalog, found {}", - skills.len() - ); - - // Should have local dallay skills - let local_count = skills.iter().filter(|s| s.is_local).count(); - assert!( - local_count > 0, - "Expected at least some local dallay skills" - ); - - // Should have external skills - let external_count = skills.iter().filter(|s| !s.is_local).count(); - assert!(external_count > 0, "Expected at least some external skills"); - - println!( - "✅ Catalog has {} skills ({} local, {} external)", - skills.len(), - local_count, - external_count + failures.is_empty(), + "{} catalog skills failed installation validation:\n- {}", + failures.len(), + failures.join("\n- ") ); } diff --git a/tests/unit/provider.rs b/tests/unit/provider.rs index f612ef8c..83e51b71 100644 --- a/tests/unit/provider.rs +++ b/tests/unit/provider.rs @@ -57,6 +57,36 @@ fn resolve_deterministic_non_skills_repo_omits_skills_prefix() { assert_eq!(info.format, "zip"); } +#[test] +fn resolve_deterministic_repo_suffix_skills_adds_skills_prefix() { + let provider = SkillsShProvider; + + let info = provider + .resolve("krutikJain/android-agent-skills/android-kotlin-core") + .unwrap(); + + assert_eq!( + info.download_url, + "https://github.com/krutikJain/android-agent-skills/archive/HEAD.zip#skills/android-kotlin-core" + ); + assert_eq!(info.format, "zip"); +} + +#[test] +fn resolve_deterministic_embedded_catalog_can_omit_fragment_for_repo_root_skill() { + let provider = SkillsShProvider; + + let info = provider + .resolve("currents-dev/playwright-best-practices-skill/playwright-best-practices") + .unwrap(); + + assert_eq!( + info.download_url, + "https://github.com/currents-dev/playwright-best-practices-skill/archive/HEAD.zip" + ); + assert_eq!(info.format, "zip"); +} + #[test] fn resolve_deterministic_skills_repo_adds_skills_prefix() { let provider = SkillsShProvider; diff --git a/tests/unit/suggest_catalog.rs b/tests/unit/suggest_catalog.rs index c1e40635..83cfb1ed 100644 --- a/tests/unit/suggest_catalog.rs +++ b/tests/unit/suggest_catalog.rs @@ -635,6 +635,9 @@ fn provider_skill(provider_skill_id: &str, local_skill_id: &str) -> ProviderCata local_skill_id: local_skill_id.to_string(), title: local_skill_id.to_string(), summary: format!("Summary for {local_skill_id}"), + archive_subpath: None, + legacy_local_skill_ids: Vec::new(), + install_source: None, } } diff --git a/tests/unit/suggest_install.rs b/tests/unit/suggest_install.rs index 51befef1..278546b0 100644 --- a/tests/unit/suggest_install.rs +++ b/tests/unit/suggest_install.rs @@ -200,6 +200,103 @@ fn install_flow_rechecks_registry_before_installing() { assert_eq!(install_response.results[0].error_message, None); } +#[test] +fn suggest_marks_canonical_vue_skill_as_installed_when_legacy_alias_exists() { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path(); + fs::write( + root.join("package.json"), + r#"{"dependencies":{"vue":"^3.4.0"}}"#, + ) + .unwrap(); + + let skills_dir = root.join(".agents/skills"); + fs::create_dir_all(&skills_dir).unwrap(); + fs::write( + skills_dir.join("registry.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "schemaVersion": 1, + "last_updated": "2026-04-06T00:00:00Z", + "skills": { + "antfu-vue": { + "name": "antfu-vue", + "version": "1.0.0" + } + } + })) + .unwrap(), + ) + .unwrap(); + + let response = SuggestionService + .suggest(root) + .expect("vue recommendation should be generated"); + + let vue = response + .recommendations + .iter() + .find(|recommendation| recommendation.provider_skill_id == "antfu/skills/vue") + .expect("antfu vue recommendation should exist"); + + assert_eq!(vue.skill_id, "vue"); + assert!(vue.installed); + assert_eq!(vue.installed_version.as_deref(), Some("1.0.0")); +} + +#[test] +fn install_flow_skips_canonical_vue_when_legacy_alias_is_installed() { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path(); + fs::write( + root.join("package.json"), + r#"{"dependencies":{"vue":"^3.4.0"}}"#, + ) + .unwrap(); + + let skills_dir = root.join(".agents/skills"); + fs::create_dir_all(&skills_dir).unwrap(); + fs::write( + skills_dir.join("registry.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "schemaVersion": 1, + "last_updated": "2026-04-06T00:00:00Z", + "skills": { + "antfu-vue": { + "name": "antfu-vue", + "version": "1.0.0" + } + } + })) + .unwrap(), + ) + .unwrap(); + + let provider = LocalSkillProvider::new(root, &[("antfu/skills/vue", "vue")]); + let response = SuggestionService + .suggest(root) + .expect("vue recommendation should be generated"); + + let install_response = SuggestionService + .install_selected_with( + root, + &response, + &provider, + SuggestInstallMode::InstallAll, + &["vue".to_string()], + |_skill_id, _source, _target_root| { + panic!("canonical vue install should be skipped when legacy alias exists") + }, + ) + .unwrap(); + + assert_eq!(install_response.results.len(), 1); + assert_eq!(install_response.results[0].skill_id, "vue"); + assert_eq!( + install_response.results[0].status, + SuggestInstallStatus::AlreadyInstalled + ); +} + #[test] fn install_flow_records_failures_and_continues() { let temp_dir = TempDir::new().unwrap(); From 1dcd0c392e56ea38040c6022061ccc227a4e78dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yuniel=20Acosta=20P=C3=A9rez?= <33158051+yacosta738@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:53:51 +0200 Subject: [PATCH 2/3] test: update catalog combo expectations --- tests/unit/suggest_catalog.rs | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/unit/suggest_catalog.rs b/tests/unit/suggest_catalog.rs index 83cfb1ed..f793c4a1 100644 --- a/tests/unit/suggest_catalog.rs +++ b/tests/unit/suggest_catalog.rs @@ -857,32 +857,32 @@ impl Provider for NoMatchCatalogProvider { fn combo_triggers_when_all_required_technologies_detected() { let catalog = EmbeddedSkillCatalog::default(); - // react-shadcn combo requires both "react" and "shadcn" + // nextjs-clerk combo requires both "nextjs" and "clerk" let detections = vec![ detection( - TechnologyId::new("react"), + TechnologyId::new("nextjs"), DetectionConfidence::High, "package.json", ), detection( - TechnologyId::new("shadcn"), + TechnologyId::new("clerk"), DetectionConfidence::High, - "components.json", + "package.json", ), ]; let recommendations = recommend_skills(&catalog, &detections); - // The react-shadcn combo should inject its skills into recommendations. + // The nextjs-clerk combo should inject its skills into recommendations. // At minimum, the combo's reason should appear. let has_combo_reason = recommendations.iter().any(|r| { r.reasons .iter() - .any(|reason| reason.contains("React + shadcn/ui")) + .any(|reason| reason.contains("Next.js + Clerk")) }); assert!( has_combo_reason, - "should have a combo-based recommendation mentioning 'React + shadcn/ui', got reasons: {:?}", + "should have a combo-based recommendation mentioning 'Next.js + Clerk', got reasons: {:?}", recommendations .iter() .flat_map(|r| r.reasons.iter()) @@ -894,9 +894,9 @@ fn combo_triggers_when_all_required_technologies_detected() { fn combo_does_not_trigger_with_partial_requirements() { let catalog = EmbeddedSkillCatalog::default(); - // Only "react" detected, no "shadcn" — the react-shadcn combo should NOT trigger + // Only "nextjs" detected, no "clerk" — the nextjs-clerk combo should NOT trigger let detections = vec![detection( - TechnologyId::new("react"), + TechnologyId::new("nextjs"), DetectionConfidence::High, "package.json", )]; @@ -906,7 +906,7 @@ fn combo_does_not_trigger_with_partial_requirements() { let has_combo_reason = recommendations.iter().any(|r| { r.reasons .iter() - .any(|reason| reason.contains("React + shadcn/ui")) + .any(|reason| reason.contains("Next.js + Clerk")) }); assert!( !has_combo_reason, @@ -943,8 +943,8 @@ fn expanded_catalog_has_minimum_expected_counts() { "expected at least 40 technologies, got {technology_count}" ); assert!( - combo_count >= 10, - "expected at least 10 combos, got {combo_count}" + combo_count >= 7, + "expected at least 7 combos, got {combo_count}" ); } From 0211efe99b96dd6bb8a6686c13eab592a2ec2693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yuniel=20Acosta=20P=C3=A9rez?= <33158051+yacosta738@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:26:25 +0200 Subject: [PATCH 3/3] fix: tighten catalog install source resolution --- .github/workflows/catalog-e2e.yml | 8 ++++- README.md | 5 +++ src/commands/skill.rs | 55 +++++++++++++++++++------------ src/skills/catalog.rs | 17 +++++++--- src/skills/catalog.v1.toml | 4 +-- src/skills/provider.rs | 49 +++++++++++++++++++++++++++ tests/test_catalog_integration.rs | 50 +++++++--------------------- 7 files changed, 122 insertions(+), 66 deletions(-) diff --git a/.github/workflows/catalog-e2e.yml b/.github/workflows/catalog-e2e.yml index fe3ff727..add569e3 100644 --- a/.github/workflows/catalog-e2e.yml +++ b/.github/workflows/catalog-e2e.yml @@ -50,4 +50,10 @@ jobs: env: AGENTSYNC_LOCAL_SKILLS_REPO: ${{ github.workspace }}/vendor/agents-skills GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: cargo test --test test_catalog_integration -- --ignored --nocapture + run: | + if [ ! -d "$AGENTSYNC_LOCAL_SKILLS_REPO" ]; then + echo "Expected vendored skills repository at '$AGENTSYNC_LOCAL_SKILLS_REPO', but the directory does not exist." + exit 1 + fi + + cargo test --test test_catalog_integration -- --ignored --nocapture diff --git a/README.md b/README.md index a2bfa5e2..4d325b2e 100644 --- a/README.md +++ b/README.md @@ -62,9 +62,14 @@ resolved, installed, and registered correctly. - Local run: ```bash +git clone https://github.com/dallay/agents-skills ../agents-skills +export AGENTSYNC_LOCAL_SKILLS_REPO="$(pwd)/../agents-skills" RUN_E2E=1 cargo test --test test_catalog_integration -- --ignored --nocapture ``` +If you already keep `agents-skills` as a sibling checkout next to this repository, you can skip the +environment variable and let the test auto-discover that sibling path instead. + This check is intentionally separate from normal CI because it depends on external networks and third-party skill repositories. diff --git a/src/commands/skill.rs b/src/commands/skill.rs index 4c852949..f9e44282 100644 --- a/src/commands/skill.rs +++ b/src/commands/skill.rs @@ -1,6 +1,6 @@ use crate::commands::skill_fmt::{self, HumanFormatter, LabelKind, OutputMode}; use agentsync::skills::catalog::EmbeddedSkillCatalog; -use agentsync::skills::provider::{Provider, SkillsShProvider}; +use agentsync::skills::provider::{Provider, SkillsShProvider, resolve_catalog_install_source}; use agentsync::skills::registry; use agentsync::skills::suggest::{ SuggestInstallJsonResponse, SuggestInstallMode, SuggestInstallPhase, @@ -926,10 +926,19 @@ impl Provider for SuggestInstallProvider { fn resolve(&self, id: &str) -> Result { let catalog = EmbeddedSkillCatalog::default(); - if let Some(install_source) = catalog.get_install_source(id) { + if let Some(definition) = catalog.get_skill_definition(id) { + let download_url = resolve_catalog_install_source( + &catalog, + &self.fallback, + &definition.provider_skill_id, + &definition.local_skill_id, + None, + )?; + return Ok(agentsync::skills::provider::SkillInstallInfo { - download_url: install_source.to_string(), - format: infer_install_source_format(install_source), + download_url: download_url.clone(), + // Informational only today: install pipeline infers behavior from the source string. + format: infer_install_source_format(&download_url), }); } @@ -1043,23 +1052,23 @@ fn resolve_source(skill_id: &str, source_arg: Option) -> Result if !skill_id.contains("://") && !skill_id.starts_with('/') && !skill_id.starts_with('.') { let catalog = EmbeddedSkillCatalog::default(); if let Some(definition) = catalog.get_skill_definition_by_local_id(skill_id) { - if let Some(install_source) = definition.install_source.as_deref() { - return Ok(install_source.to_string()); - } - let provider = SkillsShProvider; - return provider - .resolve(&definition.provider_skill_id) - .map(|info| info.download_url) - .map_err(|e| { - tracing::warn!(skill_id = %skill_id, provider_skill_id = %definition.provider_skill_id, ?e, "Failed to resolve catalog skill via skills provider"); - anyhow::anyhow!( - "failed to resolve skill '{}' via provider '{}': {}", - skill_id, - definition.provider_skill_id, - e - ) - }); + return resolve_catalog_install_source( + &catalog, + &provider, + &definition.provider_skill_id, + &definition.local_skill_id, + None, + ) + .map_err(|e| { + tracing::warn!(skill_id = %skill_id, provider_skill_id = %definition.provider_skill_id, ?e, "Failed to resolve catalog skill via skills provider"); + anyhow::anyhow!( + "failed to resolve skill '{}' via provider '{}': {}", + skill_id, + definition.provider_skill_id, + e + ) + }); } let provider = SkillsShProvider; @@ -1085,7 +1094,11 @@ fn infer_install_source_format(source: &str) -> String { return "tar.gz".to_string(); } - return "zip".to_string(); + if source.ends_with(".zip") { + return "zip".to_string(); + } + + return "url".to_string(); } "dir".to_string() diff --git a/src/skills/catalog.rs b/src/skills/catalog.rs index abc5e2fd..e12a7c5e 100644 --- a/src/skills/catalog.rs +++ b/src/skills/catalog.rs @@ -117,7 +117,7 @@ const APPROVED_EMBEDDED_EXTERNAL_SKILL_IDS: &[&str] = &[ "mindrally/skills/deno-typescript", "mongodb/agent-skills", "neondatabase/agent-skills/neon-postgres", - "nodnarbnitram/claude-code-extensions/tauri-v2", + "delexw/claude-code-misc/tauri-v2", "nrwl/nx-ai-agents-config", "openai/skills", "openai/skills/cloudflare-deploy", @@ -202,6 +202,7 @@ pub struct ResolvedSkillCatalog { source_name: String, metadata_version: String, skill_definitions: BTreeMap, + local_to_provider: BTreeMap, local_skills: BTreeMap, technologies: BTreeMap, combos: BTreeMap, @@ -238,9 +239,9 @@ impl ResolvedSkillCatalog { &self, skill_id: &str, ) -> Option<&CatalogSkillDefinition> { - self.skill_definitions - .values() - .find(|definition| definition.local_skill_id == skill_id) + self.local_to_provider + .get(skill_id) + .and_then(|provider_skill_id| self.skill_definitions.get(provider_skill_id)) } pub fn get_technology(&self, technology: &TechnologyId) -> Option<&CatalogTechnologyEntry> { @@ -625,6 +626,7 @@ fn normalize_catalog( source_name: source_name.to_string(), metadata_version: metadata_version.to_string(), skill_definitions, + local_to_provider: BTreeMap::new(), local_skills: BTreeMap::new(), technologies, combos, @@ -960,6 +962,7 @@ fn normalize_skill_references( fn rebuild_local_skill_index(catalog: &mut ResolvedSkillCatalog) -> Result<()> { let mut local_skills = BTreeMap::new(); + let mut local_to_provider = BTreeMap::new(); for definition in catalog.skill_definitions.values() { if local_skills.contains_key(&definition.local_skill_id) { @@ -978,8 +981,14 @@ fn rebuild_local_skill_index(catalog: &mut ResolvedSkillCatalog) -> Result<()> { summary: definition.summary.clone(), }, ); + + local_to_provider.insert( + definition.local_skill_id.clone(), + definition.provider_skill_id.clone(), + ); } + catalog.local_to_provider = local_to_provider; catalog.local_skills = local_skills; Ok(()) } diff --git a/src/skills/catalog.v1.toml b/src/skills/catalog.v1.toml index 7dae8896..d1d5b4be 100644 --- a/src/skills/catalog.v1.toml +++ b/src/skills/catalog.v1.toml @@ -805,7 +805,7 @@ summary = "Build SwiftUI applications with expert-level patterns." # --- Tauri --- [[skills]] -provider_skill_id = "nodnarbnitram/claude-code-extensions/tauri-v2" +provider_skill_id = "delexw/claude-code-misc/tauri-v2" local_skill_id = "tauri-v2" title = "Tauri v2" summary = "Build desktop applications with Tauri v2." @@ -1824,7 +1824,7 @@ packages = ["@nestjs/core"] [[technologies]] id = "tauri" name = "Tauri" -skills = ["nodnarbnitram/claude-code-extensions/tauri-v2"] +skills = ["delexw/claude-code-misc/tauri-v2"] min_confidence = "medium" [technologies.detect] diff --git a/src/skills/provider.rs b/src/skills/provider.rs index 1e0669b3..00e7bdc8 100644 --- a/src/skills/provider.rs +++ b/src/skills/provider.rs @@ -1,5 +1,6 @@ use anyhow::Result; use serde::Deserialize; +use std::path::{Path, PathBuf}; use crate::skills::catalog::EmbeddedSkillCatalog; @@ -89,6 +90,8 @@ struct SearchSkill { pub struct SkillsShProvider; +pub const DALLAY_AGENTS_SKILLS_PREFIX: &str = "dallay/agents-skills/"; + /// Well-known repo names where skills live in a `skills/` subdirectory. const SKILLS_REPO_NAMES: &[&str] = &["skills", "agent-skills", "agentic-skills", "agents-skills"]; @@ -100,6 +103,52 @@ fn repo_uses_skills_subdirectory(repo: &str) -> bool { || repo.ends_with("-agents-skills") } +fn local_catalog_skill_source_dir( + local_skill_id: &str, + project_root: Option<&Path>, +) -> Option { + if let Ok(path) = std::env::var("AGENTSYNC_TEST_SKILL_SOURCE_DIR") { + let candidate = PathBuf::from(path).join(local_skill_id); + if candidate.exists() { + return Some(candidate); + } + } + + if let Ok(path) = std::env::var("AGENTSYNC_LOCAL_SKILLS_REPO") { + return Some(PathBuf::from(path).join("skills").join(local_skill_id)); + } + + project_root + .and_then(Path::parent) + .map(|parent| { + parent + .join("agents-skills") + .join("skills") + .join(local_skill_id) + }) + .filter(|path| path.exists()) +} + +pub fn resolve_catalog_install_source( + catalog: &EmbeddedSkillCatalog, + provider: &dyn Provider, + provider_skill_id: &str, + local_skill_id: &str, + project_root: Option<&Path>, +) -> Result { + if let Some(install_source) = catalog.get_install_source(provider_skill_id) { + return Ok(install_source.to_string()); + } + + if provider_skill_id.starts_with(DALLAY_AGENTS_SKILLS_PREFIX) + && let Some(path) = local_catalog_skill_source_dir(local_skill_id, project_root) + { + return Ok(path.to_string_lossy().into_owned()); + } + + Ok(provider.resolve(provider_skill_id)?.download_url) +} + impl SkillsShProvider { /// Resolve a catalog-style `owner/repo/skill-name` ID deterministically by /// constructing the GitHub download URL directly — no network call needed. diff --git a/tests/test_catalog_integration.rs b/tests/test_catalog_integration.rs index 1663e708..c58e22fc 100644 --- a/tests/test_catalog_integration.rs +++ b/tests/test_catalog_integration.rs @@ -6,65 +6,39 @@ use agentsync::skills::catalog::EmbeddedSkillCatalog; use agentsync::skills::install::blocking_fetch_and_install_skill; -use agentsync::skills::provider::{Provider, SkillsShProvider}; +use agentsync::skills::provider::{SkillsShProvider, resolve_catalog_install_source}; use agentsync::skills::registry::read_registry; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::thread; use std::time::Duration; use tempfile::TempDir; -const DALLAY_SKILLS_PREFIX: &str = "dallay/agents-skills/"; - fn project_root() -> &'static Path { Path::new(env!("CARGO_MANIFEST_DIR")) } -fn local_skill_source_dir(local_skill_id: &str) -> PathBuf { - if let Ok(path) = std::env::var("AGENTSYNC_LOCAL_SKILLS_REPO") { - return PathBuf::from(path).join("skills").join(local_skill_id); - } - - let sibling_repo = project_root().parent().map(|parent| { - parent - .join("agents-skills") - .join("skills") - .join(local_skill_id) - }); - if let Some(path) = sibling_repo - && path.exists() - { - return path; - } - - project_root() - .join(".agents") - .join("skills") - .join(local_skill_id) -} - fn resolve_install_source( provider: &SkillsShProvider, provider_skill_id: &str, local_skill_id: &str, ) -> anyhow::Result { let catalog = EmbeddedSkillCatalog::default(); - if let Some(install_source) = catalog.get_install_source(provider_skill_id) { - return Ok(install_source.to_string()); - } - - if provider_skill_id.starts_with(DALLAY_SKILLS_PREFIX) { - return Ok(local_skill_source_dir(local_skill_id) - .to_string_lossy() - .into_owned()); - } - - Ok(provider.resolve(provider_skill_id)?.download_url) + resolve_catalog_install_source( + &catalog, + provider, + provider_skill_id, + local_skill_id, + Some(project_root()), + ) } fn install_with_retry(skill_id: &str, source: &str, target_root: &Path) -> anyhow::Result<()> { match blocking_fetch_and_install_skill(skill_id, source, target_root) { Ok(()) => Ok(()), Err(first_error) => { + eprintln!( + "Initial install attempt failed for {skill_id} from {source}: {first_error}. Retrying once..." + ); thread::sleep(Duration::from_secs(2)); blocking_fetch_and_install_skill(skill_id, source, target_root).map_err( |second_error| {