use std::collections::BTreeMap;
use crate::capabilities::{
CapabilityStatus, DeclarativeCapabilityDefinition, DeclarativeCapabilityFile,
DeclarativeCapabilitySkill, DeclarativeCapabilitySkillFile,
validate_declarative_capability_definition,
};
use crate::mcp_server::{McpServerTransportType, ScopedMcpServer, ScopedMcpServers};
use super::file_set::PluginFileSet;
use super::manifest::{McpServersField, PluginManifest};
const PLUGIN_CAPABILITY_PREFIX: &str = "plugin:";
const MAX_PLUGIN_NAME_BYTES: usize = 50 - PLUGIN_CAPABILITY_PREFIX.len();
#[derive(Debug, Clone)]
pub struct CompiledPlugin {
pub manifest: PluginManifest,
pub definition: DeclarativeCapabilityDefinition,
pub warnings: Vec<String>,
}
pub fn compile_plugin(file_set: &PluginFileSet) -> Result<CompiledPlugin, String> {
let (manifest, mut warnings) = file_set.manifest()?;
let name = sanitize_plugin_name(&manifest.name)?;
if name.len() > MAX_PLUGIN_NAME_BYTES {
return Err(format!(
"plugin name '{}' is {} bytes but must fit in {} bytes (plugin: prefix occupies {} bytes)",
name,
name.len(),
MAX_PLUGIN_NAME_BYTES,
PLUGIN_CAPABILITY_PREFIX.len()
));
}
let description = manifest
.description
.clone()
.filter(|d| !d.trim().is_empty())
.ok_or_else(|| "plugin manifest is missing a 'description' field".to_string())?;
let display_name = manifest
.display_name
.clone()
.filter(|d| !d.trim().is_empty());
let system_prompt = compile_agents(file_set, &manifest, &mut warnings);
let skills = compile_skills(file_set, &manifest, &mut warnings);
let command_skills = compile_commands(file_set, &manifest, &mut warnings);
let mut all_skills = skills;
all_skills.extend(command_skills);
let mcp_servers = compile_mcp_servers(file_set, &manifest, &mut warnings)?;
for ignored_field in &["hooks", "lspServers", "monitors", "themes", "outputStyles"] {
if manifest.extra.contains_key(*ignored_field) {
warnings.push(format!(
"plugin manifest: '{ignored_field}' is not supported in v1 and will be ignored"
));
}
}
let definition = DeclarativeCapabilityDefinition {
name: name.clone(),
display_name,
description,
status: CapabilityStatus::Available,
icon: Some("puzzle".to_string()),
category: Some("Plugin".to_string()),
system_prompt,
mcp_servers,
skills: all_skills,
files: Vec::<DeclarativeCapabilityFile>::new(),
dependencies: Vec::new(),
features: Vec::new(),
risk_level: crate::capabilities::RiskLevel::Low,
};
validate_declarative_capability_definition(&definition)
.map_err(|e| format!("compiled plugin failed declarative validation: {e}"))?;
Ok(CompiledPlugin {
manifest,
definition,
warnings,
})
}
fn sanitize_plugin_name(name: &str) -> Result<String, String> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err("plugin name is empty".to_string());
}
let mut chars = trimmed.chars();
let first = chars.next().unwrap();
if !first.is_ascii_lowercase() {
return Err(format!(
"plugin name '{}' must start with a lowercase letter",
trimmed
));
}
if !chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') {
return Err(format!(
"plugin name '{trimmed}' may only contain lowercase letters, digits, '-', and '_'"
));
}
if trimmed.ends_with('-') || trimmed.ends_with('_') {
return Err(format!(
"plugin name '{trimmed}' must not end with '-' or '_'"
));
}
Ok(trimmed.to_string())
}
fn compile_agents(
file_set: &PluginFileSet,
manifest: &PluginManifest,
_warnings: &mut Vec<String>,
) -> Option<String> {
let agent_dirs = match &manifest.agents {
Some(paths) => resolve_component_paths(paths),
None => vec!["agents".to_string()],
};
let mut sections: Vec<String> = Vec::new();
for agent_dir in &agent_dirs {
let dir = strip_dot_slash(agent_dir);
let mut entries: Vec<(&str, &str)> = file_set.list_dir(dir);
entries.sort_by_key(|(name, _)| *name);
for (filename, full_path) in entries {
if !filename.ends_with(".md") {
continue;
}
let Some(content) = file_set.text_file(full_path) else {
continue;
};
let (fm_name, fm_desc, body) = parse_simple_frontmatter(&content);
let agent_name =
fm_name.unwrap_or_else(|| filename.trim_end_matches(".md").to_string());
let mut section = format!("<agent name=\"{}\"", escape_attr(&agent_name));
if let Some(desc) = fm_desc {
section.push_str(&format!(" description=\"{}\"", escape_attr(&desc)));
}
section.push_str(">\n");
section.push_str(body.trim());
section.push_str("\n</agent>");
sections.push(section);
}
}
if sections.is_empty() {
None
} else {
Some(sections.join("\n\n"))
}
}
fn compile_skills(
file_set: &PluginFileSet,
manifest: &PluginManifest,
warnings: &mut Vec<String>,
) -> Vec<DeclarativeCapabilitySkill> {
let skill_dirs = match &manifest.skills {
Some(paths) => resolve_component_paths(paths),
None => vec!["skills".to_string()],
};
let mut skills = Vec::new();
for skill_dir in &skill_dirs {
let dir = strip_dot_slash(skill_dir);
let prefix = format!("{dir}/");
let mut seen_subdirs = std::collections::BTreeSet::new();
for key in file_set.files.keys() {
if let Some(rest) = key.strip_prefix(&prefix)
&& let Some(slash_pos) = rest.find('/')
{
seen_subdirs.insert(rest[..slash_pos].to_string());
}
}
for subdir_name in &seen_subdirs {
let skill_path = format!("{dir}/{subdir_name}");
let skill_md_path = format!("{skill_path}/SKILL.md");
let Some(skill_md_content) = file_set.text_file(&skill_md_path) else {
continue;
};
match crate::skill::parse_skill_md(&skill_md_content) {
Ok(parsed) => {
let mut skill_files = Vec::new();
let all_skill_files = file_set.list_dir_recursive(&skill_path);
for file_path in all_skill_files {
if file_path == skill_md_path {
continue;
}
let rel_within_skill = file_path
.strip_prefix(&format!("{skill_path}/"))
.unwrap_or(file_path);
if let Some(bytes) = file_set.files.get(file_path) {
match String::from_utf8(bytes.clone()) {
Ok(text) => {
skill_files.push(DeclarativeCapabilitySkillFile {
path: rel_within_skill.to_string(),
content: text,
});
}
Err(_) => {
warnings.push(format!(
"skill '{}': binary file '{}' skipped (text only)",
parsed.name, rel_within_skill
));
}
}
}
}
skills.push(DeclarativeCapabilitySkill {
name: parsed.name,
description: parsed.description,
instructions: parsed.instructions,
files: skill_files,
user_invocable: parsed.user_invocable,
disable_model_invocation: parsed.disable_model_invocation,
});
}
Err(errors) => {
warnings.push(format!(
"skill '{}': SKILL.md parse errors — {}: skill skipped",
subdir_name,
errors.join("; ")
));
}
}
}
}
skills
}
fn compile_commands(
file_set: &PluginFileSet,
manifest: &PluginManifest,
_warnings: &mut Vec<String>,
) -> Vec<DeclarativeCapabilitySkill> {
let command_dirs = match &manifest.commands {
Some(paths) => resolve_component_paths(paths),
None => vec!["commands".to_string()],
};
let mut skills = Vec::new();
for command_dir in &command_dirs {
let dir = strip_dot_slash(command_dir);
let mut entries: Vec<(&str, &str)> = file_set.list_dir(dir);
entries.sort_by_key(|(name, _)| *name);
for (filename, full_path) in entries {
if !filename.ends_with(".md") {
continue;
}
let Some(content) = file_set.text_file(full_path) else {
continue;
};
let (fm_name, fm_desc, body) = parse_simple_frontmatter(&content);
let stem = filename.trim_end_matches(".md");
let name = fm_name.unwrap_or_else(|| stem.to_string());
let description = fm_desc.unwrap_or_else(|| format!("/{name} command"));
skills.push(DeclarativeCapabilitySkill {
name,
description,
instructions: body.trim().to_string(),
files: Vec::new(),
user_invocable: true,
disable_model_invocation: false,
});
}
}
skills
}
fn compile_mcp_servers(
file_set: &PluginFileSet,
manifest: &PluginManifest,
warnings: &mut Vec<String>,
) -> Result<Option<ScopedMcpServers>, String> {
let mcp_source = match &manifest.mcp_servers {
Some(McpServersField::Path(path)) => {
let p = strip_dot_slash(path);
match file_set.text_file(p) {
Some(content) => McpConfigSource::File(content),
None => return Ok(None),
}
}
Some(McpServersField::Paths(paths)) => {
let mut merged: BTreeMap<String, serde_json::Value> = BTreeMap::new();
for path in paths {
let p = strip_dot_slash(path);
if let Some(content) = file_set.text_file(p) {
let parsed = parse_mcp_json_file(&content, p)?;
merged.extend(parsed);
}
}
McpConfigSource::Map(merged)
}
Some(McpServersField::Inline(map)) => {
McpConfigSource::Map(map.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
}
None => {
match file_set.text_file(".mcp.json") {
Some(content) => McpConfigSource::File(content),
None => return Ok(None),
}
}
};
let raw_map = match mcp_source {
McpConfigSource::File(content) => parse_mcp_json_file(&content, ".mcp.json")?,
McpConfigSource::Map(m) => m,
};
if raw_map.is_empty() {
return Ok(None);
}
let mut servers = ScopedMcpServers::new();
for (server_name, server_config) in raw_map {
let transport_str = server_config
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("http");
let has_command = server_config.get("command").is_some();
let is_stdio = transport_str == "stdio" || has_command;
if is_stdio {
warnings.push(format!(
"MCP server '{server_name}': stdio transport is not supported in v1 and will be skipped"
));
continue;
}
let url = server_config
.get("url")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
servers.insert(
server_name,
ScopedMcpServer {
transport_type: McpServerTransportType::Http,
url,
..ScopedMcpServer::default()
},
);
}
if servers.is_empty() {
Ok(None)
} else {
Ok(Some(servers))
}
}
enum McpConfigSource {
File(String),
Map(BTreeMap<String, serde_json::Value>),
}
fn parse_mcp_json_file(
content: &str,
path: &str,
) -> Result<BTreeMap<String, serde_json::Value>, String> {
let value: serde_json::Value =
serde_json::from_str(content).map_err(|e| format!("failed to parse {path}: {e}"))?;
if let Some(servers) = value.get("mcpServers").and_then(|v| v.as_object()) {
return Ok(servers
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect());
}
if let Some(obj) = value.as_object() {
return Ok(obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect());
}
Ok(BTreeMap::new())
}
fn resolve_component_paths(field: &super::manifest::StringOrArray) -> Vec<String> {
field.to_vec()
}
fn strip_dot_slash(path: &str) -> &str {
let p = path.strip_prefix("./").unwrap_or(path);
p.trim_end_matches('/')
}
fn escape_attr(s: &str) -> String {
s.replace('&', "&")
.replace('"', """)
.replace('<', "<")
.replace('>', ">")
}
fn parse_simple_frontmatter(content: &str) -> (Option<String>, Option<String>, &str) {
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return (None, None, content);
}
let after_first = &trimmed[3..];
let Some(closing) = after_first.find("\n---") else {
return (None, None, content);
};
let fm_text = &after_first[..closing];
let body_start = closing + 4;
let body = if body_start < after_first.len() {
after_first[body_start..].trim_start_matches('\n')
} else {
""
};
let mut name = None;
let mut description = None;
for line in fm_text.lines() {
if let Some(rest) = line.strip_prefix("name:") {
name = Some(rest.trim().trim_matches('"').trim_matches('\'').to_string());
} else if let Some(rest) = line.strip_prefix("description:") {
description = Some(rest.trim().trim_matches('"').trim_matches('\'').to_string());
}
}
(name, description, body)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compile_microsoft_docs_fixture() {
let fixture = std::path::Path::new(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../testdata/plugins/microsoft-docs"
));
let file_set = PluginFileSet::from_dir(fixture).expect("load fixture");
let compiled = compile_plugin(&file_set).expect("compile fixture");
assert_eq!(compiled.definition.name, "microsoft-docs");
assert_eq!(
compiled.definition.display_name.as_deref(),
Some("Microsoft Docs")
);
assert!(!compiled.definition.description.is_empty());
let mcp = compiled
.definition
.mcp_servers
.as_ref()
.expect("mcp_servers");
let server = mcp.get("microsoft-learn").expect("microsoft-learn server");
assert_eq!(server.url, "https://learn.microsoft.com/api/mcp");
assert!(matches!(
server.transport_type,
McpServerTransportType::Http
));
let skill = compiled
.definition
.skills
.iter()
.find(|s| s.name == "microsoft-docs")
.expect("microsoft-docs skill");
assert!(!skill.instructions.is_empty());
let command = compiled
.definition
.skills
.iter()
.find(|s| s.name == "ms-docs")
.expect("ms-docs command skill");
assert!(command.user_invocable);
let prompt = compiled
.definition
.system_prompt
.as_ref()
.expect("system_prompt");
assert!(
prompt.contains("docs-researcher"),
"expected docs-researcher in system_prompt, got: {prompt}"
);
assert!(
compiled.warnings.iter().any(|w| w.contains("interface")),
"expected interface warning, got: {:?}",
compiled.warnings
);
}
#[test]
fn traversal_rejection() {
let err = sanitize_plugin_name("../evil").unwrap_err();
assert!(err.contains("must start with a lowercase letter"), "{err}");
let err2 = sanitize_plugin_name("a/b").unwrap_err();
assert!(err2.contains("only contain"), "{err2}");
}
#[test]
fn stdio_mcp_produces_warning() {
let mut warnings = Vec::new();
let file_set_files = {
let mut f = std::collections::BTreeMap::new();
f.insert(
".claude-plugin/plugin.json".to_string(),
serde_json::json!({
"name": "test-plugin",
"description": "A test plugin."
})
.to_string()
.into_bytes(),
);
f.insert(
".mcp.json".to_string(),
serde_json::json!({
"mcpServers": {
"my-server": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@some/mcp-server"]
}
}
})
.to_string()
.into_bytes(),
);
f
};
let file_set = PluginFileSet {
files: file_set_files,
dir_name: "test-plugin".to_string(),
};
let manifest = PluginManifest {
name: "test-plugin".to_string(),
display_name: None,
version: None,
description: Some("test".to_string()),
author: None,
homepage: None,
repository: None,
license: None,
keywords: Vec::new(),
skills: None,
commands: None,
agents: None,
mcp_servers: None,
extra: Default::default(),
};
let result = compile_mcp_servers(&file_set, &manifest, &mut warnings);
assert!(result.is_ok());
assert!(
warnings.iter().any(|w| w.contains("stdio")),
"expected stdio warning, got: {warnings:?}"
);
assert!(result.unwrap().is_none());
}
#[test]
fn missing_description_is_error() {
let file_set_files = {
let mut f = std::collections::BTreeMap::new();
f.insert(
".claude-plugin/plugin.json".to_string(),
serde_json::json!({
"name": "nodesc-plugin"
})
.to_string()
.into_bytes(),
);
f
};
let file_set = PluginFileSet {
files: file_set_files,
dir_name: "nodesc-plugin".to_string(),
};
let err = compile_plugin(&file_set).unwrap_err();
assert!(err.contains("description"), "error was: {err}");
}
#[test]
fn oversized_name_is_error() {
let long_name = "a".repeat(MAX_PLUGIN_NAME_BYTES + 1);
let file_set_files = {
let mut f = std::collections::BTreeMap::new();
f.insert(
".claude-plugin/plugin.json".to_string(),
serde_json::json!({
"name": long_name,
"description": "test"
})
.to_string()
.into_bytes(),
);
f
};
let file_set = PluginFileSet {
files: file_set_files,
dir_name: "aaa".to_string(),
};
let err = compile_plugin(&file_set).unwrap_err();
assert!(err.contains("bytes"), "error was: {err}");
}
}