Skip to main content

everruns_core/plugins/
compiler.rs

1// Plugin compiler: PluginFileSet → CompiledPlugin
2//
3// Maps plugin directory components to a DeclarativeCapabilityDefinition per
4// the table in specs/plugins.md.
5
6use std::collections::BTreeMap;
7
8use crate::capabilities::{
9    CapabilityStatus, DeclarativeCapabilityDefinition, DeclarativeCapabilityFile,
10    DeclarativeCapabilitySkill, DeclarativeCapabilitySkillFile,
11    validate_declarative_capability_definition,
12};
13use crate::mcp_server::{McpServerTransportType, ScopedMcpServer, ScopedMcpServers};
14
15use super::file_set::PluginFileSet;
16use super::manifest::{McpServersField, PluginManifest};
17
18// `plugin:` prefix is 7 bytes, leaving 43 bytes for the name within the
19// VARCHAR(50) capability reference columns.
20const PLUGIN_CAPABILITY_PREFIX: &str = "plugin:";
21const MAX_PLUGIN_NAME_BYTES: usize = 50 - PLUGIN_CAPABILITY_PREFIX.len(); // 43
22
23/// Result of compiling a plugin directory.
24#[derive(Debug, Clone)]
25pub struct CompiledPlugin {
26    /// Parsed manifest.
27    pub manifest: PluginManifest,
28    /// Compiled declarative capability definition.
29    pub definition: DeclarativeCapabilityDefinition,
30    /// Non-fatal install warnings collected during compilation.
31    pub warnings: Vec<String>,
32}
33
34/// Compile a `PluginFileSet` into a `CompiledPlugin`.
35///
36/// Maps each plugin component to the corresponding capability contribution per
37/// the component-mapping table in `specs/plugins.md`. Errors are returned when
38/// compilation cannot produce a valid `DeclarativeCapabilityDefinition`.
39pub fn compile_plugin(file_set: &PluginFileSet) -> Result<CompiledPlugin, String> {
40    let (manifest, mut warnings) = file_set.manifest()?;
41
42    // --- name ---
43    let name = sanitize_plugin_name(&manifest.name)?;
44    if name.len() > MAX_PLUGIN_NAME_BYTES {
45        return Err(format!(
46            "plugin name '{}' is {} bytes but must fit in {} bytes (plugin: prefix occupies {} bytes)",
47            name,
48            name.len(),
49            MAX_PLUGIN_NAME_BYTES,
50            PLUGIN_CAPABILITY_PREFIX.len()
51        ));
52    }
53
54    // --- description (required) ---
55    let description = manifest
56        .description
57        .clone()
58        .filter(|d| !d.trim().is_empty())
59        .ok_or_else(|| "plugin manifest is missing a 'description' field".to_string())?;
60
61    // --- display_name ---
62    let display_name = manifest
63        .display_name
64        .clone()
65        .filter(|d| !d.trim().is_empty());
66
67    // --- agents → system_prompt ---
68    let system_prompt = compile_agents(file_set, &manifest, &mut warnings);
69
70    // --- skills ---
71    let skills = compile_skills(file_set, &manifest, &mut warnings);
72
73    // --- commands → user-invocable skills ---
74    let command_skills = compile_commands(file_set, &manifest, &mut warnings);
75
76    let mut all_skills = skills;
77    all_skills.extend(command_skills);
78
79    // --- MCP servers ---
80    let mcp_servers = compile_mcp_servers(file_set, &manifest, &mut warnings)?;
81
82    // Warn about unsupported component fields in the manifest.
83    for ignored_field in &["hooks", "lspServers", "monitors", "themes", "outputStyles"] {
84        if manifest.extra.contains_key(*ignored_field) {
85            warnings.push(format!(
86                "plugin manifest: '{ignored_field}' is not supported in v1 and will be ignored"
87            ));
88        }
89    }
90
91    let definition = DeclarativeCapabilityDefinition {
92        name: name.clone(),
93        display_name,
94        description,
95        status: CapabilityStatus::Available,
96        icon: Some("puzzle".to_string()),
97        category: Some("Plugin".to_string()),
98        system_prompt,
99        mcp_servers,
100        skills: all_skills,
101        files: Vec::<DeclarativeCapabilityFile>::new(),
102        dependencies: Vec::new(),
103        features: Vec::new(),
104        risk_level: crate::capabilities::RiskLevel::Low,
105    };
106
107    // Run through declarative validation to catch size/count violations.
108    validate_declarative_capability_definition(&definition)
109        .map_err(|e| format!("compiled plugin failed declarative validation: {e}"))?;
110
111    Ok(CompiledPlugin {
112        manifest,
113        definition,
114        warnings,
115    })
116}
117
118/// Validate and normalize a plugin name into the format accepted by the
119/// declarative name validator (lowercase, hyphens allowed, starts with letter).
120fn sanitize_plugin_name(name: &str) -> Result<String, String> {
121    let trimmed = name.trim();
122    if trimmed.is_empty() {
123        return Err("plugin name is empty".to_string());
124    }
125    // Validate: must start with lowercase letter, only [a-z0-9_-], no trailing -/_
126    let mut chars = trimmed.chars();
127    let first = chars.next().unwrap();
128    if !first.is_ascii_lowercase() {
129        return Err(format!(
130            "plugin name '{}' must start with a lowercase letter",
131            trimmed
132        ));
133    }
134    if !chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') {
135        return Err(format!(
136            "plugin name '{trimmed}' may only contain lowercase letters, digits, '-', and '_'"
137        ));
138    }
139    if trimmed.ends_with('-') || trimmed.ends_with('_') {
140        return Err(format!(
141            "plugin name '{trimmed}' must not end with '-' or '_'"
142        ));
143    }
144    Ok(trimmed.to_string())
145}
146
147// ============================================================================
148// Agents → system_prompt
149// ============================================================================
150
151/// Render agent files into a combined system prompt.
152///
153/// Each `.md` file under `agents/` (or the manifest-overridden path) is
154/// rendered as a named `<agent>` XML section per specs/xml-prompt-formatting.md.
155fn compile_agents(
156    file_set: &PluginFileSet,
157    manifest: &PluginManifest,
158    _warnings: &mut Vec<String>,
159) -> Option<String> {
160    let agent_dirs = match &manifest.agents {
161        Some(paths) => resolve_component_paths(paths),
162        None => vec!["agents".to_string()],
163    };
164
165    let mut sections: Vec<String> = Vec::new();
166
167    for agent_dir in &agent_dirs {
168        let dir = strip_dot_slash(agent_dir);
169        // List .md files directly under this directory.
170        let mut entries: Vec<(&str, &str)> = file_set.list_dir(dir);
171        entries.sort_by_key(|(name, _)| *name);
172
173        for (filename, full_path) in entries {
174            if !filename.ends_with(".md") {
175                continue;
176            }
177            let Some(content) = file_set.text_file(full_path) else {
178                continue;
179            };
180
181            // Parse frontmatter name/description if present.
182            let (fm_name, fm_desc, body) = parse_simple_frontmatter(&content);
183
184            // Use frontmatter `name` if available; fall back to filename stem.
185            let agent_name =
186                fm_name.unwrap_or_else(|| filename.trim_end_matches(".md").to_string());
187
188            let mut section = format!("<agent name=\"{}\"", escape_attr(&agent_name));
189            if let Some(desc) = fm_desc {
190                section.push_str(&format!(" description=\"{}\"", escape_attr(&desc)));
191            }
192            section.push_str(">\n");
193            section.push_str(body.trim());
194            section.push_str("\n</agent>");
195            sections.push(section);
196        }
197    }
198
199    if sections.is_empty() {
200        None
201    } else {
202        Some(sections.join("\n\n"))
203    }
204}
205
206// ============================================================================
207// Skills → DeclarativeCapabilitySkill
208// ============================================================================
209
210fn compile_skills(
211    file_set: &PluginFileSet,
212    manifest: &PluginManifest,
213    warnings: &mut Vec<String>,
214) -> Vec<DeclarativeCapabilitySkill> {
215    let skill_dirs = match &manifest.skills {
216        Some(paths) => resolve_component_paths(paths),
217        None => vec!["skills".to_string()],
218    };
219
220    let mut skills = Vec::new();
221
222    for skill_dir in &skill_dirs {
223        let dir = strip_dot_slash(skill_dir);
224        // Enumerate immediate subdirectories of the skills dir by finding
225        // files under `dir/` and extracting the first path component.
226        let prefix = format!("{dir}/");
227        let mut seen_subdirs = std::collections::BTreeSet::new();
228        for key in file_set.files.keys() {
229            if let Some(rest) = key.strip_prefix(&prefix)
230                && let Some(slash_pos) = rest.find('/')
231            {
232                seen_subdirs.insert(rest[..slash_pos].to_string());
233            }
234        }
235
236        for subdir_name in &seen_subdirs {
237            let skill_path = format!("{dir}/{subdir_name}");
238            let skill_md_path = format!("{skill_path}/SKILL.md");
239
240            let Some(skill_md_content) = file_set.text_file(&skill_md_path) else {
241                continue;
242            };
243
244            // Parse SKILL.md using the existing parser.
245            match crate::skill::parse_skill_md(&skill_md_content) {
246                Ok(parsed) => {
247                    // Collect sibling files (non-SKILL.md, text only).
248                    let mut skill_files = Vec::new();
249                    let all_skill_files = file_set.list_dir_recursive(&skill_path);
250                    for file_path in all_skill_files {
251                        if file_path == skill_md_path {
252                            continue;
253                        }
254                        let rel_within_skill = file_path
255                            .strip_prefix(&format!("{skill_path}/"))
256                            .unwrap_or(file_path);
257
258                        if let Some(bytes) = file_set.files.get(file_path) {
259                            match String::from_utf8(bytes.clone()) {
260                                Ok(text) => {
261                                    skill_files.push(DeclarativeCapabilitySkillFile {
262                                        path: rel_within_skill.to_string(),
263                                        content: text,
264                                    });
265                                }
266                                Err(_) => {
267                                    warnings.push(format!(
268                                        "skill '{}': binary file '{}' skipped (text only)",
269                                        parsed.name, rel_within_skill
270                                    ));
271                                }
272                            }
273                        }
274                    }
275
276                    skills.push(DeclarativeCapabilitySkill {
277                        name: parsed.name,
278                        description: parsed.description,
279                        instructions: parsed.instructions,
280                        files: skill_files,
281                        user_invocable: parsed.user_invocable,
282                        disable_model_invocation: parsed.disable_model_invocation,
283                    });
284                }
285                Err(errors) => {
286                    warnings.push(format!(
287                        "skill '{}': SKILL.md parse errors — {}: skill skipped",
288                        subdir_name,
289                        errors.join("; ")
290                    ));
291                }
292            }
293        }
294    }
295
296    skills
297}
298
299// ============================================================================
300// Commands → user-invocable DeclarativeCapabilitySkill
301// ============================================================================
302
303fn compile_commands(
304    file_set: &PluginFileSet,
305    manifest: &PluginManifest,
306    _warnings: &mut Vec<String>,
307) -> Vec<DeclarativeCapabilitySkill> {
308    let command_dirs = match &manifest.commands {
309        Some(paths) => resolve_component_paths(paths),
310        None => vec!["commands".to_string()],
311    };
312
313    let mut skills = Vec::new();
314
315    for command_dir in &command_dirs {
316        let dir = strip_dot_slash(command_dir);
317        let mut entries: Vec<(&str, &str)> = file_set.list_dir(dir);
318        entries.sort_by_key(|(name, _)| *name);
319
320        for (filename, full_path) in entries {
321            if !filename.ends_with(".md") {
322                continue;
323            }
324
325            let Some(content) = file_set.text_file(full_path) else {
326                continue;
327            };
328
329            let (fm_name, fm_desc, body) = parse_simple_frontmatter(&content);
330            let stem = filename.trim_end_matches(".md");
331            let name = fm_name.unwrap_or_else(|| stem.to_string());
332            let description = fm_desc.unwrap_or_else(|| format!("/{name} command"));
333
334            skills.push(DeclarativeCapabilitySkill {
335                name,
336                description,
337                instructions: body.trim().to_string(),
338                files: Vec::new(),
339                user_invocable: true,
340                disable_model_invocation: false,
341            });
342        }
343    }
344
345    skills
346}
347
348// ============================================================================
349// MCP Servers
350// ============================================================================
351
352/// Parse and compile MCP server configuration.
353///
354/// v1 supports HTTP transport only. Stdio entries produce a warning and are skipped.
355fn compile_mcp_servers(
356    file_set: &PluginFileSet,
357    manifest: &PluginManifest,
358    warnings: &mut Vec<String>,
359) -> Result<Option<ScopedMcpServers>, String> {
360    // Resolve where to look for MCP config.
361    let mcp_source = match &manifest.mcp_servers {
362        Some(McpServersField::Path(path)) => {
363            // Load the referenced file.
364            let p = strip_dot_slash(path);
365            match file_set.text_file(p) {
366                Some(content) => McpConfigSource::File(content),
367                None => return Ok(None),
368            }
369        }
370        Some(McpServersField::Paths(paths)) => {
371            // Merge all referenced files.
372            let mut merged: BTreeMap<String, serde_json::Value> = BTreeMap::new();
373            for path in paths {
374                let p = strip_dot_slash(path);
375                if let Some(content) = file_set.text_file(p) {
376                    let parsed = parse_mcp_json_file(&content, p)?;
377                    merged.extend(parsed);
378                }
379            }
380            McpConfigSource::Map(merged)
381        }
382        Some(McpServersField::Inline(map)) => {
383            McpConfigSource::Map(map.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
384        }
385        None => {
386            // Default: look for `.mcp.json` in the plugin root.
387            match file_set.text_file(".mcp.json") {
388                Some(content) => McpConfigSource::File(content),
389                None => return Ok(None),
390            }
391        }
392    };
393
394    let raw_map = match mcp_source {
395        McpConfigSource::File(content) => parse_mcp_json_file(&content, ".mcp.json")?,
396        McpConfigSource::Map(m) => m,
397    };
398
399    if raw_map.is_empty() {
400        return Ok(None);
401    }
402
403    let mut servers = ScopedMcpServers::new();
404
405    for (server_name, server_config) in raw_map {
406        // Extract transport type.
407        let transport_str = server_config
408            .get("type")
409            .and_then(|v| v.as_str())
410            .unwrap_or("http");
411
412        // Detect stdio by command presence or explicit "stdio" type.
413        let has_command = server_config.get("command").is_some();
414        let is_stdio = transport_str == "stdio" || has_command;
415
416        if is_stdio {
417            warnings.push(format!(
418                "MCP server '{server_name}': stdio transport is not supported in v1 and will be skipped"
419            ));
420            continue;
421        }
422
423        let url = server_config
424            .get("url")
425            .and_then(|v| v.as_str())
426            .unwrap_or("")
427            .to_string();
428
429        servers.insert(
430            server_name,
431            ScopedMcpServer {
432                transport_type: McpServerTransportType::Http,
433                url,
434                ..ScopedMcpServer::default()
435            },
436        );
437    }
438
439    if servers.is_empty() {
440        Ok(None)
441    } else {
442        Ok(Some(servers))
443    }
444}
445
446enum McpConfigSource {
447    File(String),
448    Map(BTreeMap<String, serde_json::Value>),
449}
450
451/// Parse a `.mcp.json` file and return the `mcpServers` object as a flat map.
452fn parse_mcp_json_file(
453    content: &str,
454    path: &str,
455) -> Result<BTreeMap<String, serde_json::Value>, String> {
456    let value: serde_json::Value =
457        serde_json::from_str(content).map_err(|e| format!("failed to parse {path}: {e}"))?;
458
459    // Top-level `mcpServers` key (standard .mcp.json format).
460    if let Some(servers) = value.get("mcpServers").and_then(|v| v.as_object()) {
461        return Ok(servers
462            .iter()
463            .map(|(k, v)| (k.clone(), v.clone()))
464            .collect());
465    }
466
467    // Fallback: treat the root object itself as the servers map.
468    if let Some(obj) = value.as_object() {
469        return Ok(obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect());
470    }
471
472    Ok(BTreeMap::new())
473}
474
475// ============================================================================
476// Helpers
477// ============================================================================
478
479/// Resolve a `StringOrArray` component path override to a list of dir strings.
480fn resolve_component_paths(field: &super::manifest::StringOrArray) -> Vec<String> {
481    field.to_vec()
482}
483
484/// Normalize a component path: strip leading `./` and trailing `/`.
485fn strip_dot_slash(path: &str) -> &str {
486    let p = path.strip_prefix("./").unwrap_or(path);
487    p.trim_end_matches('/')
488}
489
490/// Escape a string for use in an XML attribute value.
491fn escape_attr(s: &str) -> String {
492    s.replace('&', "&amp;")
493        .replace('"', "&quot;")
494        .replace('<', "&lt;")
495        .replace('>', "&gt;")
496}
497
498/// Minimal YAML-style frontmatter parser for `name` and `description` fields
499/// only. Returns `(name, description, body)`.
500///
501/// We don't use the full `serde_yaml` parser here because plugin agent/command
502/// files may use different frontmatter schemas; we only need two fields.
503fn parse_simple_frontmatter(content: &str) -> (Option<String>, Option<String>, &str) {
504    let trimmed = content.trim_start();
505    if !trimmed.starts_with("---") {
506        return (None, None, content);
507    }
508    let after_first = &trimmed[3..];
509    let Some(closing) = after_first.find("\n---") else {
510        return (None, None, content);
511    };
512
513    let fm_text = &after_first[..closing];
514    let body_start = closing + 4;
515    let body = if body_start < after_first.len() {
516        after_first[body_start..].trim_start_matches('\n')
517    } else {
518        ""
519    };
520
521    let mut name = None;
522    let mut description = None;
523    for line in fm_text.lines() {
524        if let Some(rest) = line.strip_prefix("name:") {
525            name = Some(rest.trim().trim_matches('"').trim_matches('\'').to_string());
526        } else if let Some(rest) = line.strip_prefix("description:") {
527            description = Some(rest.trim().trim_matches('"').trim_matches('\'').to_string());
528        }
529    }
530
531    (name, description, body)
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537
538    // ---- fixture integration test ----
539
540    #[test]
541    fn compile_microsoft_docs_fixture() {
542        let fixture = std::path::Path::new(concat!(
543            env!("CARGO_MANIFEST_DIR"),
544            "/../../testdata/plugins/microsoft-docs"
545        ));
546        let file_set = PluginFileSet::from_dir(fixture).expect("load fixture");
547        let compiled = compile_plugin(&file_set).expect("compile fixture");
548
549        // --- name and display name ---
550        assert_eq!(compiled.definition.name, "microsoft-docs");
551        assert_eq!(
552            compiled.definition.display_name.as_deref(),
553            Some("Microsoft Docs")
554        );
555
556        // --- description ---
557        assert!(!compiled.definition.description.is_empty());
558
559        // --- MCP server ---
560        let mcp = compiled
561            .definition
562            .mcp_servers
563            .as_ref()
564            .expect("mcp_servers");
565        let server = mcp.get("microsoft-learn").expect("microsoft-learn server");
566        assert_eq!(server.url, "https://learn.microsoft.com/api/mcp");
567        assert!(matches!(
568            server.transport_type,
569            McpServerTransportType::Http
570        ));
571
572        // --- skills ---
573        let skill = compiled
574            .definition
575            .skills
576            .iter()
577            .find(|s| s.name == "microsoft-docs")
578            .expect("microsoft-docs skill");
579        assert!(!skill.instructions.is_empty());
580
581        // --- commands (user-invocable skill) ---
582        let command = compiled
583            .definition
584            .skills
585            .iter()
586            .find(|s| s.name == "ms-docs")
587            .expect("ms-docs command skill");
588        assert!(command.user_invocable);
589
590        // --- agent → system_prompt ---
591        let prompt = compiled
592            .definition
593            .system_prompt
594            .as_ref()
595            .expect("system_prompt");
596        assert!(
597            prompt.contains("docs-researcher"),
598            "expected docs-researcher in system_prompt, got: {prompt}"
599        );
600
601        // --- interface warning ---
602        assert!(
603            compiled.warnings.iter().any(|w| w.contains("interface")),
604            "expected interface warning, got: {:?}",
605            compiled.warnings
606        );
607    }
608
609    // ---- targeted unit tests ----
610
611    #[test]
612    fn traversal_rejection() {
613        // The OS won't allow `..` in actual directory paths, so we test the
614        // name validation logic that rejects traversal-like plugin names and
615        // also verify the file_set traversal guard via file_set::tests.
616        // `../evil` fails at the first char check (not lowercase).
617        let err = sanitize_plugin_name("../evil").unwrap_err();
618        assert!(err.contains("must start with a lowercase letter"), "{err}");
619        // A name that starts with a letter but contains traversal separators.
620        let err2 = sanitize_plugin_name("a/b").unwrap_err();
621        assert!(err2.contains("only contain"), "{err2}");
622    }
623
624    #[test]
625    fn stdio_mcp_produces_warning() {
626        let mut warnings = Vec::new();
627        let file_set_files = {
628            let mut f = std::collections::BTreeMap::new();
629            f.insert(
630                ".claude-plugin/plugin.json".to_string(),
631                serde_json::json!({
632                    "name": "test-plugin",
633                    "description": "A test plugin."
634                })
635                .to_string()
636                .into_bytes(),
637            );
638            f.insert(
639                ".mcp.json".to_string(),
640                serde_json::json!({
641                    "mcpServers": {
642                        "my-server": {
643                            "type": "stdio",
644                            "command": "npx",
645                            "args": ["-y", "@some/mcp-server"]
646                        }
647                    }
648                })
649                .to_string()
650                .into_bytes(),
651            );
652            f
653        };
654        let file_set = PluginFileSet {
655            files: file_set_files,
656            dir_name: "test-plugin".to_string(),
657        };
658        let manifest = PluginManifest {
659            name: "test-plugin".to_string(),
660            display_name: None,
661            version: None,
662            description: Some("test".to_string()),
663            author: None,
664            homepage: None,
665            repository: None,
666            license: None,
667            keywords: Vec::new(),
668            skills: None,
669            commands: None,
670            agents: None,
671            mcp_servers: None,
672            extra: Default::default(),
673        };
674        let result = compile_mcp_servers(&file_set, &manifest, &mut warnings);
675        assert!(result.is_ok());
676        assert!(
677            warnings.iter().any(|w| w.contains("stdio")),
678            "expected stdio warning, got: {warnings:?}"
679        );
680        // No servers compiled since only one was stdio.
681        assert!(result.unwrap().is_none());
682    }
683
684    #[test]
685    fn missing_description_is_error() {
686        let file_set_files = {
687            let mut f = std::collections::BTreeMap::new();
688            f.insert(
689                ".claude-plugin/plugin.json".to_string(),
690                serde_json::json!({
691                    "name": "nodesc-plugin"
692                })
693                .to_string()
694                .into_bytes(),
695            );
696            f
697        };
698        let file_set = PluginFileSet {
699            files: file_set_files,
700            dir_name: "nodesc-plugin".to_string(),
701        };
702        let err = compile_plugin(&file_set).unwrap_err();
703        assert!(err.contains("description"), "error was: {err}");
704    }
705
706    #[test]
707    fn oversized_name_is_error() {
708        // A name longer than MAX_PLUGIN_NAME_BYTES (43) should fail.
709        let long_name = "a".repeat(MAX_PLUGIN_NAME_BYTES + 1);
710        let file_set_files = {
711            let mut f = std::collections::BTreeMap::new();
712            f.insert(
713                ".claude-plugin/plugin.json".to_string(),
714                serde_json::json!({
715                    "name": long_name,
716                    "description": "test"
717                })
718                .to_string()
719                .into_bytes(),
720            );
721            f
722        };
723        let file_set = PluginFileSet {
724            files: file_set_files,
725            dir_name: "aaa".to_string(),
726        };
727        let err = compile_plugin(&file_set).unwrap_err();
728        assert!(err.contains("bytes"), "error was: {err}");
729    }
730}