pub mod catalog;
pub mod source;
pub use catalog::SkillCatalog;
pub use source::{FilesystemSkillSource, SkillSource, StaticSkillSource};
pub type SkillName = String;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct SkillMetadata {
pub id: SkillName,
#[serde(alias = "name")]
pub display_name: String,
pub description: String,
#[serde(default)]
pub origin: SkillOrigin,
#[serde(default)]
pub auto_activate: bool,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub requires_tools: Vec<String>,
#[serde(default)]
pub requires_capabilities: Vec<String>,
#[serde(default)]
pub requires_trust: bool,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize,
)]
#[serde(rename_all = "snake_case")]
pub enum SkillOrigin {
ProjectFilesystem,
UserFilesystem,
ClientProvided,
#[default]
BuiltIn,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct SkillLocation {
pub directory: std::path::PathBuf,
pub skill_file: std::path::PathBuf,
#[serde(default)]
pub resources_dir: Option<std::path::PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct SkillResourceEntry {
pub path: String,
pub description: String,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct LoadedSkill {
pub metadata: SkillMetadata,
pub location: Option<SkillLocation>,
pub body: String,
pub resources: Vec<SkillResourceEntry>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum DiagnosticLevel {
Info,
Warning,
Error,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SkillDiagnostic {
pub level: DiagnosticLevel,
pub message: String,
pub skill_name: Option<String>,
}
pub fn origin_precedence(origin: SkillOrigin) -> u8 {
match origin {
SkillOrigin::ProjectFilesystem => 4,
SkillOrigin::ClientProvided => 3,
SkillOrigin::UserFilesystem => 2,
SkillOrigin::BuiltIn => 1,
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ActivatedSkillRecord {
pub name: SkillName,
pub body: String,
pub resources: Vec<SkillResourceEntry>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct SessionSkillState {
pub active: Vec<ActivatedSkillRecord>,
}
impl SessionSkillState {
pub fn is_active(&self, name: &str) -> bool {
self.active.iter().any(|r| r.name == name)
}
pub fn activate(&mut self, record: ActivatedSkillRecord) {
if !self.is_active(&record.name) {
self.active.push(record);
}
}
pub fn deactivate(&mut self, name: &str) {
self.active.retain(|r| r.name != name);
}
pub fn active_names(&self) -> Vec<&str> {
self.active.iter().map(|r| r.name.as_str()).collect()
}
pub fn active_skill_instructions(&self) -> String {
let mut output = String::new();
for skill in &self.active {
if !output.is_empty() {
output.push_str("\n\n");
}
output.push_str(&render_skill_content(&skill.name, &skill.body));
}
output
}
}
pub(crate) fn render_skill_content(name: &str, body: &str) -> String {
format!(
"<skill_content name=\"{}\">\n{}\n</skill_content>",
name, body
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_skill_state_activation_is_idempotent() {
let mut state = SessionSkillState::default();
let record = ActivatedSkillRecord {
name: "test-skill".to_string(),
body: "body".to_string(),
resources: vec![],
};
state.activate(record.clone());
assert_eq!(state.active.len(), 1);
state.activate(record);
assert_eq!(
state.active.len(),
1,
"Duplicate activation should be idempotent"
);
}
#[test]
fn session_skill_state_deactivation() {
let mut state = SessionSkillState::default();
state.activate(ActivatedSkillRecord {
name: "skill-a".to_string(),
body: "body-a".to_string(),
resources: vec![],
});
state.activate(ActivatedSkillRecord {
name: "skill-b".to_string(),
body: "body-b".to_string(),
resources: vec![],
});
assert_eq!(state.active.len(), 2);
state.deactivate("skill-a");
assert_eq!(state.active.len(), 1);
assert!(!state.is_active("skill-a"));
assert!(state.is_active("skill-b"));
}
#[test]
fn session_skill_state_active_skill_instructions() {
let mut state = SessionSkillState::default();
state.activate(ActivatedSkillRecord {
name: "skill-a".to_string(),
body: "Do A".to_string(),
resources: vec![],
});
state.activate(ActivatedSkillRecord {
name: "skill-b".to_string(),
body: "Do B".to_string(),
resources: vec![],
});
let instructions = state.active_skill_instructions();
assert!(instructions.contains("<skill_content name=\"skill-a\">"));
assert!(instructions.contains("Do A"));
assert!(instructions.contains("<skill_content name=\"skill-b\">"));
assert!(instructions.contains("Do B"));
}
#[test]
fn skill_metadata_requires_trust_default() {
let yaml = r#"
id: test
name: Test
description: A test skill
"#;
let metadata: SkillMetadata = serde_yaml::from_str(yaml).unwrap();
assert!(!metadata.requires_trust);
}
#[test]
fn skill_metadata_requires_trust_parsed() {
let yaml = r#"
id: test
name: Test
description: A test skill
requires_trust: true
"#;
let metadata: SkillMetadata = serde_yaml::from_str(yaml).unwrap();
assert!(metadata.requires_trust);
}
}