use serde::{Deserialize, Serialize};
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum SkillSpecContext {
#[default]
Inline,
Fork,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SkillArgumentSpec {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub required: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SkillSpec {
pub id: String,
pub name: String,
pub description: String,
pub instructions_md: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_tools: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub when_to_use: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub arguments: Vec<SkillArgumentSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub argument_hint: Option<String>,
#[serde(default = "default_true")]
pub user_invocable: bool,
#[serde(default = "default_true")]
pub model_invocable: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_override: Option<String>,
#[serde(default)]
pub context: SkillSpecContext,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub paths: Vec<String>,
}
fn default_true() -> bool {
true
}
impl Default for SkillSpec {
fn default() -> Self {
Self {
id: String::new(),
name: String::new(),
description: String::new(),
instructions_md: String::new(),
allowed_tools: Vec::new(),
when_to_use: None,
arguments: Vec::new(),
argument_hint: None,
user_invocable: true,
model_invocable: true,
model_override: None,
context: SkillSpecContext::Inline,
paths: Vec::new(),
}
}
}
pub trait PreparedSkillSpecs: Send {
fn commit(self: Box<Self>);
}
pub trait SkillSpecSink: Send + Sync {
fn prepare_skill_specs(
&self,
specs: Vec<SkillSpec>,
) -> Result<Box<dyn PreparedSkillSpecs>, String>;
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn round_trip_preserves_fields() {
let spec = SkillSpec {
id: "db-management".into(),
name: "Database Management".into(),
description: "Helps with database operations".into(),
instructions_md: "Inspect schema before writing queries.".into(),
allowed_tools: vec!["db_query".into()],
when_to_use: Some("When the user asks about a database".into()),
arguments: vec![SkillArgumentSpec {
name: "dialect".into(),
description: Some("SQL dialect".into()),
required: false,
}],
argument_hint: Some("dialect=postgres".into()),
user_invocable: true,
model_invocable: false,
model_override: Some("fast".into()),
context: SkillSpecContext::Fork,
paths: vec!["migrations/**".into()],
};
let value = serde_json::to_value(&spec).unwrap();
let back: SkillSpec = serde_json::from_value(value).unwrap();
assert_eq!(spec, back);
}
#[test]
fn serde_defaults_match_runtime_defaults() {
let spec: SkillSpec = serde_json::from_value(json!({
"id": "db-management",
"name": "Database Management",
"description": "Helps with database operations",
"instructions_md": "Inspect schema before writing queries."
}))
.unwrap();
assert!(spec.user_invocable);
assert!(spec.model_invocable);
assert_eq!(spec.context, SkillSpecContext::Inline);
assert!(spec.allowed_tools.is_empty());
assert!(spec.paths.is_empty());
}
#[test]
fn unknown_field_is_rejected() {
let bad = json!({
"id": "x",
"name": "x",
"description": "x",
"instructions_md": "x",
"garbage": true
});
assert!(serde_json::from_value::<SkillSpec>(bad).is_err());
}
}