Skip to main content

mars_schema/
dispatch.rs

1//! Classify one package root: the ordered dispatch of `mars-normalize`, shared by `prepare`
2//! (which reads the root as one package) and `inspect` (which reports it).
3//!
4//! 1. A directory holding a valid `.mars-plugin.json` is generated output, never input.
5//! 2. A Mars manifest decides; no declaration overrides it.
6//! 3. A declared dialect (`mars-native` on a root with no manifest is a pre-v1 Mars package,
7//!    which the upgrade reads), else
8//! 4. the dialect predicted from evidence in the root, strongest first. A key only pre-v1 Mars
9//!    defined is not evidence: translation rewrites it per key, whatever dialect the root is.
10//! 5. No items at all is `empty`.
11
12use std::collections::{BTreeMap, BTreeSet};
13
14use serde::Serialize;
15
16use crate::claude::discover::discover_claude;
17use crate::claude::manifest::{
18    MARKETPLACE_MANIFEST, Marketplace, MarketplaceEntry, PLUGIN_MANIFEST, PLUGIN_NAME_RULE,
19    PluginManifest, PluginSpec, is_plugin_name, resolve_spec,
20};
21use crate::diagnostic::{SchemaDiagnostic, Severity};
22use crate::dialect::{Dialect, DialectChoice};
23use crate::discover::{Discovery, discover};
24use crate::item::DefinitionKind;
25use crate::package::{ManifestClassification, classify_manifest};
26use crate::plugin_metadata::{SENTINEL_FILE, is_valid_sentinel};
27use crate::prepare::{Classification, PrepareContext};
28use crate::tree::TreeSource;
29
30/// What a root is, as `inspect` reports it: derived from the classification and layout.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
32#[serde(rename_all = "kebab-case")]
33pub enum Kind {
34    #[serde(rename = "schema-1")]
35    Schema1,
36    MarsLegacy,
37    SchemaNewer,
38    Marketplace,
39    Plugin,
40    SkillTree,
41    Invalid,
42    Empty,
43}
44
45/// How the root's items are found.
46#[derive(Debug, Clone)]
47pub(crate) enum Layout {
48    /// Nothing is read (invalid, newer, empty, generated).
49    None,
50    /// Mars's convention walk, output equal to source.
51    Convention,
52    /// Claude plugin rules, relocating into schema v1.
53    Claude(PluginSpec),
54    Marketplace(Marketplace),
55}
56
57/// A harness's copy of the package, found beside the one that is read.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
59pub struct Mirror {
60    pub dialect: Dialect,
61    pub paths: Vec<String>,
62    /// A weak signal (a Codex sidecar many Claude-shaped repositories also ship) is reported
63    /// and never decides.
64    pub weak: bool,
65}
66
67pub(crate) struct Dispatched {
68    /// The package manifest to prepare. Consumer configuration is not one, so it is `None`
69    /// there; the root's `mars.toml` stays the consumer's to read.
70    pub manifest: Option<Vec<u8>>,
71    pub classification: Classification,
72    pub layout: Layout,
73    pub discovery: Discovery,
74    pub also_found: Vec<Mirror>,
75}
76
77impl Dispatched {
78    /// What the root is, as `inspect` reports it: the classification, with a foreign root's
79    /// layout telling a plugin from a bare skill tree.
80    pub(crate) fn kind(&self) -> Kind {
81        match (&self.classification, &self.layout) {
82            (Classification::Schema1, _) => Kind::Schema1,
83            (Classification::MarsLegacy, _) => Kind::MarsLegacy,
84            (Classification::SchemaNewer { .. }, _) => Kind::SchemaNewer,
85            (Classification::Invalid, _) => Kind::Invalid,
86            (Classification::Marketplace, _) => Kind::Marketplace,
87            (Classification::Empty, _) => Kind::Empty,
88            (Classification::Foreign(_), Layout::Claude(spec)) if !spec.is_bare_tree() => {
89                Kind::Plugin
90            }
91            (Classification::Foreign(_), _) => Kind::SkillTree,
92        }
93    }
94
95    /// The foreign dialect read and why; `None` for Mars packages and unreadable roots.
96    pub(crate) fn dialect(&self) -> Option<&DialectChoice> {
97        match &self.classification {
98            Classification::Foreign(choice) => Some(choice),
99            _ => None,
100        }
101    }
102}
103
104/// A marketplace entry the root is being read as, with whether the entry's source is the
105/// marketplace root itself.
106#[derive(Clone, Copy)]
107pub(crate) struct AsPlugin<'a> {
108    pub entry: &'a MarketplaceEntry,
109    pub at_marketplace_root: bool,
110}
111
112/// What the Agent Skills specification (agentskills.io/specification) defines for `SKILL.md`,
113/// `allowed-tools` included, which it marks experimental. A skill using only these says nothing
114/// about the harness it was written for.
115const STANDARD_SKILL_KEYS: &[&str] = &[
116    "name",
117    "description",
118    "license",
119    "compatibility",
120    "metadata",
121    "allowed-tools",
122];
123
124pub(crate) fn dispatch(
125    source: &impl TreeSource,
126    context: PrepareContext,
127    declared: Option<Dialect>,
128    as_plugin: Option<AsPlugin<'_>>,
129    fallback_name: Option<&str>,
130) -> Dispatched {
131    let manifest = source.read("mars.toml");
132    let manifest_class = classify_manifest(manifest.as_deref());
133    let consumer_tree = consumer_content(context, &manifest_class);
134    let mut dispatched = classify(
135        source,
136        &manifest_class,
137        consumer_tree,
138        declared,
139        as_plugin,
140        fallback_name,
141    );
142    dispatched.manifest = manifest.filter(|_| !consumer_tree);
143    dispatched
144}
145
146/// Whether the root's items are the consumer's own authored content, read as Mars convention
147/// with no manifest.
148fn consumer_content(context: PrepareContext, manifest: &ManifestClassification) -> bool {
149    matches!(
150        (context, manifest),
151        (
152            PrepareContext::AuthoredLocal,
153            ManifestClassification::NoManifest
154        ) | (
155            PrepareContext::AuthoredLocal | PrepareContext::Project,
156            ManifestClassification::ConsumerConfig
157        )
158    )
159}
160
161fn classify(
162    source: &impl TreeSource,
163    manifest: &ManifestClassification,
164    consumer_tree: bool,
165    declared: Option<Dialect>,
166    as_plugin: Option<AsPlugin<'_>>,
167    fallback_name: Option<&str>,
168) -> Dispatched {
169    let done = |classification, layout, discovery, also_found| Dispatched {
170        manifest: None,
171        classification,
172        layout,
173        discovery,
174        also_found,
175    };
176    if source
177        .read(SENTINEL_FILE)
178        .is_some_and(|bytes| is_valid_sentinel(&bytes))
179    {
180        return done(
181            Classification::Empty,
182            Layout::None,
183            Discovery::generated_root(),
184            Vec::new(),
185        );
186    }
187
188    match manifest {
189        _ if consumer_tree => {
190            return done(
191                Classification::MarsLegacy,
192                Layout::Convention,
193                discover(source, fallback_name),
194                Vec::new(),
195            );
196        }
197        ManifestClassification::Schema1 => {
198            return done(
199                Classification::Schema1,
200                Layout::Convention,
201                discover(source, fallback_name),
202                Vec::new(),
203            );
204        }
205        ManifestClassification::MarsLegacy => {
206            return done(
207                Classification::MarsLegacy,
208                Layout::Convention,
209                discover(source, fallback_name),
210                Vec::new(),
211            );
212        }
213        ManifestClassification::SchemaNewer { schema } => {
214            return done(
215                Classification::SchemaNewer { schema: *schema },
216                Layout::None,
217                Discovery::empty(),
218                Vec::new(),
219            );
220        }
221        ManifestClassification::Invalid { .. } | ManifestClassification::ConsumerConfig => {
222            return done(
223                Classification::Invalid,
224                Layout::None,
225                Discovery::empty(),
226                Vec::new(),
227            );
228        }
229        ManifestClassification::NoManifest => {}
230    }
231
232    // Mars content with no manifest has no `schema`, so it is pre-v1 by definition: it takes the
233    // upgrade, which leaves a file already in v1 as it is.
234    if declared == Some(Dialect::MarsNative) {
235        return done(
236            Classification::MarsLegacy,
237            Layout::Convention,
238            discover(source, fallback_name),
239            Vec::new(),
240        );
241    }
242
243    let evidence = collect_evidence(source);
244    // Hidden containers and harness manifests decide before any item is read; frontmatter
245    // evidence needs the items, which Claude's rules find for both of its dialects.
246    let decided = declared.map(DialectChoice::declared).or_else(|| {
247        evidence
248            .strongest()
249            .map(|(dialect, paths)| DialectChoice::predicted(dialect, paths))
250    });
251    let also_found = |chosen: Dialect| evidence.mirrors(chosen, source);
252
253    if decided
254        .as_ref()
255        .is_none_or(|choice| matches!(choice.dialect, Dialect::Claude | Dialect::AgentSkills))
256    {
257        let (layout, manifest_diagnostics) = claude_layout(source, as_plugin);
258        let mut discovery = match &layout {
259            Layout::Claude(spec) => discover_claude(source, spec, fallback_name),
260            _ => Discovery::empty(),
261        };
262        for (path, diagnostic) in manifest_diagnostics {
263            discovery
264                .diagnostics
265                .entry(path)
266                .or_default()
267                .push(diagnostic);
268        }
269        if matches!(layout, Layout::None) {
270            return done(Classification::Invalid, layout, discovery, Vec::new());
271        }
272        let choice = match decided {
273            Some(choice) => choice,
274            // A Claude marketplace named this root as a plugin.
275            None if as_plugin.is_some() => DialectChoice::predicted(Dialect::Claude, Vec::new()),
276            None => predict_from_items(source, &discovery),
277        };
278        let mirrors = also_found(choice.dialect);
279        let has_items = !discovery.definitions.is_empty()
280            || !discovery.dropped.is_empty()
281            || matches!(layout, Layout::Marketplace(_));
282        let bare_tree = matches!(&layout, Layout::Claude(spec) if spec.is_bare_tree());
283        if !has_items && choice.evidence.paths.is_empty() && bare_tree {
284            return done(Classification::Empty, Layout::None, discovery, mirrors);
285        }
286        let classification = if matches!(layout, Layout::Marketplace(_)) {
287            Classification::Marketplace
288        } else {
289            Classification::Foreign(choice)
290        };
291        return done(classification, layout, discovery, mirrors);
292    }
293
294    let choice = decided.expect("a non-Claude dialect was decided");
295    let discovery = discover(source, fallback_name);
296    let mirrors = also_found(choice.dialect);
297    if discovery.definitions.is_empty() && choice.evidence.paths.is_empty() {
298        return done(Classification::Empty, Layout::None, discovery, mirrors);
299    }
300    done(
301        Classification::Foreign(choice),
302        Layout::Convention,
303        discovery,
304        mirrors,
305    )
306}
307
308/// Marketplace, plugin or bare tree, from the manifests in the root. A manifest that does not
309/// parse makes the root invalid.
310fn claude_layout(
311    source: &impl TreeSource,
312    as_plugin: Option<AsPlugin<'_>>,
313) -> (Layout, Vec<(String, SchemaDiagnostic)>) {
314    let mut diagnostics = Vec::new();
315    let invalid = |diagnostic: SchemaDiagnostic, path: &str| {
316        (Layout::None, vec![(path.to_owned(), diagnostic)])
317    };
318    if let Some(plugin) = as_plugin
319        && !is_plugin_name(&plugin.entry.name)
320    {
321        return invalid(
322            SchemaDiagnostic::new(
323                "marketplace.plugin-name-invalid",
324                Severity::Error,
325                Vec::new(),
326                format!(
327                    "plugin name `{}` is not usable ({PLUGIN_NAME_RULE})",
328                    plugin.entry.name
329                ),
330            ),
331            MARKETPLACE_MANIFEST,
332        );
333    }
334    if as_plugin.is_none()
335        && let Some(bytes) = source.read(MARKETPLACE_MANIFEST)
336    {
337        return match Marketplace::parse(&bytes, &mut diagnostics) {
338            Ok(marketplace) => (Layout::Marketplace(marketplace), diagnostics),
339            Err(diagnostic) => invalid(diagnostic, MARKETPLACE_MANIFEST),
340        };
341    }
342    let manifest = match source.read(PLUGIN_MANIFEST) {
343        Some(bytes) => match PluginManifest::parse(&bytes, &mut diagnostics) {
344            Ok(manifest) => Some(manifest),
345            Err(diagnostic) => return invalid(diagnostic, PLUGIN_MANIFEST),
346        },
347        None => None,
348    };
349    if manifest.is_none() && as_plugin.is_none() {
350        return (Layout::Claude(PluginSpec::walk()), diagnostics);
351    }
352    match resolve_spec(
353        manifest.as_ref(),
354        as_plugin.map(|plugin| plugin.entry),
355        as_plugin.is_some_and(|plugin| plugin.at_marketplace_root),
356    ) {
357        Ok(spec) => (Layout::Claude(spec), diagnostics),
358        Err(message) => invalid(
359            SchemaDiagnostic::new(
360                "plugin.conflicting-manifests",
361                Severity::Error,
362                Vec::new(),
363                message,
364            ),
365            PLUGIN_MANIFEST,
366        ),
367    }
368}
369
370/// No manifest or container decided: the discovered files do, strongest first. A Claude-only key
371/// on an Agent predicts Claude; an Agent written the way only OpenCode writes one predicts
372/// OpenCode; a Claude-only key on a skill predicts Claude; skills with only the standard's keys
373/// are `agent-skills`; anything else is Claude, Mars's default.
374fn predict_from_items(source: &impl TreeSource, discovery: &Discovery) -> DialectChoice {
375    let (mut claude_agents, mut claude_skills) = (Vec::new(), Vec::new());
376    let mut opencode_paths = Vec::new();
377    let mut standard_only = true;
378    let mut skills = 0;
379    for definition in &discovery.definitions {
380        if definition.kind == DefinitionKind::Agent {
381            standard_only = false;
382        } else {
383            skills += 1;
384        }
385        let Some(frontmatter) = source
386            .read(&definition.source_path)
387            .and_then(|bytes| String::from_utf8(bytes).ok())
388            .and_then(|text| crate::frontmatter::Frontmatter::parse(&text).ok())
389        else {
390            continue;
391        };
392        let keys = frontmatter.keys();
393        if keys
394            .iter()
395            .any(|key| crate::claude::translate::EVIDENCE_KEYS.contains(&key.as_str()))
396        {
397            match definition.kind {
398                DefinitionKind::Agent => claude_agents.push(definition.source_path.clone()),
399                DefinitionKind::Skill => claude_skills.push(definition.source_path.clone()),
400            }
401        }
402        if definition.kind == DefinitionKind::Agent && crate::lift::is_opencode_agent(&frontmatter)
403        {
404            opencode_paths.push(definition.source_path.clone());
405        }
406        if definition.kind == DefinitionKind::Skill
407            && keys
408                .iter()
409                .any(|key| !STANDARD_SKILL_KEYS.contains(&key.as_str()))
410        {
411            standard_only = false;
412        }
413    }
414    // An Agent decides what a skill cannot: Claude skills are everywhere, so a Claude-only
415    // skill key does not outweigh an Agent written the way only OpenCode writes one.
416    if !claude_agents.is_empty() {
417        claude_agents.extend(claude_skills);
418        claude_agents.truncate(5);
419        DialectChoice::predicted(Dialect::Claude, claude_agents)
420    } else if !opencode_paths.is_empty() {
421        opencode_paths.truncate(5);
422        DialectChoice::predicted(Dialect::OpenCode, opencode_paths)
423    } else if !claude_skills.is_empty() {
424        claude_skills.truncate(5);
425        DialectChoice::predicted(Dialect::Claude, claude_skills)
426    } else if skills > 0 && standard_only {
427        DialectChoice::predicted(Dialect::AgentSkills, Vec::new())
428    } else {
429        DialectChoice::predicted(Dialect::Claude, Vec::new())
430    }
431}
432
433/// Files that vouch for a dialect, found in the root only.
434#[derive(Default)]
435struct Evidence(BTreeMap<Dialect, Vec<String>>);
436
437/// Strongest first. A Claude plugin root outranks a Cursor or Codex mirror of it.
438const STRENGTH: &[Dialect] = &[
439    Dialect::Claude,
440    Dialect::Cursor,
441    Dialect::Codex,
442    Dialect::OpenCode,
443];
444
445fn collect_evidence(source: &impl TreeSource) -> Evidence {
446    let mut evidence = Evidence::default();
447    let mut add = |dialect, path: String| evidence.0.entry(dialect).or_default().push(path);
448    for path in [PLUGIN_MANIFEST, MARKETPLACE_MANIFEST] {
449        if source.is_file(path) {
450            add(Dialect::Claude, path.to_owned());
451        }
452    }
453    for (container, dialect) in [
454        (".claude", Dialect::Claude),
455        (".cursor", Dialect::Cursor),
456        (".codex", Dialect::Codex),
457        (".opencode", Dialect::OpenCode),
458    ] {
459        for kind in ["skills", "agents"] {
460            let path = format!("{container}/{kind}");
461            if source.is_dir(&path) && !source.children(&path).is_empty() {
462                add(dialect, path);
463                break;
464            }
465        }
466    }
467    if source.is_dir(".cursor-plugin") {
468        add(Dialect::Cursor, ".cursor-plugin".to_owned());
469    }
470    for rules in [".cursor/rules", "rules"] {
471        if let Some(rule) = source
472            .children(rules)
473            .into_iter()
474            .find(|path| path.ends_with(".mdc"))
475        {
476            add(Dialect::Cursor, rule);
477        }
478    }
479    if source.is_dir(".codex-plugin") {
480        add(Dialect::Codex, ".codex-plugin".to_owned());
481    }
482    evidence
483}
484
485impl Evidence {
486    fn strongest(&self) -> Option<(Dialect, Vec<String>)> {
487        STRENGTH
488            .iter()
489            .find_map(|dialect| Some((*dialect, self.0.get(dialect)?.clone())))
490    }
491
492    /// Everything found for a dialect other than the one read, plus the weak Codex sidecar.
493    fn mirrors(&self, chosen: Dialect, source: &impl TreeSource) -> Vec<Mirror> {
494        let mut mirrors = STRENGTH
495            .iter()
496            .filter(|dialect| **dialect != chosen)
497            .filter_map(|dialect| {
498                Some(Mirror {
499                    dialect: *dialect,
500                    paths: self.0.get(dialect)?.clone(),
501                    weak: false,
502                })
503            })
504            .collect::<Vec<_>>();
505        if chosen != Dialect::Codex {
506            let sidecars = openai_sidecars(source);
507            if !sidecars.is_empty() {
508                mirrors.push(Mirror {
509                    dialect: Dialect::Codex,
510                    paths: sidecars,
511                    weak: true,
512                });
513            }
514        }
515        mirrors
516    }
517}
518
519/// `agents/openai.yaml` beside a `SKILL.md`: a Codex sidecar, found one skill level down.
520fn openai_sidecars(source: &impl TreeSource) -> Vec<String> {
521    let mut found = BTreeSet::new();
522    let mut check = |dir: &str| {
523        let path = format!("{dir}/agents/openai.yaml");
524        if source.is_dir(dir) && source.is_file(&format!("{dir}/SKILL.md")) && source.is_file(&path)
525        {
526            found.insert(path);
527        }
528    };
529    for group in source.children("skills") {
530        check(&group);
531        for child in source.children(&group) {
532            check(&child);
533        }
534    }
535    found.into_iter().take(5).collect()
536}