use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillsManifest {
pub metadata: ManifestMetadata,
#[serde(default)]
pub skills: Vec<SkillEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestMetadata {
pub version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillEntry {
pub id: String,
pub source: SkillSource,
pub version: String,
#[serde(default)]
pub groups: Vec<String>,
#[serde(default)]
pub editable: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type")]
pub enum SkillSource {
#[serde(rename = "git")]
Git {
url: String,
#[serde(default)]
branch: Option<String>,
#[serde(default)]
tag: Option<String>,
#[serde(default)]
subdir: Option<PathBuf>,
},
#[serde(rename = "source")]
Source {
name: String,
skill: String,
#[serde(default)]
version: Option<String>,
},
#[serde(rename = "local")]
Local {
path: PathBuf,
#[serde(default)]
editable: bool,
},
#[serde(rename = "zip-url")]
ZipUrl {
base_url: String,
#[serde(default)]
version: Option<String>,
},
}
impl SkillsManifest {
pub fn load_from_file(path: &Path) -> Result<Self, ManifestError> {
if !path.exists() {
return Err(ManifestError::NotFound(path.to_path_buf()));
}
let content = std::fs::read_to_string(path).map_err(ManifestError::Io)?;
let manifest: SkillsManifest =
toml::from_str(&content).map_err(|e| ManifestError::Parse(e.to_string()))?;
Ok(manifest)
}
pub fn save_to_file(&self, path: &Path) -> Result<(), ManifestError> {
let content =
toml::to_string_pretty(self).map_err(|e| ManifestError::Serialize(e.to_string()))?;
std::fs::write(path, content).map_err(ManifestError::Io)?;
Ok(())
}
pub fn get_skills_for_groups(
&self,
exclude_groups: Option<&[String]>,
only_groups: Option<&[String]>,
) -> Vec<&SkillEntry> {
self.skills
.iter()
.filter(|skill| {
if let Some(only) = only_groups {
if skill.groups.is_empty() && !only.is_empty() {
return false;
}
if !skill.groups.is_empty() {
return skill.groups.iter().any(|g| only.contains(g));
}
}
if let Some(exclude) = exclude_groups {
return !skill.groups.iter().any(|g| exclude.contains(g));
}
true
})
.collect()
}
pub fn get_all_skills(&self) -> Vec<&SkillEntry> {
self.skills.iter().collect()
}
pub fn add_skill(&mut self, skill: SkillEntry) {
self.skills.push(skill);
}
pub fn remove_skill(&mut self, skill_id: &str) -> bool {
if let Some(pos) = self.skills.iter().position(|s| s.id == skill_id) {
self.skills.remove(pos);
return true;
}
false
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillProjectToml {
#[serde(default)]
pub metadata: Option<MetadataSection>,
#[serde(default)]
pub dependencies: Option<DependenciesSection>,
#[serde(default)]
#[serde(rename = "tool")]
pub tool: Option<ToolSection>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetadataSection {
pub id: Option<String>,
pub version: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub author: Option<String>,
#[serde(default)]
pub download_url: Option<String>,
#[serde(default)]
pub name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DependenciesSection {
#[serde(flatten)]
pub dependencies: HashMap<String, DependencySpec>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum DependencySpec {
Version(String),
Inline {
source: DependencySource,
#[serde(flatten)]
source_specific: SourceSpecificFields,
#[serde(default)]
groups: Option<Vec<String>>,
#[serde(default)]
editable: Option<bool>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum DependencySource {
#[serde(rename = "git")]
Git,
#[serde(rename = "local")]
Local,
#[serde(rename = "zip-url")]
ZipUrl,
#[serde(rename = "source")]
Source,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SourceSpecificFields {
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub branch: Option<String>,
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub skill: Option<String>,
#[serde(default)]
pub zip_url: Option<String>,
#[serde(default)]
pub version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolSection {
#[serde(default)]
pub fastskill: Option<FastSkillToolConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FastSkillToolConfig {
#[serde(default)]
pub skills_directory: Option<PathBuf>,
#[serde(default)]
pub embedding: Option<EmbeddingConfigToml>,
#[serde(default)]
pub repositories: Option<Vec<RepositoryDefinition>>,
#[serde(default)]
pub server: Option<HttpServerConfigToml>,
#[serde(default = "default_install_depth")]
pub install_depth: u32,
#[serde(default)]
pub skip_transitive: bool,
#[serde(default)]
pub eval: Option<EvalConfigToml>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvalConfigToml {
pub prompts: PathBuf,
#[serde(default)]
pub checks: Option<PathBuf>,
#[serde(default = "default_eval_timeout_seconds")]
pub timeout_seconds: u64,
#[serde(default = "default_trials_per_case")]
pub trials_per_case: u32,
#[serde(default)]
pub parallel: Option<u32>,
#[serde(default = "default_pass_threshold")]
pub pass_threshold: f64,
#[serde(default = "default_fail_on_missing_agent")]
pub fail_on_missing_agent: bool,
}
fn default_eval_timeout_seconds() -> u64 {
900
}
fn default_trials_per_case() -> u32 {
1
}
fn default_pass_threshold() -> f64 {
1.0
}
fn default_fail_on_missing_agent() -> bool {
true
}
fn default_install_depth() -> u32 {
5
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HttpServerConfigToml {
#[serde(default)]
pub allowed_origins: Vec<String>,
#[serde(default = "default_allowed_headers_toml")]
pub allowed_headers: Vec<String>,
}
fn default_allowed_headers_toml() -> Vec<String> {
vec!["Content-Type".to_string(), "Authorization".to_string()]
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbeddingConfigToml {
pub openai_base_url: String,
pub embedding_model: String,
#[serde(default)]
pub index_path: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepositoryDefinition {
pub name: String,
pub r#type: RepositoryType,
pub priority: u32,
#[serde(flatten)]
pub connection: RepositoryConnection,
#[serde(default)]
pub auth: Option<AuthConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum RepositoryType {
#[serde(rename = "http-registry")]
HttpRegistry,
#[serde(rename = "git-marketplace")]
GitMarketplace,
#[serde(rename = "zip-url")]
ZipUrl,
#[serde(rename = "local")]
Local,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum RepositoryConnection {
HttpRegistry {
index_url: String,
},
GitMarketplace {
url: String,
#[serde(default)]
branch: Option<String>,
},
ZipUrl {
zip_url: String,
},
Local {
path: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthConfig {
pub r#type: AuthType,
#[serde(default)]
pub env_var: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AuthType {
#[serde(rename = "pat")]
Pat,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProjectContext {
Project,
Skill,
Ambiguous,
}
#[derive(Debug, Clone)]
pub struct FileResolutionResult {
pub path: PathBuf,
pub context: ProjectContext,
pub found: bool,
}
impl SkillProjectToml {
pub fn load_from_file(path: &Path) -> Result<Self, ManifestError> {
if !path.exists() {
return Err(ManifestError::NotFound(path.to_path_buf()));
}
let safe_path = path.canonicalize().map_err(ManifestError::Io)?;
let content = std::fs::read_to_string(&safe_path).map_err(ManifestError::Io)?;
let project: SkillProjectToml = toml::from_str(&content).map_err(|e| {
let error_msg = e.to_string();
let line_info = if let Some(line_start) = error_msg.find("line ") {
let after_line = &error_msg[line_start + 5..];
let line_end = after_line
.find(|c: char| !c.is_ascii_digit() && c != ',')
.unwrap_or(after_line.len());
if let Ok(line) = after_line[..line_end].parse::<usize>() {
format!("line {}", line)
} else {
String::new()
}
} else {
String::new()
};
if !line_info.is_empty() {
ManifestError::Parse(format!("TOML syntax error at {}: {}", line_info, error_msg))
} else {
ManifestError::Parse(format!("TOML syntax error: {}", error_msg))
}
})?;
Ok(project)
}
pub fn save_to_file(&self, path: &Path) -> Result<(), ManifestError> {
let content =
toml::to_string_pretty(self).map_err(|e| ManifestError::Serialize(e.to_string()))?;
std::fs::write(path, content).map_err(ManifestError::Io)?;
Ok(())
}
pub fn validate_for_context(&self, context: ProjectContext) -> Result<(), String> {
match context {
ProjectContext::Skill => {
if let Some(ref metadata) = self.metadata {
if metadata.id.as_ref().is_none_or(|id| id.is_empty()) {
return Err(
"Skill-level skill-project.toml (in directory with SKILL.md) requires [metadata].id field. \
Add 'id = \"your-skill-id\"' to the [metadata] section.".to_string()
);
}
if metadata.version.as_ref().is_none_or(|v| v.is_empty()) {
return Err(
"Skill-level skill-project.toml (in directory with SKILL.md) requires [metadata].version field. \
Add 'version = \"1.0.0\"' to the [metadata] section.".to_string()
);
}
} else {
return Err(
"Skill-level skill-project.toml (in directory with SKILL.md) requires [metadata] section with 'id' and 'version' fields. \
This file is used for skill author metadata.".to_string()
);
}
}
ProjectContext::Project => {
if self.dependencies.is_none() {
return Err(
"Project-level skill-project.toml (at project root) requires [dependencies] section. \
Add '[dependencies]' section to manage skill dependencies. \
Use 'fastskill add <skill-id>' to add skills.".to_string()
);
}
let has_skills_directory = self
.tool
.as_ref()
.and_then(|t| t.fastskill.as_ref())
.and_then(|f| f.skills_directory.as_ref())
.is_some();
if !has_skills_directory {
return Err(
"Project-level skill-project.toml requires [tool.fastskill] with skills_directory. \
Run 'fastskill init --skills-dir <path>' or add [tool.fastskill] with skills_directory = \"...\".".to_string()
);
}
}
ProjectContext::Ambiguous => {
return Err(
"Cannot determine context for skill-project.toml. \
The file location and content are ambiguous. \
For skill-level: ensure SKILL.md exists in the same directory and add [metadata] section with 'id' and 'version'. \
For project-level: ensure file is at project root and add [dependencies] section.".to_string()
);
}
}
Ok(())
}
pub fn to_skill_entries(&self) -> Result<Vec<SkillEntry>, String> {
let mut entries = Vec::new();
if let Some(ref deps_section) = self.dependencies {
for (skill_id, dep_spec) in &deps_section.dependencies {
let (source, version, groups, editable) = match dep_spec {
DependencySpec::Version(version_str) => {
(
SkillSource::Source {
name: "default".to_string(),
skill: skill_id.clone(),
version: Some(version_str.clone()),
},
Some(version_str.clone()),
Vec::new(),
false,
)
}
DependencySpec::Inline {
source,
source_specific,
groups,
editable,
} => {
let source = match source {
DependencySource::Git => {
let url = source_specific.url.clone().ok_or_else(|| {
format!("Git source requires 'url' field for {}", skill_id)
})?;
SkillSource::Git {
url,
branch: source_specific.branch.clone(),
tag: None,
subdir: None,
}
}
DependencySource::Local => {
let path = source_specific.path.clone().ok_or_else(|| {
format!("Local source requires 'path' field for {}", skill_id)
})?;
SkillSource::Local {
path: PathBuf::from(path),
editable: editable.unwrap_or(false),
}
}
DependencySource::ZipUrl => {
let zip_url = source_specific.zip_url.clone().ok_or_else(|| {
format!(
"ZipUrl source requires 'zip_url' field for {}",
skill_id
)
})?;
SkillSource::ZipUrl {
base_url: zip_url,
version: source_specific.version.clone(),
}
}
DependencySource::Source => {
let name = source_specific.name.clone().ok_or_else(|| {
format!("Source source requires 'name' field for {}", skill_id)
})?;
let skill = source_specific.skill.clone().ok_or_else(|| {
format!("Source source requires 'skill' field for {}", skill_id)
})?;
SkillSource::Source {
name,
skill,
version: source_specific.version.clone(),
}
}
};
(
source,
source_specific.version.clone(),
groups.clone().unwrap_or_default(),
editable.unwrap_or(false),
)
}
};
entries.push(SkillEntry {
id: skill_id.clone(),
source,
version: version.unwrap_or_else(|| "*".to_string()),
groups,
editable,
});
}
}
Ok(entries)
}
}
#[derive(Debug, thiserror::Error)]
pub enum ManifestError {
#[error("Manifest 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),
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_manifest_parsing() {
let toml_content = r#"
[metadata]
version = "1.0.0"
[[skills]]
id = "web-scraper"
source = { type = "git", url = "https://github.com/org/repo.git", branch = "main" }
version = "*"
[[skills]]
id = "dev-tools"
source = { type = "git", url = "https://github.com/org/dev-tools.git" }
groups = ["dev"]
version = "*"
[[skills]]
id = "monitoring"
source = { type = "source", name = "team-tools", skill = "monitoring", version = "2.1.0" }
groups = ["prod"]
version = "2.1.0"
"#;
let manifest: SkillsManifest = toml::from_str(toml_content).unwrap();
assert_eq!(manifest.metadata.version, "1.0.0");
assert_eq!(manifest.skills.len(), 3);
let all_skills = manifest.get_all_skills();
assert_eq!(all_skills.len(), 3);
let without_dev = manifest.get_skills_for_groups(Some(&["dev".to_string()]), None);
assert_eq!(without_dev.len(), 2);
let only_prod = manifest.get_skills_for_groups(None, Some(&["prod".to_string()]));
assert_eq!(only_prod.len(), 1); }
#[test]
fn test_skill_source_variants() {
let git_source = SkillSource::Git {
url: "https://github.com/org/repo.git".to_string(),
branch: Some("main".to_string()),
tag: None,
subdir: None,
};
let source_ref = SkillSource::Source {
name: "team-tools".to_string(),
skill: "monitoring".to_string(),
version: Some("2.1.0".to_string()),
};
let _local_source = SkillSource::Local {
path: PathBuf::from("./local-skills"),
editable: false,
};
let _zip_source = SkillSource::ZipUrl {
base_url: "https://skills.example.com/".to_string(),
version: None,
};
let git_toml = toml::to_string(&git_source).unwrap();
assert!(git_toml.contains("type = \"git\""));
let source_toml = toml::to_string(&source_ref).unwrap();
assert!(source_toml.contains("type = \"source\""));
}
}