1use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct SkillsManifest {
10 pub metadata: ManifestMetadata,
11 #[serde(default)]
12 pub skills: Vec<SkillEntry>,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct ManifestMetadata {
18 pub version: String,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct SkillEntry {
24 pub id: String,
25 pub source: SkillSource,
26 pub version: String,
27 #[serde(default)]
28 pub groups: Vec<String>,
29 #[serde(default)]
30 pub editable: bool,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
35#[serde(tag = "type")]
36pub enum SkillSource {
37 #[serde(rename = "git")]
38 Git {
39 url: String,
40 #[serde(default)]
41 branch: Option<String>,
42 #[serde(default)]
43 tag: Option<String>,
44 #[serde(default)]
45 subdir: Option<PathBuf>,
46 },
47 #[serde(rename = "source")]
48 Source {
49 name: String,
50 skill: String,
51 #[serde(default)]
52 version: Option<String>,
53 },
54 #[serde(rename = "local")]
55 Local {
56 path: PathBuf,
57 #[serde(default)]
58 editable: bool,
59 },
60 #[serde(rename = "zip-url")]
61 ZipUrl {
62 base_url: String,
63 #[serde(default)]
64 version: Option<String>,
65 },
66}
67
68impl SkillsManifest {
69 pub fn load_from_file(path: &Path) -> Result<Self, ManifestError> {
71 if !path.exists() {
72 return Err(ManifestError::NotFound(path.to_path_buf()));
73 }
74
75 let content = std::fs::read_to_string(path).map_err(ManifestError::Io)?;
76
77 let manifest: SkillsManifest =
78 toml::from_str(&content).map_err(|e| ManifestError::Parse(e.to_string()))?;
79
80 Ok(manifest)
81 }
82
83 pub fn save_to_file(&self, path: &Path) -> Result<(), ManifestError> {
85 let content =
86 toml::to_string_pretty(self).map_err(|e| ManifestError::Serialize(e.to_string()))?;
87
88 std::fs::write(path, content).map_err(ManifestError::Io)?;
89
90 Ok(())
91 }
92
93 pub fn get_skills_for_groups(
95 &self,
96 exclude_groups: Option<&[String]>,
97 only_groups: Option<&[String]>,
98 ) -> Vec<&SkillEntry> {
99 self.skills
100 .iter()
101 .filter(|skill| {
102 if let Some(only) = only_groups {
104 if skill.groups.is_empty() && !only.is_empty() {
105 return false;
106 }
107 if !skill.groups.is_empty() {
108 return skill.groups.iter().any(|g| only.contains(g));
109 }
110 }
111
112 if let Some(exclude) = exclude_groups {
114 return !skill.groups.iter().any(|g| exclude.contains(g));
115 }
116
117 true
118 })
119 .collect()
120 }
121
122 pub fn get_all_skills(&self) -> Vec<&SkillEntry> {
124 self.skills.iter().collect()
125 }
126
127 pub fn add_skill(&mut self, skill: SkillEntry) {
129 self.skills.push(skill);
130 }
131
132 pub fn remove_skill(&mut self, skill_id: &str) -> bool {
134 if let Some(pos) = self.skills.iter().position(|s| s.id == skill_id) {
135 self.skills.remove(pos);
136 return true;
137 }
138 false
139 }
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct SkillProjectToml {
151 #[serde(default)]
153 pub metadata: Option<MetadataSection>,
154 #[serde(default)]
156 pub dependencies: Option<DependenciesSection>,
157 #[serde(default)]
159 #[serde(rename = "tool")]
160 pub tool: Option<ToolSection>,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct MetadataSection {
167 pub id: Option<String>,
169 pub version: Option<String>,
171 #[serde(default)]
173 pub description: Option<String>,
174 #[serde(default)]
176 pub author: Option<String>,
177 #[serde(default)]
179 pub download_url: Option<String>,
180 #[serde(default)]
182 pub name: Option<String>,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct DependenciesSection {
188 #[serde(flatten)]
190 pub dependencies: HashMap<String, DependencySpec>,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
195#[serde(untagged)]
196pub enum DependencySpec {
197 Version(String),
199 Inline {
201 source: DependencySource,
202 #[serde(flatten)]
203 source_specific: SourceSpecificFields,
204 #[serde(default)]
205 groups: Option<Vec<String>>,
206 #[serde(default)]
207 editable: Option<bool>,
208 },
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
213pub enum DependencySource {
214 #[serde(rename = "git")]
215 Git,
216 #[serde(rename = "local")]
217 Local,
218 #[serde(rename = "zip-url")]
219 ZipUrl,
220 #[serde(rename = "source")]
221 Source,
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct SourceSpecificFields {
227 #[serde(default)]
229 pub url: Option<String>,
230 #[serde(default)]
231 pub branch: Option<String>,
232 #[serde(default)]
234 pub path: Option<String>,
235 #[serde(default)]
237 pub name: Option<String>,
238 #[serde(default)]
239 pub skill: Option<String>,
240 #[serde(default)]
242 pub zip_url: Option<String>,
243 #[serde(default)]
245 pub version: Option<String>,
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct ToolSection {
251 #[serde(default)]
252 pub fastskill: Option<FastSkillToolConfig>,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct FastSkillToolConfig {
258 #[serde(default)]
260 pub skills_directory: Option<PathBuf>,
261 #[serde(default)]
263 pub embedding: Option<EmbeddingConfigToml>,
264 #[serde(default)]
266 pub repositories: Option<Vec<RepositoryDefinition>>,
267 #[serde(default)]
269 pub server: Option<HttpServerConfigToml>,
270 #[serde(default = "default_install_depth")]
272 pub install_depth: u32,
273 #[serde(default)]
275 pub skip_transitive: bool,
276 #[serde(default)]
278 pub eval: Option<EvalConfigToml>,
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct EvalConfigToml {
284 pub prompts: PathBuf,
286 #[serde(default)]
288 pub checks: Option<PathBuf>,
289 #[serde(default = "default_eval_timeout_seconds")]
291 pub timeout_seconds: u64,
292 #[serde(default = "default_trials_per_case")]
294 pub trials_per_case: u32,
295 #[serde(default)]
297 pub parallel: Option<u32>,
298 #[serde(default = "default_pass_threshold")]
300 pub pass_threshold: f64,
301 #[serde(default = "default_fail_on_missing_agent")]
303 pub fail_on_missing_agent: bool,
304}
305
306fn default_eval_timeout_seconds() -> u64 {
307 900
308}
309
310fn default_trials_per_case() -> u32 {
311 1
312}
313
314fn default_pass_threshold() -> f64 {
315 1.0
316}
317
318fn default_fail_on_missing_agent() -> bool {
319 true
320}
321
322fn default_install_depth() -> u32 {
323 5
324}
325
326#[derive(Debug, Clone, Serialize, Deserialize)]
328pub struct HttpServerConfigToml {
329 #[serde(default)]
331 pub allowed_origins: Vec<String>,
332 #[serde(default = "default_allowed_headers_toml")]
334 pub allowed_headers: Vec<String>,
335}
336
337fn default_allowed_headers_toml() -> Vec<String> {
338 vec!["Content-Type".to_string(), "Authorization".to_string()]
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize)]
343pub struct EmbeddingConfigToml {
344 pub openai_base_url: String,
345 pub embedding_model: String,
346 #[serde(default)]
347 pub index_path: Option<PathBuf>,
348}
349
350#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct RepositoryDefinition {
353 pub name: String,
355 pub r#type: RepositoryType,
357 pub priority: u32,
359 #[serde(flatten)]
361 pub connection: RepositoryConnection,
362 #[serde(default)]
364 pub auth: Option<AuthConfig>,
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize)]
369pub enum RepositoryType {
370 #[serde(rename = "http-registry")]
371 HttpRegistry,
372 #[serde(rename = "git-marketplace")]
373 GitMarketplace,
374 #[serde(rename = "zip-url")]
375 ZipUrl,
376 #[serde(rename = "local")]
377 Local,
378}
379
380#[derive(Debug, Clone, Serialize, Deserialize)]
382#[serde(untagged)]
383pub enum RepositoryConnection {
384 HttpRegistry {
385 index_url: String,
386 },
387 GitMarketplace {
388 url: String,
389 #[serde(default)]
390 branch: Option<String>,
391 },
392 ZipUrl {
393 zip_url: String,
394 },
395 Local {
396 path: String,
397 },
398}
399
400#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct AuthConfig {
403 pub r#type: AuthType,
404 #[serde(default)]
405 pub env_var: Option<String>,
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize)]
410pub enum AuthType {
411 #[serde(rename = "pat")]
412 Pat,
413}
414
415#[derive(Debug, Clone, PartialEq, Eq)]
417pub enum ProjectContext {
418 Project,
420 Skill,
422 Ambiguous,
424}
425
426#[derive(Debug, Clone)]
428pub struct FileResolutionResult {
429 pub path: PathBuf,
431 pub context: ProjectContext,
433 pub found: bool,
435}
436
437impl SkillProjectToml {
438 pub fn load_from_file(path: &Path) -> Result<Self, ManifestError> {
440 if !path.exists() {
441 return Err(ManifestError::NotFound(path.to_path_buf()));
442 }
443
444 let safe_path = path.canonicalize().map_err(ManifestError::Io)?;
446
447 let content = std::fs::read_to_string(&safe_path).map_err(ManifestError::Io)?;
448
449 let project: SkillProjectToml = toml::from_str(&content).map_err(|e| {
450 let error_msg = e.to_string();
452 let line_info = if let Some(line_start) = error_msg.find("line ") {
454 let after_line = &error_msg[line_start + 5..];
455 let line_end = after_line
456 .find(|c: char| !c.is_ascii_digit() && c != ',')
457 .unwrap_or(after_line.len());
458 if let Ok(line) = after_line[..line_end].parse::<usize>() {
459 format!("line {}", line)
460 } else {
461 String::new()
462 }
463 } else {
464 String::new()
465 };
466
467 if !line_info.is_empty() {
468 ManifestError::Parse(format!("TOML syntax error at {}: {}", line_info, error_msg))
469 } else {
470 ManifestError::Parse(format!("TOML syntax error: {}", error_msg))
471 }
472 })?;
473
474 Ok(project)
475 }
476
477 pub fn save_to_file(&self, path: &Path) -> Result<(), ManifestError> {
479 let content =
480 toml::to_string_pretty(self).map_err(|e| ManifestError::Serialize(e.to_string()))?;
481
482 std::fs::write(path, content).map_err(ManifestError::Io)?;
483
484 Ok(())
485 }
486
487 pub fn validate_for_context(&self, context: ProjectContext) -> Result<(), String> {
490 match context {
491 ProjectContext::Skill => {
492 if let Some(ref metadata) = self.metadata {
494 if metadata.id.as_ref().is_none_or(|id| id.is_empty()) {
495 return Err(
496 "Skill-level skill-project.toml (in directory with SKILL.md) requires [metadata].id field. \
497 Add 'id = \"your-skill-id\"' to the [metadata] section.".to_string()
498 );
499 }
500 if metadata.version.as_ref().is_none_or(|v| v.is_empty()) {
501 return Err(
502 "Skill-level skill-project.toml (in directory with SKILL.md) requires [metadata].version field. \
503 Add 'version = \"1.0.0\"' to the [metadata] section.".to_string()
504 );
505 }
506 } else {
507 return Err(
508 "Skill-level skill-project.toml (in directory with SKILL.md) requires [metadata] section with 'id' and 'version' fields. \
509 This file is used for skill author metadata.".to_string()
510 );
511 }
512 }
513 ProjectContext::Project => {
514 if self.dependencies.is_none() {
516 return Err(
517 "Project-level skill-project.toml (at project root) requires [dependencies] section. \
518 Add '[dependencies]' section to manage skill dependencies. \
519 Use 'fastskill add <skill-id>' to add skills.".to_string()
520 );
521 }
522
523 let has_skills_directory = self
525 .tool
526 .as_ref()
527 .and_then(|t| t.fastskill.as_ref())
528 .and_then(|f| f.skills_directory.as_ref())
529 .is_some();
530
531 if !has_skills_directory {
532 return Err(
533 "Project-level skill-project.toml requires [tool.fastskill] with skills_directory. \
534 Run 'fastskill init --skills-dir <path>' or add [tool.fastskill] with skills_directory = \"...\".".to_string()
535 );
536 }
537 }
538 ProjectContext::Ambiguous => {
539 return Err(
542 "Cannot determine context for skill-project.toml. \
543 The file location and content are ambiguous. \
544 For skill-level: ensure SKILL.md exists in the same directory and add [metadata] section with 'id' and 'version'. \
545 For project-level: ensure file is at project root and add [dependencies] section.".to_string()
546 );
547 }
548 }
549 Ok(())
550 }
551
552 pub fn to_skill_entries(&self) -> Result<Vec<SkillEntry>, String> {
555 let mut entries = Vec::new();
556
557 if let Some(ref deps_section) = self.dependencies {
558 for (skill_id, dep_spec) in &deps_section.dependencies {
559 let (source, version, groups, editable) = match dep_spec {
560 DependencySpec::Version(version_str) => {
561 (
563 SkillSource::Source {
564 name: "default".to_string(),
565 skill: skill_id.clone(),
566 version: Some(version_str.clone()),
567 },
568 Some(version_str.clone()),
569 Vec::new(),
570 false,
571 )
572 }
573 DependencySpec::Inline {
574 source,
575 source_specific,
576 groups,
577 editable,
578 } => {
579 let source = match source {
580 DependencySource::Git => {
581 let url = source_specific.url.clone().ok_or_else(|| {
582 format!("Git source requires 'url' field for {}", skill_id)
583 })?;
584 SkillSource::Git {
585 url,
586 branch: source_specific.branch.clone(),
587 tag: None,
588 subdir: None,
589 }
590 }
591 DependencySource::Local => {
592 let path = source_specific.path.clone().ok_or_else(|| {
593 format!("Local source requires 'path' field for {}", skill_id)
594 })?;
595 SkillSource::Local {
596 path: PathBuf::from(path),
597 editable: editable.unwrap_or(false),
598 }
599 }
600 DependencySource::ZipUrl => {
601 let zip_url = source_specific.zip_url.clone().ok_or_else(|| {
602 format!(
603 "ZipUrl source requires 'zip_url' field for {}",
604 skill_id
605 )
606 })?;
607 SkillSource::ZipUrl {
608 base_url: zip_url,
609 version: source_specific.version.clone(),
610 }
611 }
612 DependencySource::Source => {
613 let name = source_specific.name.clone().ok_or_else(|| {
614 format!("Source source requires 'name' field for {}", skill_id)
615 })?;
616 let skill = source_specific.skill.clone().ok_or_else(|| {
617 format!("Source source requires 'skill' field for {}", skill_id)
618 })?;
619 SkillSource::Source {
620 name,
621 skill,
622 version: source_specific.version.clone(),
623 }
624 }
625 };
626 (
627 source,
628 source_specific.version.clone(),
629 groups.clone().unwrap_or_default(),
630 editable.unwrap_or(false),
631 )
632 }
633 };
634
635 entries.push(SkillEntry {
636 id: skill_id.clone(),
637 source,
638 version: version.unwrap_or_else(|| "*".to_string()),
639 groups,
640 editable,
641 });
642 }
643 }
644
645 Ok(entries)
646 }
647}
648
649#[derive(Debug, thiserror::Error)]
651pub enum ManifestError {
652 #[error("Manifest file not found: {0}")]
653 NotFound(PathBuf),
654
655 #[error("IO error: {0}")]
656 Io(#[from] std::io::Error),
657
658 #[error("Parse error: {0}")]
659 Parse(String),
660
661 #[error("Serialize error: {0}")]
662 Serialize(String),
663}
664
665#[cfg(test)]
666#[allow(clippy::unwrap_used)]
667mod tests {
668 use super::*;
669 use std::path::PathBuf;
670
671 #[test]
672 fn test_manifest_parsing() {
673 let toml_content = r#"
674 [metadata]
675 version = "1.0.0"
676
677 [[skills]]
678 id = "web-scraper"
679 source = { type = "git", url = "https://github.com/org/repo.git", branch = "main" }
680 version = "*"
681
682 [[skills]]
683 id = "dev-tools"
684 source = { type = "git", url = "https://github.com/org/dev-tools.git" }
685 groups = ["dev"]
686 version = "*"
687
688 [[skills]]
689 id = "monitoring"
690 source = { type = "source", name = "team-tools", skill = "monitoring", version = "2.1.0" }
691 groups = ["prod"]
692 version = "2.1.0"
693 "#;
694
695 let manifest: SkillsManifest = toml::from_str(toml_content).unwrap();
696
697 assert_eq!(manifest.metadata.version, "1.0.0");
698 assert_eq!(manifest.skills.len(), 3);
699
700 let all_skills = manifest.get_all_skills();
702 assert_eq!(all_skills.len(), 3);
703
704 let without_dev = manifest.get_skills_for_groups(Some(&["dev".to_string()]), None);
706 assert_eq!(without_dev.len(), 2); let only_prod = manifest.get_skills_for_groups(None, Some(&["prod".to_string()]));
710 assert_eq!(only_prod.len(), 1); }
712
713 #[test]
714 fn test_skill_source_variants() {
715 let git_source = SkillSource::Git {
717 url: "https://github.com/org/repo.git".to_string(),
718 branch: Some("main".to_string()),
719 tag: None,
720 subdir: None,
721 };
722
723 let source_ref = SkillSource::Source {
725 name: "team-tools".to_string(),
726 skill: "monitoring".to_string(),
727 version: Some("2.1.0".to_string()),
728 };
729
730 let _local_source = SkillSource::Local {
732 path: PathBuf::from("./local-skills"),
733 editable: false,
734 };
735
736 let _zip_source = SkillSource::ZipUrl {
738 base_url: "https://skills.example.com/".to_string(),
739 version: None,
740 };
741
742 let git_toml = toml::to_string(&git_source).unwrap();
744 assert!(git_toml.contains("type = \"git\""));
745
746 let source_toml = toml::to_string(&source_ref).unwrap();
747 assert!(source_toml.contains("type = \"source\""));
748 }
749}