Skip to main content

mars_schema/
skill.rs

1//! Skill definition reading.
2
3use 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    /// `metadata.type`, kept apart for callers; the reading carries it under `metadata`.
14    #[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    /// true when the source frontmatter explicitly set `model-invocable`
23    #[serde(skip)]
24    pub had_model_invocable_field: bool,
25    #[serde(skip)]
26    pub has_frontmatter: bool,
27    /// Per-harness native keys (`harness-overrides:`), written only to that harness's file.
28    #[serde(skip)]
29    pub harness_overrides: crate::harness_overrides::HarnessOverrides,
30}
31
32/// Non-JSON YAML (tags, non-string keys) is left out, as in the reading's `frontmatter`,
33/// where it carries a `yaml.non-json-value` diagnostic.
34fn 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
45/// The frontmatter keys Mars writes into a harness's native skill file itself. A
46/// `harness-overrides` entry never replaces one (native lowering keeps Mars's value and reports
47/// the override as dropped), so the translation's set-aside never moves one there either. The
48/// Agent counterpart is [`crate::agent::LoweredField`].
49pub const LOWERED_KEYS: &[&str] = &[
50    "name",
51    "description",
52    "license",
53    "metadata",
54    "disable-model-invocation",
55    "user-invocable",
56    "alwaysApply",
57];
58
59/// Schema v1 skills carry no tool policy, so every spelling of one is a `discarded` key; the
60/// foreign-dialect import and the legacy upgrade drop them. The upgrade rewrites `type` and
61/// `when_to_use` and the invocability renames; the other removed keys stay for the author.
62pub(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
78/// The keys that carry a skill's tool policy.
79pub(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
224/// Read a skill definition through the versioned schema contract.
225pub fn read(content: &str) -> crate::read::DefinitionRead<SkillProfile> {
226    crate::read::read_with(content, read_fields)
227}