pub mod claude;
pub mod codewhale;
pub mod codex;
pub mod kimi;
use serde_json::Value;
use crate::plugins::manifest::{PluginCompatibility, PluginInventory};
use super::types::{
MarketplaceCatalog, MarketplaceCatalogId, MarketplaceDiagnostic, MarketplaceFormat,
};
pub(super) fn declared_catalog_compatibility(declared: &PluginInventory) -> PluginCompatibility {
let supported = declared.skills > 0 || declared.mcp_servers > 0;
let unsupported = declared.commands > 0
|| declared.agents > 0
|| declared.hooks > 0
|| declared.lsp > 0
|| declared.native > 0
|| !declared.filesystem_roots.is_empty()
|| !declared.network_hosts.is_empty()
|| declared.lifecycle_mutation;
match (supported, unsupported) {
(true, false) => PluginCompatibility::Full,
(true, true) => PluginCompatibility::Partial,
(false, true) => PluginCompatibility::Unsupported,
(false, false) => PluginCompatibility::Full,
}
}
pub struct MarketplaceDocument {
pub catalog_id: MarketplaceCatalogId,
pub format: MarketplaceFormat,
pub root: Value,
pub base: Option<String>,
}
pub fn parse_catalog(document: MarketplaceDocument) -> MarketplaceCatalog {
let format = match document.format {
MarketplaceFormat::Auto => match detect_format(&document.root) {
Ok(format) => format,
Err(diagnostic) => {
let name = document.catalog_id.as_str().to_string();
return MarketplaceCatalog {
id: document.catalog_id,
format: MarketplaceFormat::Auto,
name,
display_name: None,
description: None,
version: None,
base: document.base,
provenance: super::types::CatalogProvenance::default(),
candidates: Vec::new(),
diagnostics: vec![diagnostic],
};
}
},
explicit => explicit,
};
match format {
MarketplaceFormat::Kimi => kimi::parse_kimi_catalog(document),
MarketplaceFormat::Claude => claude::parse_claude_catalog(document),
MarketplaceFormat::Codex => codex::parse_codex_catalog(document),
MarketplaceFormat::Codewhale => codewhale::parse_codewhale_catalog(document),
MarketplaceFormat::Auto => unreachable!("resolved above"),
}
}
fn detect_format(root: &Value) -> Result<MarketplaceFormat, MarketplaceDiagnostic> {
let Some(obj) = root.as_object() else {
return Err(MarketplaceDiagnostic::error(
"NOT_AN_OBJECT",
"marketplace catalog must be a JSON object",
None,
None,
));
};
let Some(entries) = obj.get("plugins").and_then(Value::as_array) else {
return Err(MarketplaceDiagnostic::error(
"UNKNOWN_FORMAT",
"catalog has no documented marker: expected Kimi `plugins` with `id` entries, \
Claude `owner`/`plugins`, Codex `policy`/`interface`, or Codewhale `plugins` \
with install-spec sources",
None,
None,
));
};
if obj.contains_key("owner")
|| obj
.get("metadata")
.and_then(|m| m.get("pluginRoot"))
.is_some()
{
return Ok(MarketplaceFormat::Claude);
}
let entry_markers: Vec<MapMarker> = entries
.iter()
.map(|entry| {
let entry_obj = entry.as_object();
MapMarker {
has_id: entry_obj.is_some_and(|o| o.contains_key("id")),
has_name: entry_obj.is_some_and(|o| o.contains_key("name")),
source_kind: entry_obj.and_then(|o| o.get("source")).map(source_marker),
has_policy: entry_obj.is_some_and(|o| o.contains_key("policy")),
}
})
.collect();
if entry_markers
.iter()
.any(|m| m.has_policy || m.source_kind == Some(SourceMarker::Local))
|| obj.contains_key("interface")
{
return Ok(MarketplaceFormat::Codex);
}
if entry_markers.iter().any(|m| {
matches!(
m.source_kind,
Some(SourceMarker::Github)
| Some(SourceMarker::Url)
| Some(SourceMarker::GitSubdir)
| Some(SourceMarker::Npm)
| Some(SourceMarker::Archive)
| Some(SourceMarker::Command)
)
}) {
return Ok(MarketplaceFormat::Claude);
}
if entry_markers
.iter()
.any(|m| m.has_id && matches!(m.source_kind, Some(SourceMarker::String)))
{
return Ok(MarketplaceFormat::Kimi);
}
if entry_markers
.iter()
.any(|m| m.has_name && matches!(m.source_kind, Some(SourceMarker::InstallSpec)))
{
return Ok(MarketplaceFormat::Codewhale);
}
Err(MarketplaceDiagnostic::error(
"AMBIGUOUS_FORMAT",
"catalog markers match no documented format uniquely; pass the format explicitly",
None,
None,
))
}
struct MapMarker {
has_id: bool,
has_name: bool,
source_kind: Option<SourceMarker>,
has_policy: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SourceMarker {
String,
InstallSpec,
Github,
Url,
GitSubdir,
Npm,
Archive,
Command,
Local,
OtherObject,
}
fn source_marker(source: &Value) -> SourceMarker {
match source {
Value::String(s) => {
if s.starts_with("github:") || s.starts_with("path:") {
SourceMarker::InstallSpec
} else {
SourceMarker::String
}
}
Value::Object(o) => match o.get("source").and_then(Value::as_str) {
Some("github") => SourceMarker::Github,
Some("url") => SourceMarker::Url,
Some("git-subdir") => SourceMarker::GitSubdir,
Some("npm") => SourceMarker::Npm,
Some("archive") => SourceMarker::Archive,
Some("command") => SourceMarker::Command,
Some("local") => SourceMarker::Local,
_ => SourceMarker::OtherObject,
},
_ => SourceMarker::OtherObject,
}
}
pub(super) fn str_field<'a>(
entry: &'a serde_json::Map<String, Value>,
field: &str,
) -> (Option<&'a str>, Option<MarketplaceDiagnostic>) {
match entry.get(field) {
None | Some(Value::Null) => (None, None),
Some(Value::String(s)) => (Some(s.as_str()), None),
Some(other) => (
None,
Some(MarketplaceDiagnostic::warning(
"FIELD_TYPE",
format!("field `{field}` must be a string, got {}", type_name(other)),
None,
None,
)),
),
}
}
pub(super) fn str_array_field(
entry: &serde_json::Map<String, Value>,
field: &str,
) -> (Vec<String>, Option<MarketplaceDiagnostic>) {
match entry.get(field) {
None | Some(Value::Null) => (Vec::new(), None),
Some(Value::Array(items)) => {
let mut out = Vec::new();
let mut skipped = false;
for item in items {
match item {
Value::String(s) => out.push(s.clone()),
_ => skipped = true,
}
}
let diag = skipped.then(|| {
MarketplaceDiagnostic::warning(
"FIELD_TYPE",
format!(
"field `{field}` must be an array of strings; non-string items skipped"
),
None,
None,
)
});
(out, diag)
}
Some(other) => (
Vec::new(),
Some(MarketplaceDiagnostic::warning(
"FIELD_TYPE",
format!(
"field `{field}` must be an array of strings, got {}",
type_name(other)
),
None,
None,
)),
),
}
}
pub(super) fn unknown_fields_warning(
entry: &serde_json::Map<String, Value>,
known: &[&str],
) -> Option<MarketplaceDiagnostic> {
let unknown: Vec<&String> = entry
.keys()
.filter(|k| !known.contains(&k.as_str()))
.collect();
if unknown.is_empty() {
return None;
}
Some(MarketplaceDiagnostic::warning(
"UNKNOWN_FIELD",
format!(
"undeclared field(s) ignored: {}",
unknown
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
),
None,
None,
))
}
fn type_name(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "a boolean",
Value::Number(_) => "a number",
Value::String(_) => "a string",
Value::Array(_) => "an array",
Value::Object(_) => "an object",
}
}