use std::collections::{BTreeMap, BTreeSet};
use serde::Serialize;
use crate::claude::discover::discover_claude;
use crate::claude::manifest::{
MARKETPLACE_MANIFEST, Marketplace, MarketplaceEntry, PLUGIN_MANIFEST, PLUGIN_NAME_RULE,
PluginManifest, PluginSpec, is_plugin_name, resolve_spec,
};
use crate::diagnostic::{SchemaDiagnostic, Severity};
use crate::dialect::{Dialect, DialectChoice};
use crate::discover::{Discovery, discover};
use crate::item::DefinitionKind;
use crate::package::{ManifestClassification, classify_manifest};
use crate::plugin_metadata::{SENTINEL_FILE, is_valid_sentinel};
use crate::prepare::{Classification, PrepareContext};
use crate::tree::TreeSource;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Kind {
#[serde(rename = "schema-1")]
Schema1,
MarsLegacy,
SchemaNewer,
Marketplace,
Plugin,
SkillTree,
Invalid,
Empty,
}
#[derive(Debug, Clone)]
pub(crate) enum Layout {
None,
Convention,
Claude(PluginSpec),
Marketplace(Marketplace),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Mirror {
pub dialect: Dialect,
pub paths: Vec<String>,
pub weak: bool,
}
pub(crate) struct Dispatched {
pub manifest: Option<Vec<u8>>,
pub classification: Classification,
pub layout: Layout,
pub discovery: Discovery,
pub also_found: Vec<Mirror>,
}
impl Dispatched {
pub(crate) fn kind(&self) -> Kind {
match (&self.classification, &self.layout) {
(Classification::Schema1, _) => Kind::Schema1,
(Classification::MarsLegacy, _) => Kind::MarsLegacy,
(Classification::SchemaNewer { .. }, _) => Kind::SchemaNewer,
(Classification::Invalid, _) => Kind::Invalid,
(Classification::Marketplace, _) => Kind::Marketplace,
(Classification::Empty, _) => Kind::Empty,
(Classification::Foreign(_), Layout::Claude(spec)) if !spec.is_bare_tree() => {
Kind::Plugin
}
(Classification::Foreign(_), _) => Kind::SkillTree,
}
}
pub(crate) fn dialect(&self) -> Option<&DialectChoice> {
match &self.classification {
Classification::Foreign(choice) => Some(choice),
_ => None,
}
}
}
#[derive(Clone, Copy)]
pub(crate) struct AsPlugin<'a> {
pub entry: &'a MarketplaceEntry,
pub at_marketplace_root: bool,
}
const STANDARD_SKILL_KEYS: &[&str] = &[
"name",
"description",
"license",
"compatibility",
"metadata",
"allowed-tools",
];
pub(crate) fn dispatch(
source: &impl TreeSource,
context: PrepareContext,
declared: Option<Dialect>,
as_plugin: Option<AsPlugin<'_>>,
fallback_name: Option<&str>,
) -> Dispatched {
let manifest = source.read("mars.toml");
let manifest_class = classify_manifest(manifest.as_deref());
let consumer_tree = consumer_content(context, &manifest_class);
let mut dispatched = classify(
source,
&manifest_class,
consumer_tree,
declared,
as_plugin,
fallback_name,
);
dispatched.manifest = manifest.filter(|_| !consumer_tree);
dispatched
}
fn consumer_content(context: PrepareContext, manifest: &ManifestClassification) -> bool {
matches!(
(context, manifest),
(
PrepareContext::AuthoredLocal,
ManifestClassification::NoManifest
) | (
PrepareContext::AuthoredLocal | PrepareContext::Project,
ManifestClassification::ConsumerConfig
)
)
}
fn classify(
source: &impl TreeSource,
manifest: &ManifestClassification,
consumer_tree: bool,
declared: Option<Dialect>,
as_plugin: Option<AsPlugin<'_>>,
fallback_name: Option<&str>,
) -> Dispatched {
let done = |classification, layout, discovery, also_found| Dispatched {
manifest: None,
classification,
layout,
discovery,
also_found,
};
if source
.read(SENTINEL_FILE)
.is_some_and(|bytes| is_valid_sentinel(&bytes))
{
return done(
Classification::Empty,
Layout::None,
Discovery::generated_root(),
Vec::new(),
);
}
match manifest {
_ if consumer_tree => {
return done(
Classification::MarsLegacy,
Layout::Convention,
discover(source, fallback_name),
Vec::new(),
);
}
ManifestClassification::Schema1 => {
return done(
Classification::Schema1,
Layout::Convention,
discover(source, fallback_name),
Vec::new(),
);
}
ManifestClassification::MarsLegacy => {
return done(
Classification::MarsLegacy,
Layout::Convention,
discover(source, fallback_name),
Vec::new(),
);
}
ManifestClassification::SchemaNewer { schema } => {
return done(
Classification::SchemaNewer { schema: *schema },
Layout::None,
Discovery::empty(),
Vec::new(),
);
}
ManifestClassification::Invalid { .. } | ManifestClassification::ConsumerConfig => {
return done(
Classification::Invalid,
Layout::None,
Discovery::empty(),
Vec::new(),
);
}
ManifestClassification::NoManifest => {}
}
if declared == Some(Dialect::MarsNative) {
return done(
Classification::MarsLegacy,
Layout::Convention,
discover(source, fallback_name),
Vec::new(),
);
}
let evidence = collect_evidence(source);
let decided = declared.map(DialectChoice::declared).or_else(|| {
evidence
.strongest()
.map(|(dialect, paths)| DialectChoice::predicted(dialect, paths))
});
let also_found = |chosen: Dialect| evidence.mirrors(chosen, source);
if decided
.as_ref()
.is_none_or(|choice| matches!(choice.dialect, Dialect::Claude | Dialect::AgentSkills))
{
let (layout, manifest_diagnostics) = claude_layout(source, as_plugin);
let mut discovery = match &layout {
Layout::Claude(spec) => discover_claude(source, spec, fallback_name),
_ => Discovery::empty(),
};
for (path, diagnostic) in manifest_diagnostics {
discovery
.diagnostics
.entry(path)
.or_default()
.push(diagnostic);
}
if matches!(layout, Layout::None) {
return done(Classification::Invalid, layout, discovery, Vec::new());
}
let choice = match decided {
Some(choice) => choice,
None if as_plugin.is_some() => DialectChoice::predicted(Dialect::Claude, Vec::new()),
None => predict_from_items(source, &discovery),
};
let mirrors = also_found(choice.dialect);
let has_items = !discovery.definitions.is_empty()
|| !discovery.dropped.is_empty()
|| matches!(layout, Layout::Marketplace(_));
let bare_tree = matches!(&layout, Layout::Claude(spec) if spec.is_bare_tree());
if !has_items && choice.evidence.paths.is_empty() && bare_tree {
return done(Classification::Empty, Layout::None, discovery, mirrors);
}
let classification = if matches!(layout, Layout::Marketplace(_)) {
Classification::Marketplace
} else {
Classification::Foreign(choice)
};
return done(classification, layout, discovery, mirrors);
}
let choice = decided.expect("a non-Claude dialect was decided");
let discovery = discover(source, fallback_name);
let mirrors = also_found(choice.dialect);
if discovery.definitions.is_empty() && choice.evidence.paths.is_empty() {
return done(Classification::Empty, Layout::None, discovery, mirrors);
}
done(
Classification::Foreign(choice),
Layout::Convention,
discovery,
mirrors,
)
}
fn claude_layout(
source: &impl TreeSource,
as_plugin: Option<AsPlugin<'_>>,
) -> (Layout, Vec<(String, SchemaDiagnostic)>) {
let mut diagnostics = Vec::new();
let invalid = |diagnostic: SchemaDiagnostic, path: &str| {
(Layout::None, vec![(path.to_owned(), diagnostic)])
};
if let Some(plugin) = as_plugin
&& !is_plugin_name(&plugin.entry.name)
{
return invalid(
SchemaDiagnostic::new(
"marketplace.plugin-name-invalid",
Severity::Error,
Vec::new(),
format!(
"plugin name `{}` is not usable ({PLUGIN_NAME_RULE})",
plugin.entry.name
),
),
MARKETPLACE_MANIFEST,
);
}
if as_plugin.is_none()
&& let Some(bytes) = source.read(MARKETPLACE_MANIFEST)
{
return match Marketplace::parse(&bytes, &mut diagnostics) {
Ok(marketplace) => (Layout::Marketplace(marketplace), diagnostics),
Err(diagnostic) => invalid(diagnostic, MARKETPLACE_MANIFEST),
};
}
let manifest = match source.read(PLUGIN_MANIFEST) {
Some(bytes) => match PluginManifest::parse(&bytes, &mut diagnostics) {
Ok(manifest) => Some(manifest),
Err(diagnostic) => return invalid(diagnostic, PLUGIN_MANIFEST),
},
None => None,
};
if manifest.is_none() && as_plugin.is_none() {
return (Layout::Claude(PluginSpec::walk()), diagnostics);
}
match resolve_spec(
manifest.as_ref(),
as_plugin.map(|plugin| plugin.entry),
as_plugin.is_some_and(|plugin| plugin.at_marketplace_root),
) {
Ok(spec) => (Layout::Claude(spec), diagnostics),
Err(message) => invalid(
SchemaDiagnostic::new(
"plugin.conflicting-manifests",
Severity::Error,
Vec::new(),
message,
),
PLUGIN_MANIFEST,
),
}
}
fn predict_from_items(source: &impl TreeSource, discovery: &Discovery) -> DialectChoice {
let (mut claude_agents, mut claude_skills) = (Vec::new(), Vec::new());
let mut opencode_paths = Vec::new();
let mut standard_only = true;
let mut skills = 0;
for definition in &discovery.definitions {
if definition.kind == DefinitionKind::Agent {
standard_only = false;
} else {
skills += 1;
}
let Some(frontmatter) = source
.read(&definition.source_path)
.and_then(|bytes| String::from_utf8(bytes).ok())
.and_then(|text| crate::frontmatter::Frontmatter::parse(&text).ok())
else {
continue;
};
let keys = frontmatter.keys();
if keys
.iter()
.any(|key| crate::claude::translate::EVIDENCE_KEYS.contains(&key.as_str()))
{
match definition.kind {
DefinitionKind::Agent => claude_agents.push(definition.source_path.clone()),
DefinitionKind::Skill => claude_skills.push(definition.source_path.clone()),
}
}
if definition.kind == DefinitionKind::Agent && crate::lift::is_opencode_agent(&frontmatter)
{
opencode_paths.push(definition.source_path.clone());
}
if definition.kind == DefinitionKind::Skill
&& keys
.iter()
.any(|key| !STANDARD_SKILL_KEYS.contains(&key.as_str()))
{
standard_only = false;
}
}
if !claude_agents.is_empty() {
claude_agents.extend(claude_skills);
claude_agents.truncate(5);
DialectChoice::predicted(Dialect::Claude, claude_agents)
} else if !opencode_paths.is_empty() {
opencode_paths.truncate(5);
DialectChoice::predicted(Dialect::OpenCode, opencode_paths)
} else if !claude_skills.is_empty() {
claude_skills.truncate(5);
DialectChoice::predicted(Dialect::Claude, claude_skills)
} else if skills > 0 && standard_only {
DialectChoice::predicted(Dialect::AgentSkills, Vec::new())
} else {
DialectChoice::predicted(Dialect::Claude, Vec::new())
}
}
#[derive(Default)]
struct Evidence(BTreeMap<Dialect, Vec<String>>);
const STRENGTH: &[Dialect] = &[
Dialect::Claude,
Dialect::Cursor,
Dialect::Codex,
Dialect::OpenCode,
];
fn collect_evidence(source: &impl TreeSource) -> Evidence {
let mut evidence = Evidence::default();
let mut add = |dialect, path: String| evidence.0.entry(dialect).or_default().push(path);
for path in [PLUGIN_MANIFEST, MARKETPLACE_MANIFEST] {
if source.is_file(path) {
add(Dialect::Claude, path.to_owned());
}
}
for (container, dialect) in [
(".claude", Dialect::Claude),
(".cursor", Dialect::Cursor),
(".codex", Dialect::Codex),
(".opencode", Dialect::OpenCode),
] {
for kind in ["skills", "agents"] {
let path = format!("{container}/{kind}");
if source.is_dir(&path) && !source.children(&path).is_empty() {
add(dialect, path);
break;
}
}
}
if source.is_dir(".cursor-plugin") {
add(Dialect::Cursor, ".cursor-plugin".to_owned());
}
for rules in [".cursor/rules", "rules"] {
if let Some(rule) = source
.children(rules)
.into_iter()
.find(|path| path.ends_with(".mdc"))
{
add(Dialect::Cursor, rule);
}
}
if source.is_dir(".codex-plugin") {
add(Dialect::Codex, ".codex-plugin".to_owned());
}
evidence
}
impl Evidence {
fn strongest(&self) -> Option<(Dialect, Vec<String>)> {
STRENGTH
.iter()
.find_map(|dialect| Some((*dialect, self.0.get(dialect)?.clone())))
}
fn mirrors(&self, chosen: Dialect, source: &impl TreeSource) -> Vec<Mirror> {
let mut mirrors = STRENGTH
.iter()
.filter(|dialect| **dialect != chosen)
.filter_map(|dialect| {
Some(Mirror {
dialect: *dialect,
paths: self.0.get(dialect)?.clone(),
weak: false,
})
})
.collect::<Vec<_>>();
if chosen != Dialect::Codex {
let sidecars = openai_sidecars(source);
if !sidecars.is_empty() {
mirrors.push(Mirror {
dialect: Dialect::Codex,
paths: sidecars,
weak: true,
});
}
}
mirrors
}
}
fn openai_sidecars(source: &impl TreeSource) -> Vec<String> {
let mut found = BTreeSet::new();
let mut check = |dir: &str| {
let path = format!("{dir}/agents/openai.yaml");
if source.is_dir(dir) && source.is_file(&format!("{dir}/SKILL.md")) && source.is_file(&path)
{
found.insert(path);
}
};
for group in source.children("skills") {
check(&group);
for child in source.children(&group) {
check(&child);
}
}
found.into_iter().take(5).collect()
}