use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SourcesConfig {
#[serde(default)]
pub sources: Vec<SourceDefinition>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SourceDefinition {
pub name: String,
#[serde(default = "default_priority")]
pub priority: u32,
pub source: SourceConfig,
}
impl SourceDefinition {
pub fn supports_listing(&self) -> bool {
matches!(
&self.source,
SourceConfig::Git { .. } | SourceConfig::ZipUrl { .. } | SourceConfig::Local { .. }
)
}
}
fn default_priority() -> u32 {
0
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum SourceAuth {
#[serde(rename = "pat")]
Pat { env_var: String },
#[serde(rename = "ssh-key")]
SshKey { path: PathBuf },
#[serde(rename = "basic")]
Basic {
username: String,
password_env: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum SourceConfig {
#[serde(rename = "git")]
Git {
url: String,
#[serde(default)]
branch: Option<String>,
#[serde(default)]
tag: Option<String>,
#[serde(default)]
auth: Option<SourceAuth>,
},
#[serde(rename = "zip-url")]
ZipUrl {
base_url: String,
#[serde(default)]
auth: Option<SourceAuth>,
},
#[serde(rename = "local")]
Local { path: PathBuf },
}
#[derive(Debug, Clone)]
pub struct SkillInfo {
pub id: String,
pub name: String,
pub description: String,
pub version: Option<String>,
pub source_name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MarketplaceJson {
pub version: String,
pub skills: Vec<MarketplaceSkill>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MarketplaceSkill {
pub id: String,
pub name: String,
pub description: String,
pub version: String,
#[serde(default)]
pub author: Option<String>,
#[serde(default)]
pub download_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClaudeCodeMarketplaceJson {
pub name: String,
#[serde(default)]
pub owner: Option<ClaudeCodeOwner>,
#[serde(default)]
pub metadata: Option<ClaudeCodeMetadata>,
pub plugins: Vec<ClaudeCodePlugin>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClaudeCodeOwner {
pub name: String,
#[serde(default)]
pub email: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClaudeCodeMetadata {
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClaudeCodePlugin {
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub source: Option<String>,
#[serde(default)]
pub strict: Option<bool>,
pub skills: Vec<String>, }
#[derive(Debug, Clone)]
struct CachedMarketplace {
data: MarketplaceJson,
fetched_at: DateTime<Utc>,
ttl_seconds: u64,
}
impl CachedMarketplace {
fn is_expired(&self) -> bool {
let now = Utc::now();
let elapsed = (now - self.fetched_at).num_seconds() as u64;
elapsed > self.ttl_seconds
}
}
pub struct SourcesManager {
pub(crate) config_path: PathBuf,
sources: HashMap<String, SourceDefinition>,
marketplace_cache: Arc<RwLock<HashMap<String, CachedMarketplace>>>,
cache_ttl_seconds: u64,
}
impl SourcesManager {
pub fn new(config_path: PathBuf) -> Self {
Self {
config_path,
sources: HashMap::new(),
marketplace_cache: Arc::new(RwLock::new(HashMap::new())),
cache_ttl_seconds: 300, }
}
pub fn with_cache_ttl(config_path: PathBuf, cache_ttl_seconds: u64) -> Self {
Self {
config_path,
sources: HashMap::new(),
marketplace_cache: Arc::new(RwLock::new(HashMap::new())),
cache_ttl_seconds,
}
}
pub fn load(&mut self) -> Result<(), SourcesError> {
if !self.config_path.exists() {
let config = SourcesConfig {
sources: Vec::new(),
};
config.save_to_file(&self.config_path)?;
self.sources = HashMap::new();
return Ok(());
}
let config = SourcesConfig::load_from_file(&self.config_path)?;
let mut sorted_sources: Vec<SourceDefinition> = config.sources;
sorted_sources.sort_by_key(|s| s.priority);
self.sources = sorted_sources
.into_iter()
.map(|source| (source.name.clone(), source))
.collect();
Ok(())
}
pub fn save(&self) -> Result<(), SourcesError> {
let mut sources: Vec<SourceDefinition> = self.sources.values().cloned().collect();
sources.sort_by_key(|s| s.priority);
let config = SourcesConfig { sources };
config.save_to_file(&self.config_path)?;
Ok(())
}
pub fn add_source(&mut self, name: String, config: SourceConfig) -> Result<(), SourcesError> {
self.add_source_with_priority(name, config, 0)
}
pub fn add_source_with_priority(
&mut self,
name: String,
config: SourceConfig,
priority: u32,
) -> Result<(), SourcesError> {
if self.sources.contains_key(&name) {
return Err(SourcesError::AlreadyExists(name));
}
let definition = SourceDefinition {
name: name.clone(),
priority,
source: config,
};
self.sources.insert(name, definition);
Ok(())
}
pub fn remove_source(&mut self, name: &str) -> Result<(), SourcesError> {
if self.sources.remove(name).is_none() {
return Err(SourcesError::SourceNotFound(name.to_string()));
}
Ok(())
}
pub fn get_source(&self, name: &str) -> Option<&SourceDefinition> {
self.sources.get(name)
}
pub fn list_sources(&self) -> Vec<&SourceDefinition> {
let mut sources: Vec<&SourceDefinition> = self.sources.values().collect();
sources.sort_by_key(|s| s.priority);
sources
}
pub async fn clear_cache(&self) {
let mut cache = self.marketplace_cache.write().await;
cache.clear();
}
pub async fn get_available_skills(&self) -> Result<Vec<SkillInfo>, SourcesError> {
let mut all_skills = Vec::new();
let mut sources: Vec<(&String, &SourceDefinition)> = self.sources.iter().collect();
sources.sort_by_key(|(_, def)| def.priority);
for (source_name, source_def) in sources {
let skills = self.get_skills_from_source(source_name, source_def).await?;
all_skills.extend(skills);
}
Ok(all_skills)
}
pub async fn get_skills_from_source(
&self,
source_name: &str,
source_def: &SourceDefinition,
) -> Result<Vec<SkillInfo>, SourcesError> {
match &source_def.source {
SourceConfig::Git { url, branch, .. } => {
self.load_marketplace_from_url_with_branch(url, branch.as_deref(), source_name)
.await
}
SourceConfig::ZipUrl { base_url, .. } => {
self.load_marketplace_from_url_with_branch(base_url, None, source_name)
.await
}
SourceConfig::Local { path } => {
self.scan_local_source(path, source_name).await
}
}
}
async fn convert_claude_to_fastskill_format(
&self,
claude_marketplace: ClaudeCodeMarketplaceJson,
base_url: String,
_source_name: &str,
) -> Result<MarketplaceJson, SourcesError> {
let mut skills = Vec::new();
let owner_name = claude_marketplace.owner.as_ref().map(|o| o.name.clone());
let metadata_version = claude_marketplace
.metadata
.as_ref()
.and_then(|m| m.version.clone());
for plugin in claude_marketplace.plugins {
let plugin_source = plugin.source.as_deref().unwrap_or("./");
for skill_path in plugin.skills {
let resolved_path = if skill_path.starts_with("./") {
format!(
"{}{}",
plugin_source.trim_end_matches('/'),
&skill_path[1..]
)
} else if skill_path.starts_with('/') {
skill_path.trim_start_matches('/').to_string()
} else {
format!("{}/{}", plugin_source.trim_end_matches('/'), skill_path)
};
let skill_id = resolved_path
.trim_end_matches('/')
.split('/')
.next_back()
.unwrap_or(&resolved_path)
.to_string();
let description = plugin
.description
.clone()
.or_else(|| {
claude_marketplace
.metadata
.as_ref()
.and_then(|m| m.description.clone())
})
.unwrap_or_else(|| format!("Skill from {}", plugin.name));
let download_url = if base_url.contains("github.com")
&& !base_url.contains("raw.githubusercontent.com")
{
let repo_path = base_url
.trim_start_matches("https://github.com/")
.trim_start_matches("http://github.com/")
.trim_end_matches(".git")
.trim_end_matches('/');
Some(format!(
"https://github.com/{}/tree/main/{}",
repo_path, resolved_path
))
} else if !base_url.is_empty() {
let base = base_url.trim_end_matches('/');
Some(format!("{}/{}", base, resolved_path))
} else {
None
};
skills.push(MarketplaceSkill {
id: skill_id.clone(),
name: skill_id.clone(), description,
version: metadata_version
.clone()
.unwrap_or_else(|| "1.0.0".to_string()),
author: owner_name.clone(),
download_url,
});
}
}
Ok(MarketplaceJson {
version: "1.0".to_string(),
skills,
})
}
async fn try_fetch_marketplace(
&self,
url: &str,
base_repo_url: Option<&str>,
) -> Result<MarketplaceJson, SourcesError> {
let client = reqwest::Client::new();
let response = client.get(url).send().await.map_err(|e| {
SourcesError::Network(format!("Failed to fetch marketplace.json: {}", e))
})?;
if !response.status().is_success() {
return Err(SourcesError::Network(format!(
"Failed to fetch marketplace.json: HTTP {}",
response.status()
)));
}
let claude_marketplace: ClaudeCodeMarketplaceJson = response.json().await.map_err(|e| {
SourcesError::Parse(format!(
"Failed to parse Claude Code marketplace.json: {}",
e
))
})?;
let base_url = if let Some(repo_url) = base_repo_url {
repo_url.to_string()
} else if url.contains("raw.githubusercontent.com") {
let parts: Vec<&str> = url.split('/').collect();
if parts.len() >= 5 {
let owner = parts[3];
let repo = parts[4];
format!("https://github.com/{}/{}", owner, repo)
} else {
String::new()
}
} else {
if let Some(pos) = url.rfind('/') {
url[..pos].to_string()
} else {
url.to_string()
}
};
let marketplace = self
.convert_claude_to_fastskill_format(claude_marketplace, base_url, "")
.await?;
for skill in &marketplace.skills {
if skill.id.is_empty() || skill.name.is_empty() || skill.description.is_empty() {
return Err(SourcesError::Parse(
"Invalid marketplace.json: skills must have id, name, and description"
.to_string(),
));
}
}
Ok(marketplace)
}
async fn load_marketplace_from_url_with_branch(
&self,
base_url: &str,
branch: Option<&str>,
source_name: &str,
) -> Result<Vec<SkillInfo>, SourcesError> {
let branch_name = branch.unwrap_or("main");
let (claude_plugin_url, root_url) =
if base_url.contains("github.com") && !base_url.contains("raw.githubusercontent.com") {
let repo_path = base_url
.trim_start_matches("https://github.com/")
.trim_start_matches("http://github.com/")
.trim_end_matches(".git")
.trim_end_matches('/');
(
format!(
"https://raw.githubusercontent.com/{}/{}/.claude-plugin/marketplace.json",
repo_path, branch_name
),
format!(
"https://raw.githubusercontent.com/{}/{}/marketplace.json",
repo_path, branch_name
),
)
} else {
let base = if base_url.ends_with('/') {
base_url.to_string()
} else {
format!("{}/", base_url)
};
(
format!("{}.claude-plugin/marketplace.json", base),
format!("{}marketplace.json", base),
)
};
let cache_key = claude_plugin_url.clone();
{
let cache = self.marketplace_cache.read().await;
if let Some(cached) = cache.get(&cache_key) {
if !cached.is_expired() {
return Ok(cached
.data
.skills
.iter()
.map(|skill| SkillInfo {
id: skill.id.clone(),
name: skill.name.clone(),
description: skill.description.clone(),
version: Some(skill.version.clone()),
source_name: source_name.to_string(),
})
.collect());
}
}
if let Some(cached) = cache.get(&root_url) {
if !cached.is_expired() {
return Ok(cached
.data
.skills
.iter()
.map(|skill| SkillInfo {
id: skill.id.clone(),
name: skill.name.clone(),
description: skill.description.clone(),
version: Some(skill.version.clone()),
source_name: source_name.to_string(),
})
.collect());
}
}
}
let (marketplace, successful_url) = match self
.try_fetch_marketplace(&claude_plugin_url, Some(base_url))
.await
{
Ok(m) => {
tracing::debug!(
"Loaded marketplace.json from Claude Code standard location: {}",
claude_plugin_url
);
(m, claude_plugin_url.clone())
}
Err(e) => {
tracing::debug!(
"Claude Code location failed ({}), trying root location: {}",
e,
root_url
);
match self.try_fetch_marketplace(&root_url, Some(base_url)).await {
Ok(m) => {
tracing::debug!("Loaded marketplace.json from root location: {}", root_url);
(m, root_url.clone())
}
Err(e2) => {
return Err(SourcesError::Network(format!(
"Failed to fetch marketplace.json from both locations. Claude Code location (.claude-plugin/marketplace.json): {}. Root location (marketplace.json): {}",
e, e2
)));
}
}
}
};
{
let mut cache = self.marketplace_cache.write().await;
cache.insert(
successful_url.clone(),
CachedMarketplace {
data: marketplace.clone(),
fetched_at: Utc::now(),
ttl_seconds: self.cache_ttl_seconds,
},
);
}
Ok(marketplace
.skills
.iter()
.map(|skill| SkillInfo {
id: skill.id.clone(),
name: skill.name.clone(),
description: skill.description.clone(),
version: Some(skill.version.clone()),
source_name: source_name.to_string(),
})
.collect())
}
pub async fn get_marketplace_json(
&self,
source_name: &str,
) -> Result<MarketplaceJson, SourcesError> {
let source_def = self
.sources
.get(source_name)
.ok_or_else(|| SourcesError::SourceNotFound(source_name.to_string()))?;
let (base_url, branch) = match &source_def.source {
SourceConfig::Git { url, branch, .. } => {
(url.as_str(), branch.as_deref().unwrap_or("main"))
}
SourceConfig::ZipUrl { base_url, .. } => (base_url.as_str(), ""),
SourceConfig::Local { .. } => {
return Err(SourcesError::Network(
"Local sources do not support marketplace.json".to_string(),
));
}
};
let (claude_plugin_url, root_url) =
if base_url.contains("github.com") && !base_url.contains("raw.githubusercontent.com") {
let repo_path = base_url
.trim_start_matches("https://github.com/")
.trim_start_matches("http://github.com/")
.trim_end_matches(".git")
.trim_end_matches('/');
(
format!(
"https://raw.githubusercontent.com/{}/{}/.claude-plugin/marketplace.json",
repo_path, branch
),
format!(
"https://raw.githubusercontent.com/{}/{}/marketplace.json",
repo_path, branch
),
)
} else {
let base = if base_url.ends_with('/') {
base_url.to_string()
} else {
format!("{}/", base_url)
};
(
format!("{}.claude-plugin/marketplace.json", base),
format!("{}marketplace.json", base),
)
};
{
let cache = self.marketplace_cache.read().await;
if let Some(cached) = cache.get(&claude_plugin_url) {
if !cached.is_expired() {
return Ok(cached.data.clone());
}
}
if let Some(cached) = cache.get(&root_url) {
if !cached.is_expired() {
return Ok(cached.data.clone());
}
}
}
let (marketplace, successful_url) = match self
.try_fetch_marketplace(&claude_plugin_url, Some(base_url))
.await
{
Ok(m) => {
tracing::debug!(
"Loaded marketplace.json from Claude Code standard location: {}",
claude_plugin_url
);
(m, claude_plugin_url.clone())
}
Err(e) => {
tracing::debug!(
"Claude Code location failed ({}), trying root location: {}",
e,
root_url
);
match self.try_fetch_marketplace(&root_url, Some(base_url)).await {
Ok(m) => {
tracing::debug!("Loaded marketplace.json from root location: {}", root_url);
(m, root_url.clone())
}
Err(e2) => {
return Err(SourcesError::Network(format!(
"Failed to fetch marketplace.json from both locations. Claude Code location (.claude-plugin/marketplace.json): {}. Root location (marketplace.json): {}",
e, e2
)));
}
}
}
};
{
let mut cache = self.marketplace_cache.write().await;
cache.insert(
successful_url.clone(),
CachedMarketplace {
data: marketplace.clone(),
fetched_at: Utc::now(),
ttl_seconds: self.cache_ttl_seconds,
},
);
}
Ok(marketplace)
}
async fn scan_local_source(
&self,
path: &PathBuf,
source_name: &str,
) -> Result<Vec<SkillInfo>, SourcesError> {
use walkdir::WalkDir;
let resolved_path = if path.is_absolute() {
path.clone()
} else {
std::env::current_dir()
.map_err(SourcesError::Io)?
.join(path)
};
if !resolved_path.exists() {
return Err(SourcesError::NotFound(resolved_path));
}
if !resolved_path.is_dir() {
return Err(SourcesError::Io(std::io::Error::new(
std::io::ErrorKind::NotADirectory,
format!("Path is not a directory: {}", resolved_path.display()),
)));
}
let mut skills = Vec::new();
for entry in WalkDir::new(&resolved_path)
.into_iter()
.filter_map(|e| e.ok())
{
let entry_path = entry.path();
if entry_path.is_file()
&& entry_path.file_name() == Some(std::ffi::OsStr::new("SKILL.md"))
{
if let Some(skill_dir) = entry_path.parent() {
if let Ok(skill_info) =
self.extract_skill_info_from_path(skill_dir, source_name)
{
skills.push(skill_info);
}
}
}
}
Ok(skills)
}
fn extract_skill_info_from_path(
&self,
skill_path: &Path,
source_name: &str,
) -> Result<SkillInfo, SourcesError> {
use std::fs;
let skill_file = skill_path.join("SKILL.md");
if !skill_file.exists() {
return Err(SourcesError::NotFound(skill_file));
}
let content = fs::read_to_string(&skill_file).map_err(SourcesError::Io)?;
let (id, name, description, version) =
self.parse_skill_frontmatter(&content, skill_path)?;
Ok(SkillInfo {
id,
name,
description,
version: Some(version),
source_name: source_name.to_string(),
})
}
fn parse_skill_frontmatter(
&self,
content: &str,
skill_path: &Path,
) -> Result<(String, String, String, String), SourcesError> {
if !content.starts_with("---\n") {
let id = skill_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
return Ok((
id.clone(),
id.clone(),
"No description".to_string(),
"1.0.0".to_string(),
));
}
let end_marker = content[4..]
.find("---\n")
.ok_or_else(|| SourcesError::Parse("Invalid frontmatter format".to_string()))?;
let frontmatter = &content[4..end_marker + 4];
let mut id = None;
let mut name = None;
let mut description = None;
let mut version = None;
for line in frontmatter.lines() {
let line = line.trim();
if line.starts_with("id:") {
id = line
.split(':')
.nth(1)
.map(|s| s.trim().trim_matches('"').to_string());
} else if line.starts_with("name:") {
name = line
.split(':')
.nth(1)
.map(|s| s.trim().trim_matches('"').to_string());
} else if line.starts_with("description:") {
description = line
.split(':')
.nth(1)
.map(|s| s.trim().trim_matches('"').to_string());
} else if line.starts_with("version:") {
version = line
.split(':')
.nth(1)
.map(|s| s.trim().trim_matches('"').to_string());
}
}
let skill_id = id.unwrap_or_else(|| {
skill_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string()
});
Ok((
skill_id.clone(),
name.unwrap_or(skill_id.clone()),
description.unwrap_or_else(|| "No description".to_string()),
version.unwrap_or_else(|| "1.0.0".to_string()),
))
}
}
impl SourcesConfig {
pub fn load_from_file(path: &Path) -> Result<Self, SourcesError> {
if !path.exists() {
return Err(SourcesError::NotFound(path.to_path_buf()));
}
let content = std::fs::read_to_string(path).map_err(SourcesError::Io)?;
let config: SourcesConfig =
toml::from_str(&content).map_err(|e| SourcesError::Parse(e.to_string()))?;
Ok(config)
}
pub fn save_to_file(&self, path: &Path) -> Result<(), SourcesError> {
let content =
toml::to_string_pretty(self).map_err(|e| SourcesError::Serialize(e.to_string()))?;
std::fs::write(path, content).map_err(SourcesError::Io)?;
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
pub enum SourcesError {
#[error("Sources config file not found: {0}")]
NotFound(PathBuf),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Parse error: {0}")]
Parse(String),
#[error("Serialize error: {0}")]
Serialize(String),
#[error("Source already exists: {0}")]
AlreadyExists(String),
#[error("Source not found: {0}")]
SourceNotFound(String),
#[error("Network error: {0}")]
Network(String),
#[error("Git error: {0}")]
Git(String),
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use tempfile::NamedTempFile;
#[test]
fn test_sources_config_parsing() {
let toml_content = r#"
[[sources]]
name = "team-tools"
source = { type = "git", url = "https://github.com/org/team-plugins.git", branch = "main" }
[[sources]]
name = "official-skills"
source = { type = "zip-url", base_url = "https://skills.example.com/" }
[[sources]]
name = "local"
source = { type = "local", path = "./local-sources" }
"#;
let config: SourcesConfig = toml::from_str(toml_content).unwrap();
assert_eq!(config.sources.len(), 3);
assert_eq!(config.sources[0].name, "team-tools");
assert_eq!(config.sources[1].name, "official-skills");
assert_eq!(config.sources[2].name, "local");
}
#[test]
fn test_source_config_variants() {
let git_config = SourceConfig::Git {
url: "https://github.com/org/repo.git".to_string(),
branch: Some("main".to_string()),
tag: None,
auth: None,
};
let zip_config = SourceConfig::ZipUrl {
base_url: "https://skills.example.com/".to_string(),
auth: None,
};
let local_config = SourceConfig::Local {
path: PathBuf::from("./local-sources"),
};
let git_toml = toml::to_string(&git_config).unwrap();
assert!(git_toml.contains("type = \"git\""));
let zip_toml = toml::to_string(&zip_config).unwrap();
assert!(zip_toml.contains("type = \"zip-url\""));
let local_toml = toml::to_string(&local_config).unwrap();
assert!(local_toml.contains("type = \"local\""));
}
#[tokio::test]
async fn test_sources_manager() {
let temp_file = NamedTempFile::new().unwrap();
let config_path = temp_file.path().to_path_buf();
let mut manager = SourcesManager::new(config_path.clone());
manager.load().unwrap();
assert_eq!(manager.list_sources().len(), 0);
manager
.add_source(
"team-tools".to_string(),
SourceConfig::Git {
url: "https://github.com/org/team-plugins.git".to_string(),
branch: Some("main".to_string()),
tag: None,
auth: None,
},
)
.unwrap();
manager
.add_source(
"official".to_string(),
SourceConfig::ZipUrl {
base_url: "https://skills.example.com/".to_string(),
auth: None,
},
)
.unwrap();
assert_eq!(manager.list_sources().len(), 2);
manager.save().unwrap();
let mut new_manager = SourcesManager::new(config_path);
new_manager.load().unwrap();
assert_eq!(new_manager.list_sources().len(), 2);
}
}