1use super::{
2 CapabilityStatus, MountAccess, MountPoint, RiskLevel, SKILLS_DISCOVERY_PATH, SkillContribution,
3};
4use crate::capability_types::{CapabilityId, MountSource, plugin_capability_id};
5use crate::{CapabilityInfo, ScopedMcpServers, validate_skill_name};
6use serde::{Deserialize, Serialize};
7
8pub const DECLARATIVE_CAPABILITY_PREFIX: &str = "declarative:";
9const MAX_NAME_BYTES: usize = 38;
12const MAX_DISPLAY_NAME_BYTES: usize = 80;
13const MAX_PROMPT_BYTES: usize = 64 * 1024;
14const MAX_FILES: usize = 32;
15const MAX_FILE_BYTES: usize = 64 * 1024;
16const MAX_SKILLS: usize = 16;
17const MAX_SKILL_BYTES: usize = 64 * 1024;
18const MAX_MCP_SERVERS: usize = 16;
19
20pub fn declarative_capability_id(name: &str) -> String {
21 format!("{DECLARATIVE_CAPABILITY_PREFIX}{name}")
22}
23
24pub fn is_declarative_capability(capability_id: &str) -> bool {
25 capability_id.starts_with(DECLARATIVE_CAPABILITY_PREFIX)
26}
27
28pub fn parse_declarative_capability_id(capability_id: &str) -> Option<&str> {
29 capability_id.strip_prefix(DECLARATIVE_CAPABILITY_PREFIX)
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct DeclarativeCapabilityDefinition {
34 pub name: String,
35 #[serde(default)]
36 pub display_name: Option<String>,
37 pub description: String,
38 #[serde(default = "default_status")]
39 pub status: CapabilityStatus,
40 #[serde(default)]
41 pub icon: Option<String>,
42 #[serde(default)]
43 pub category: Option<String>,
44 #[serde(default)]
45 pub system_prompt: Option<String>,
46 #[serde(default)]
47 pub mcp_servers: Option<ScopedMcpServers>,
48 #[serde(default)]
49 pub skills: Vec<DeclarativeCapabilitySkill>,
50 #[serde(default)]
51 pub files: Vec<DeclarativeCapabilityFile>,
52 #[serde(default)]
53 pub dependencies: Vec<String>,
54 #[serde(default)]
55 pub features: Vec<String>,
56 #[serde(default = "default_risk_level")]
57 pub risk_level: RiskLevel,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct DeclarativeCapabilityFile {
62 pub path: String,
63 pub content: String,
64 #[serde(default)]
65 pub access: MountAccess,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct DeclarativeCapabilitySkill {
70 pub name: String,
71 pub description: String,
72 pub instructions: String,
73 #[serde(default)]
74 pub files: Vec<DeclarativeCapabilitySkillFile>,
75 #[serde(default = "default_true")]
76 pub user_invocable: bool,
77 #[serde(default)]
78 pub disable_model_invocation: bool,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct DeclarativeCapabilitySkillFile {
83 pub path: String,
84 pub content: String,
85}
86
87fn default_true() -> bool {
88 true
89}
90
91fn default_status() -> CapabilityStatus {
92 CapabilityStatus::Available
93}
94
95fn default_risk_level() -> RiskLevel {
96 RiskLevel::Low
97}
98
99impl Default for DeclarativeCapabilityDefinition {
100 fn default() -> Self {
101 Self {
102 name: String::new(),
103 display_name: None,
104 description: String::new(),
105 status: CapabilityStatus::Available,
106 icon: Some("puzzle".to_string()),
107 category: Some("Declarative".to_string()),
108 system_prompt: None,
109 mcp_servers: None,
110 skills: Vec::new(),
111 files: Vec::new(),
112 dependencies: Vec::new(),
113 features: Vec::new(),
114 risk_level: RiskLevel::Low,
115 }
116 }
117}
118
119impl DeclarativeCapabilityDefinition {
120 pub fn mounts(&self, capability_id: &str) -> Vec<MountPoint> {
121 self.files
122 .iter()
123 .map(|file| {
124 let source = MountSource::text_file(file.content.clone());
125 match file.access {
126 MountAccess::ReadOnly => {
127 MountPoint::readonly(file.path.clone(), source, capability_id)
128 }
129 MountAccess::ReadWrite => {
130 MountPoint::readwrite(file.path.clone(), source, capability_id)
131 }
132 }
133 })
134 .collect()
135 }
136
137 pub fn skill_contributions(&self) -> Vec<SkillContribution> {
138 self.skills
139 .iter()
140 .map(|skill| {
141 SkillContribution::new(
142 skill.name.clone(),
143 skill.description.clone(),
144 skill.instructions.clone(),
145 )
146 .with_files(
147 skill
148 .files
149 .iter()
150 .map(|file| (file.path.clone(), file.content.clone()))
151 .collect(),
152 )
153 .with_user_invocable(skill.user_invocable)
154 .with_disable_model_invocation(skill.disable_model_invocation)
155 })
156 .collect()
157 }
158}
159
160pub fn hydrate_declarative_capability_config(
161 _config: serde_json::Value,
162 definition: &DeclarativeCapabilityDefinition,
163) -> serde_json::Value {
164 serde_json::to_value(definition).unwrap_or_default()
165}
166
167pub fn declarative_capability_info(
168 name: &str,
169 definition: DeclarativeCapabilityDefinition,
170) -> CapabilityInfo {
171 CapabilityInfo {
172 id: CapabilityId::new(declarative_capability_id(name)),
173 name: definition.display_name.unwrap_or(definition.name),
174 description: definition.description,
175 status: definition.status,
176 icon: definition.icon.or_else(|| Some("puzzle".to_string())),
177 category: definition
178 .category
179 .or_else(|| Some("Declarative".to_string())),
180 system_prompt: definition.system_prompt,
181 tool_definitions: Vec::new(),
182 is_mcp: false,
183 is_skill: false,
184 is_guardrail: false,
185 dependencies: definition.dependencies,
186 features: definition.features,
187 config_schema: None,
188 config_ui_schema: None,
189 risk_level: definition.risk_level,
190 agent_count: 0,
191 harness_count: 0,
192 docs_slug: None,
193 localizations: Default::default(),
194 }
195}
196
197pub fn hydrate_plugin_capability_config(
203 config: serde_json::Value,
204 definition: &DeclarativeCapabilityDefinition,
205) -> serde_json::Value {
206 hydrate_declarative_capability_config(config, definition)
210}
211
212pub fn plugin_capability_info(
217 name: &str,
218 definition: DeclarativeCapabilityDefinition,
219) -> CapabilityInfo {
220 CapabilityInfo {
221 id: CapabilityId::new(plugin_capability_id(name)),
222 name: definition
223 .display_name
224 .clone()
225 .unwrap_or_else(|| definition.name.clone()),
226 description: definition.description.clone(),
227 status: definition.status,
228 icon: definition
229 .icon
230 .clone()
231 .or_else(|| Some("puzzle".to_string())),
232 category: definition
233 .category
234 .clone()
235 .or_else(|| Some("Plugin".to_string())),
236 system_prompt: definition.system_prompt.clone(),
237 tool_definitions: Vec::new(),
238 is_mcp: false,
239 is_skill: false,
240 is_guardrail: false,
241 dependencies: definition.dependencies.clone(),
242 features: definition.features.clone(),
243 config_schema: None,
244 config_ui_schema: None,
245 risk_level: definition.risk_level,
246 agent_count: 0,
247 harness_count: 0,
248 docs_slug: None,
249 localizations: Default::default(),
250 }
251}
252
253pub fn validate_declarative_capability_definition(
254 definition: &DeclarativeCapabilityDefinition,
255) -> Result<(), String> {
256 validate_name(&definition.name)?;
257 if let Some(display_name) = &definition.display_name {
258 validate_non_empty("display_name", display_name, MAX_DISPLAY_NAME_BYTES)?;
259 }
260 validate_non_empty("description", &definition.description, 512)?;
261
262 if let Some(prompt) = &definition.system_prompt {
263 validate_size("system_prompt", prompt, MAX_PROMPT_BYTES)?;
264 }
265 if let Some(servers) = &definition.mcp_servers
266 && servers.len() > MAX_MCP_SERVERS
267 {
268 return Err(format!(
269 "mcp_servers cannot contain more than {MAX_MCP_SERVERS} entries"
270 ));
271 }
272 if definition.files.len() > MAX_FILES {
273 return Err(format!(
274 "files cannot contain more than {MAX_FILES} entries"
275 ));
276 }
277 if definition.skills.len() > MAX_SKILLS {
278 return Err(format!(
279 "skills cannot contain more than {MAX_SKILLS} entries"
280 ));
281 }
282
283 for dependency in &definition.dependencies {
284 if is_declarative_capability(dependency) {
285 return Err("declarative capability dependencies cannot reference other declarative capabilities".to_string());
286 }
287 }
288
289 for file in &definition.files {
290 validate_mount_path(&file.path)?;
291 validate_size(
292 &format!("file {}", file.path),
293 &file.content,
294 MAX_FILE_BYTES,
295 )?;
296 if file.path.starts_with(SKILLS_DISCOVERY_PATH) {
297 return Err(format!(
298 "file path {} is reserved; use skills[] for skill contributions",
299 file.path
300 ));
301 }
302 }
303
304 for skill in &definition.skills {
305 validate_skill_name(&skill.name).map_err(|errors| {
306 format!("invalid skill name '{}': {}", skill.name, errors.join("; "))
307 })?;
308 validate_non_empty("skill.description", &skill.description, 512)?;
309 validate_size(
310 &format!("skill {} instructions", skill.name),
311 &skill.instructions,
312 MAX_SKILL_BYTES,
313 )?;
314 for file in &skill.files {
315 validate_relative_path(&file.path)?;
316 validate_size(
317 &format!("skill {} file {}", skill.name, file.path),
318 &file.content,
319 MAX_FILE_BYTES,
320 )?;
321 }
322 }
323
324 Ok(())
325}
326
327fn validate_non_empty(field: &str, value: &str, max: usize) -> Result<(), String> {
328 if value.trim().is_empty() {
329 return Err(format!("{field} is required"));
330 }
331 validate_size(field, value, max)
332}
333
334fn validate_name(name: &str) -> Result<(), String> {
335 validate_non_empty("name", name, MAX_NAME_BYTES)?;
336 let mut chars = name.chars();
337 let Some(first) = chars.next() else {
338 return Err("name is required".to_string());
339 };
340 if !first.is_ascii_lowercase() {
341 return Err("name must start with a lowercase letter".to_string());
342 }
343 if !chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_' || ch == '-') {
344 return Err("name may contain only lowercase letters, digits, '_' and '-'".to_string());
345 }
346 if name.ends_with('_') || name.ends_with('-') {
347 return Err("name cannot end with '_' or '-'".to_string());
348 }
349 Ok(())
350}
351
352fn validate_size(field: &str, value: &str, max: usize) -> Result<(), String> {
353 if value.len() > max {
354 return Err(format!("{field} cannot exceed {max} bytes"));
355 }
356 Ok(())
357}
358
359fn validate_mount_path(path: &str) -> Result<(), String> {
360 if !path.starts_with('/') || path.contains("..") || path.contains("//") {
361 return Err(format!("invalid mount path: {path}"));
362 }
363 Ok(())
364}
365
366fn validate_relative_path(path: &str) -> Result<(), String> {
367 if path.starts_with('/') || path.contains("..") || path.contains("//") || path.trim().is_empty()
368 {
369 return Err(format!("invalid relative file path: {path}"));
370 }
371 Ok(())
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 fn valid_definition() -> DeclarativeCapabilityDefinition {
379 DeclarativeCapabilityDefinition {
380 name: "research_pack".to_string(),
381 display_name: Some("Research Pack".to_string()),
382 description: "Curated research behavior".to_string(),
383 ..Default::default()
384 }
385 }
386
387 #[test]
388 fn declarative_capability_ref_uses_unique_name() {
389 assert_eq!(
390 declarative_capability_id("research_pack"),
391 "declarative:research_pack"
392 );
393 assert_eq!(
394 parse_declarative_capability_id("declarative:research_pack"),
395 Some("research_pack")
396 );
397 }
398
399 #[test]
400 fn validation_accepts_name_and_display_name() {
401 validate_declarative_capability_definition(&valid_definition()).unwrap();
402 }
403
404 #[test]
405 fn validation_rejects_names_that_do_not_fit_capability_ref_columns() {
406 let mut definition = valid_definition();
407 definition.name = "a".repeat(MAX_NAME_BYTES + 1);
408 let err = validate_declarative_capability_definition(&definition).unwrap_err();
409 assert!(err.contains("name cannot exceed"));
410 }
411
412 #[test]
413 fn capability_info_uses_display_name_for_title() {
414 let info = declarative_capability_info("research_pack", valid_definition());
415 assert_eq!(info.id.as_str(), "declarative:research_pack");
416 assert_eq!(info.name, "Research Pack");
417 }
418}