1use crate::diagnostic::{RemovedField, unrecognized_field};
4use crate::frontmatter::Frontmatter;
5use crate::invocability::parse_invocability_axis;
6use serde_yaml::Value;
7
8#[derive(Debug, Clone, serde::Serialize)]
9#[serde(rename_all = "kebab-case")]
10pub struct SkillProfile {
11 pub name: Option<String>,
12 pub description: Option<String>,
13 #[serde(skip)]
15 pub skill_type: Option<String>,
16 pub model_invocable: bool,
17 pub user_invocable: bool,
18 pub license: Option<String>,
19 pub compatibility: Option<String>,
20 #[serde(serialize_with = "serialize_metadata")]
21 pub metadata: Option<Value>,
22 #[serde(skip)]
24 pub had_model_invocable_field: bool,
25 #[serde(skip)]
26 pub has_frontmatter: bool,
27 #[serde(skip)]
29 pub harness_overrides: crate::harness_overrides::HarnessOverrides,
30}
31
32fn serialize_metadata<S: serde::Serializer>(
35 value: &Option<Value>,
36 serializer: S,
37) -> Result<S::Ok, S::Error> {
38 use serde::Serialize;
39 value
40 .as_ref()
41 .and_then(|value| crate::ordered::OrderedValue::from_yaml(value).ok())
42 .serialize(serializer)
43}
44
45pub const LOWERED_KEYS: &[&str] = &[
50 "name",
51 "description",
52 "license",
53 "metadata",
54 "disable-model-invocation",
55 "user-invocable",
56 "alwaysApply",
57];
58
59pub(crate) const REMOVED_SKILL_FIELDS: &[RemovedField] = &[
63 RemovedField::discarded("tools", "remove this unused skill policy"),
64 RemovedField::discarded("allowed-tools", "remove this unused skill policy"),
65 RemovedField::discarded("allowed_tools", "remove this unused skill policy"),
66 RemovedField::discarded("disallowed-tools", "remove this unused skill policy"),
67 RemovedField::discarded("disallowed_tools", "remove this unused skill policy"),
68 RemovedField::discarded("disallowedTools", "remove this unused skill policy"),
69 RemovedField::replaced("type", "use `metadata.type`"),
70 RemovedField::replaced("when_to_use", "fold it into `description`"),
71 RemovedField::replaced("model_invocable", "use `model-invocable`"),
72 RemovedField::replaced("user_invocable", "use `user-invocable`"),
73 RemovedField::replaced("invocation", "use `model-invocable` and `user-invocable`"),
74 RemovedField::replaced("disable-model-invocation", "use `model-invocable`"),
75 RemovedField::replaced("allow_implicit_invocation", "use `model-invocable`"),
76];
77
78pub(crate) fn tool_keys() -> impl Iterator<Item = &'static str> {
80 REMOVED_SKILL_FIELDS
81 .iter()
82 .filter(|field| field.discarded)
83 .map(|field| field.key)
84}
85
86pub(crate) fn read_fields(
87 fm: &Frontmatter,
88 diags: &mut Vec<crate::diagnostic::SchemaDiagnostic>,
89) -> SkillProfile {
90 use crate::diagnostic::{SchemaDiagnostic, Severity, child_path, key_path};
91
92 let mut profile = SkillProfile {
93 name: None,
94 description: None,
95 skill_type: None,
96 model_invocable: true,
97 user_invocable: true,
98 license: None,
99 compatibility: None,
100 metadata: None,
101 had_model_invocable_field: false,
102 has_frontmatter: fm.has_frontmatter(),
103 harness_overrides: Default::default(),
104 };
105 let mut classification_diagnostics = Vec::new();
106 let mut field_diagnostics = Vec::new();
107
108 for key in fm.keys() {
109 let value = fm.get(&key).expect("frontmatter key came from mapping");
110 match key.as_str() {
111 "name" | "description" | "license" | "compatibility" => {
112 let parsed = if let Some(text) = value.as_str() {
113 Some(if key == "name" {
114 text.trim().to_owned()
115 } else {
116 text.to_owned()
117 })
118 } else {
119 field_diagnostics.push(SchemaDiagnostic::new(
120 "field.type",
121 Severity::Error,
122 key_path(&key),
123 format!("`{key}` must be a string"),
124 ));
125 None
126 };
127 match key.as_str() {
128 "name" => profile.name = parsed,
129 "description" => profile.description = parsed,
130 "license" => profile.license = parsed,
131 "compatibility" => profile.compatibility = parsed,
132 _ => unreachable!(),
133 }
134 }
135 "metadata" => {
136 profile.metadata = Some(value.clone());
137 if let Some(mapping) = value.as_mapping() {
138 if let Some(skill_type) = mapping.get(Value::String("type".to_string())) {
139 if let Some(skill_type) = skill_type.as_str() {
140 profile.skill_type = Some(skill_type.to_owned());
141 } else {
142 field_diagnostics.push(SchemaDiagnostic::new(
143 "field.type",
144 Severity::Error,
145 child_path("metadata", "type"),
146 "`metadata.type` must be a string",
147 ));
148 }
149 }
150 } else {
151 field_diagnostics.push(SchemaDiagnostic::new(
152 "field.type",
153 Severity::Error,
154 key_path("metadata"),
155 "`metadata` must be a map",
156 ));
157 }
158 }
159 "model-invocable" => {
160 let (value, had_field, invalid) = parse_invocability_axis(Some(value));
161 profile.model_invocable = value;
162 profile.had_model_invocable_field = had_field;
163 if invalid.is_some() {
164 field_diagnostics.push(SchemaDiagnostic::new(
165 "field.type",
166 Severity::Error,
167 key_path("model-invocable"),
168 "`model-invocable` must be true or false",
169 ));
170 }
171 }
172 "user-invocable" => {
173 let (value, _, invalid) = parse_invocability_axis(Some(value));
174 profile.user_invocable = value;
175 if invalid.is_some() {
176 field_diagnostics.push(SchemaDiagnostic::new(
177 "field.type",
178 Severity::Error,
179 key_path("user-invocable"),
180 "`user-invocable` must be true or false",
181 ));
182 }
183 }
184 "harness-overrides" => {
185 profile.harness_overrides = crate::harness_overrides::parse_harness_overrides(
186 "skill",
187 value,
188 &mut field_diagnostics,
189 );
190 }
191 _ => classification_diagnostics.push(unrecognized_field(REMOVED_SKILL_FIELDS, &key)),
192 }
193 }
194
195 diags.append(&mut classification_diagnostics);
196
197 if !fm.has_frontmatter() {
198 diags.push(SchemaDiagnostic::new(
199 "skill.no-frontmatter",
200 Severity::Warning,
201 Vec::new(),
202 "body-only skill has no frontmatter",
203 ));
204 } else {
205 for (key, value) in [
206 ("name", profile.name.as_deref()),
207 ("description", profile.description.as_deref()),
208 ] {
209 if value.is_none_or(|value| value.trim().is_empty()) {
210 diags.push(SchemaDiagnostic::new(
211 "field.required",
212 Severity::Error,
213 key_path(key),
214 format!("`{key}` must be a non-empty string"),
215 ));
216 }
217 }
218 }
219 diags.append(&mut field_diagnostics);
220
221 profile
222}
223
224pub fn read(content: &str) -> crate::read::DefinitionRead<SkillProfile> {
226 crate::read::read_with(content, read_fields)
227}