Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .github/workflows/catalog-e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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: |
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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -50,6 +51,28 @@ 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
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.

## Installation

### Node.js Package Managers (Recommended)
Expand Down
57 changes: 56 additions & 1 deletion src/commands/skill.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::commands::skill_fmt::{self, HumanFormatter, LabelKind, OutputMode};
use agentsync::skills::provider::{Provider, SkillsShProvider};
use agentsync::skills::catalog::EmbeddedSkillCatalog;
use agentsync::skills::provider::{Provider, SkillsShProvider, resolve_catalog_install_source};
use agentsync::skills::registry;
use agentsync::skills::suggest::{
SuggestInstallJsonResponse, SuggestInstallMode, SuggestInstallPhase,
Expand Down Expand Up @@ -924,6 +925,23 @@ impl Provider for SuggestInstallProvider {
}

fn resolve(&self, id: &str) -> Result<agentsync::skills::provider::SkillInstallInfo> {
let catalog = EmbeddedSkillCatalog::default();
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: download_url.clone(),
// Informational only today: install pipeline infers behavior from the source string.
format: infer_install_source_format(&download_url),
});
}

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.
Expand Down Expand Up @@ -1032,6 +1050,27 @@ fn resolve_source(skill_id: &str, source_arg: Option<String>) -> Result<String>

// 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) {
let provider = SkillsShProvider;
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;
match provider.resolve(skill_id) {
Ok(info) => Ok(info.download_url),
Expand All @@ -1049,6 +1088,22 @@ fn resolve_source(skill_id: &str, source_arg: Option<String>) -> Result<String>
}
}

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();
}

if source.ends_with(".zip") {
return "zip".to_string();
}

return "url".to_string();
}

"dir".to_string()
}

/// Attempts to convert a GitHub URL to a downloadable ZIP URL.
///
/// Supports the following GitHub URL formats:
Expand Down
55 changes: 52 additions & 3 deletions src/skills/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -172,6 +172,9 @@ pub struct CatalogSkillDefinition {
pub local_skill_id: String,
pub title: String,
pub summary: String,
pub archive_subpath: Option<String>,
pub legacy_local_skill_ids: Vec<String>,
pub install_source: Option<String>,
}

#[derive(Debug, Clone, PartialEq)]
Expand Down Expand Up @@ -199,6 +202,7 @@ pub struct ResolvedSkillCatalog {
source_name: String,
metadata_version: String,
skill_definitions: BTreeMap<String, CatalogSkillDefinition>,
local_to_provider: BTreeMap<String, String>,
local_skills: BTreeMap<String, CatalogSkillMetadata>,
technologies: BTreeMap<TechnologyId, CatalogTechnologyEntry>,
combos: BTreeMap<String, CatalogComboEntry>,
Expand All @@ -221,6 +225,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.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> {
self.technologies.get(technology)
}
Expand Down Expand Up @@ -288,6 +311,12 @@ struct RawCatalogSkill {
local_skill_id: String,
title: String,
summary: String,
#[serde(default)]
archive_subpath: Option<String>,
#[serde(default)]
legacy_local_skill_ids: Vec<String>,
#[serde(default)]
install_source: Option<String>,
}

#[derive(Debug, Clone, Deserialize)]
Expand Down Expand Up @@ -327,6 +356,9 @@ impl From<ProviderCatalogMetadata> 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
Expand Down Expand Up @@ -458,7 +490,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);
}
Expand Down Expand Up @@ -495,7 +530,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
Expand Down Expand Up @@ -588,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,
Expand Down Expand Up @@ -824,6 +863,9 @@ fn normalize_skill_definition(raw_skill: &RawCatalogSkill) -> Result<CatalogSkil
local_skill_id: local_skill_id.to_string(),
title: title.to_string(),
summary: summary.to_string(),
archive_subpath: raw_skill.archive_subpath.clone(),
legacy_local_skill_ids: raw_skill.legacy_local_skill_ids.clone(),
install_source: raw_skill.install_source.clone(),
})
}

Expand Down Expand Up @@ -920,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) {
Expand All @@ -938,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(())
}
Expand Down
Loading
Loading