1use 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#[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#[derive(Debug, Clone)]
47pub(crate) enum Layout {
48 None,
50 Convention,
52 Claude(PluginSpec),
54 Marketplace(Marketplace),
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
59pub struct Mirror {
60 pub dialect: Dialect,
61 pub paths: Vec<String>,
62 pub weak: bool,
65}
66
67pub(crate) struct Dispatched {
68 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 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 pub(crate) fn dialect(&self) -> Option<&DialectChoice> {
97 match &self.classification {
98 Classification::Foreign(choice) => Some(choice),
99 _ => None,
100 }
101 }
102}
103
104#[derive(Clone, Copy)]
107pub(crate) struct AsPlugin<'a> {
108 pub entry: &'a MarketplaceEntry,
109 pub at_marketplace_root: bool,
110}
111
112const 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
146fn 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 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 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 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
308fn 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
370fn 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 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#[derive(Default)]
435struct Evidence(BTreeMap<Dialect, Vec<String>>);
436
437const 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 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
519fn 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}