1use serde::{Deserialize, Serialize};
2use time::OffsetDateTime;
3
4use crate::events::{ThreadId, TurnId};
5
6pub type SkillId = String;
7pub type SkillName = String;
8pub type SkillCanonicalPath = String;
9pub type SkillDiagnosticMessage = String;
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
12#[serde(rename_all = "camelCase")]
13pub enum SkillSource {
14 Workspace,
15 User,
16 Plugin { plugin_id: String },
17 Imported { import_id: String },
18 BuiltIn,
19}
20
21#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
22#[serde(rename_all = "snake_case")]
23pub enum SkillExposure {
24 Global,
25 DirectOnly,
26}
27
28#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
29#[serde(rename_all = "snake_case")]
30pub enum SkillActivationState {
31 Enabled,
32 Disabled,
33 Experimental,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
37#[serde(rename_all = "camelCase")]
38pub enum SkillSelector {
39 Name { name: SkillName },
40 Path { path: SkillCanonicalPath },
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
44#[serde(rename_all = "camelCase")]
45pub enum SkillActivationReason {
46 DirectInvocation,
47 FeatureBinding,
48 GlobalIndex,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
52#[serde(rename_all = "camelCase")]
53pub struct FeatureSkillBinding {
54 pub feature_id: String,
55 pub skill_selector: SkillSelector,
56 pub required: bool,
57 pub activation_reason: SkillActivationReason,
58}
59
60#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
61#[serde(rename_all = "camelCase")]
62pub struct SkillAgentMetadata {
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub interface: Option<String>,
65 #[serde(default, skip_serializing_if = "Vec::is_empty")]
66 pub dependencies: Vec<String>,
67 #[serde(default, skip_serializing_if = "Vec::is_empty")]
68 pub policies: Vec<String>,
69 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
70 pub raw: serde_json::Value,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
74#[serde(rename_all = "camelCase")]
75pub struct SkillDescriptor {
76 pub id: SkillId,
77 pub name: SkillName,
78 pub canonical_path: SkillCanonicalPath,
79 pub source: SkillSource,
80 pub exposure: SkillExposure,
81 pub activation: SkillActivationState,
82 pub description: String,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub short_description: Option<String>,
85 #[serde(default)]
86 pub experimental: bool,
87 #[serde(default, skip_serializing_if = "Vec::is_empty")]
88 pub diagnostics: Vec<SkillDiagnosticMessage>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub agent_metadata: Option<SkillAgentMetadata>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
94#[serde(rename_all = "camelCase")]
95pub struct Skill {
96 pub descriptor: SkillDescriptor,
97 pub body: String,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
101#[serde(rename_all = "camelCase")]
102pub struct SkillsCatalogLoaded {
103 pub descriptors: Vec<SkillDescriptor>,
104 #[serde(default, skip_serializing_if = "Vec::is_empty")]
105 pub diagnostics: Vec<SkillDiagnosticMessage>,
106 #[serde(with = "time::serde::rfc3339")]
107 pub timestamp: OffsetDateTime,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
111#[serde(rename_all = "camelCase")]
112pub struct SkillConfigApplied {
113 pub descriptor: SkillDescriptor,
114 pub previous_activation: SkillActivationState,
115 pub activation: SkillActivationState,
116 pub previous_exposure: SkillExposure,
117 pub exposure: SkillExposure,
118 #[serde(default, skip_serializing_if = "Vec::is_empty")]
119 pub diagnostics: Vec<SkillDiagnosticMessage>,
120 #[serde(with = "time::serde::rfc3339")]
121 pub timestamp: OffsetDateTime,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
125#[serde(rename_all = "camelCase")]
126pub struct SkillActivationResolved {
127 pub thread_id: ThreadId,
128 pub turn_id: TurnId,
129 pub selector: SkillSelector,
130 pub activation_reason: SkillActivationReason,
131 pub activated: bool,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub descriptor: Option<SkillDescriptor>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub diagnostic: Option<SkillDiagnosticMessage>,
136 #[serde(with = "time::serde::rfc3339")]
137 pub timestamp: OffsetDateTime,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
141#[serde(rename_all = "camelCase")]
142pub struct SkillIndexRendered {
143 pub thread_id: ThreadId,
144 pub turn_id: TurnId,
145 pub rendered_count: u64,
146 pub hidden_count: u64,
147 pub estimated_tokens: u32,
148 #[serde(with = "time::serde::rfc3339")]
149 pub timestamp: OffsetDateTime,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
153#[serde(rename_all = "camelCase")]
154pub struct SkillInvoked {
155 pub thread_id: ThreadId,
156 pub turn_id: TurnId,
157 pub selector: SkillSelector,
158 pub descriptor: SkillDescriptor,
159 #[serde(with = "time::serde::rfc3339")]
160 pub timestamp: OffsetDateTime,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
164#[serde(rename_all = "camelCase")]
165pub struct SkillAutoActivated {
166 pub thread_id: ThreadId,
167 pub turn_id: TurnId,
168 pub feature_id: String,
169 pub descriptor: SkillDescriptor,
170 #[serde(with = "time::serde::rfc3339")]
171 pub timestamp: OffsetDateTime,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
175#[serde(rename_all = "camelCase")]
176pub struct SkillSkipped {
177 pub thread_id: ThreadId,
178 pub turn_id: TurnId,
179 pub selector: SkillSelector,
180 pub reason: String,
181 #[serde(with = "time::serde::rfc3339")]
182 pub timestamp: OffsetDateTime,
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 #[test]
190 fn skill_descriptor_serializes_source_and_exposure() {
191 let descriptor = SkillDescriptor {
192 id: "builtin:commit".to_string(),
193 name: "commit".to_string(),
194 canonical_path: "roder-builtin://commit/SKILL.md".to_string(),
195 source: SkillSource::BuiltIn,
196 exposure: SkillExposure::DirectOnly,
197 activation: SkillActivationState::Enabled,
198 description: "Commit staged changes safely.".to_string(),
199 short_description: Some("Commit safely".to_string()),
200 experimental: false,
201 diagnostics: Vec::new(),
202 agent_metadata: Some(SkillAgentMetadata {
203 interface: Some("openai".to_string()),
204 dependencies: vec!["git".to_string()],
205 policies: vec!["do-not-stage-unrequested-files".to_string()],
206 raw: serde_json::json!({ "interface": "openai" }),
207 }),
208 };
209
210 let value = serde_json::to_value(&descriptor).unwrap();
211 assert_eq!(value["canonicalPath"], "roder-builtin://commit/SKILL.md");
212 assert_eq!(value["source"], "builtIn");
213 assert_eq!(value["exposure"], "direct_only");
214 assert_eq!(value["agentMetadata"]["dependencies"][0], "git");
215 let round_trip: SkillDescriptor = serde_json::from_value(value).unwrap();
216 assert_eq!(round_trip, descriptor);
217 }
218
219 #[test]
220 fn feature_skill_binding_targets_name_or_path() {
221 let binding = FeatureSkillBinding {
222 feature_id: "command:commit".to_string(),
223 skill_selector: SkillSelector::Name {
224 name: "commit".to_string(),
225 },
226 required: true,
227 activation_reason: SkillActivationReason::FeatureBinding,
228 };
229
230 let value = serde_json::to_value(binding).unwrap();
231 assert_eq!(value["skillSelector"]["name"]["name"], "commit");
232 assert_eq!(value["activationReason"], "featureBinding");
233 }
234
235 #[test]
236 fn skill_events_round_trip_public_shapes() {
237 let descriptor = SkillDescriptor {
238 id: "builtin:commit".to_string(),
239 name: "commit".to_string(),
240 canonical_path: "roder-builtin://commit/SKILL.md".to_string(),
241 source: SkillSource::BuiltIn,
242 exposure: SkillExposure::DirectOnly,
243 activation: SkillActivationState::Enabled,
244 description: "Commit staged changes safely.".to_string(),
245 short_description: Some("Commit safely".to_string()),
246 experimental: false,
247 diagnostics: Vec::new(),
248 agent_metadata: None,
249 };
250 let event = SkillActivationResolved {
251 thread_id: "thread-a".to_string(),
252 turn_id: "turn-a".to_string(),
253 selector: SkillSelector::Name {
254 name: "commit".to_string(),
255 },
256 activation_reason: SkillActivationReason::DirectInvocation,
257 activated: true,
258 descriptor: Some(descriptor),
259 diagnostic: None,
260 timestamp: OffsetDateTime::UNIX_EPOCH,
261 };
262
263 let value = serde_json::to_value(&event).unwrap();
264 assert_eq!(value["timestamp"], "1970-01-01T00:00:00Z");
265 assert_eq!(value["selector"]["name"]["name"], "commit");
266 let round_trip: SkillActivationResolved = serde_json::from_value(value).unwrap();
267 assert!(round_trip.activated);
268 }
269}