返回 CodeWhale
mod.rs
1 //! Catalog parsers, one per real published schema.
2 //!
3 //! Each parser consumes only fields its format documents. Unknown fields
4 //! produce visible warnings, never silent acceptance and never invented
5 //! fallbacks: a field the format does not define is not parsed as some
6 //! plausible equivalent. There is no network access at this layer.
7
8 pub mod claude;
9 pub mod codewhale;
10 pub mod codex;
11 pub mod kimi;
12
13 use serde_json::Value;
14
15 use crate::plugins::manifest::{PluginCompatibility, PluginInventory};
16
17 use super::types::{
18 MarketplaceCatalog, MarketplaceCatalogId, MarketplaceDiagnostic, MarketplaceFormat,
19 };
20
21 /// Catalog-side compatibility. Unlike an installed bundle's reviewed
22 /// inventory, catalog declarations do not state MCP transport, so any
23 /// declared `mcp_servers` count is treated as a supported-capable
24 /// declaration; the install-time review decides the real activation
25 /// policy binding.
26 pub(super) fn declared_catalog_compatibility(declared: &PluginInventory) -> PluginCompatibility {
27 let supported = declared.skills > 0 || declared.mcp_servers > 0;
28 let unsupported = declared.commands > 0
29 || declared.agents > 0
30 || declared.hooks > 0
31 || declared.lsp > 0
32 || declared.native > 0
33 || !declared.filesystem_roots.is_empty()
34 || !declared.network_hosts.is_empty()
35 || declared.lifecycle_mutation;
36 match (supported, unsupported) {
37 (true, false) => PluginCompatibility::Full,
38 (true, true) => PluginCompatibility::Partial,
39 (false, true) => PluginCompatibility::Unsupported,
40 (false, false) => PluginCompatibility::Full,
41 }
42 }
43
44 /// Input to catalog parsing. `base` is where the document was read from
45 /// (path or URL) — kept for install-time resolution of relative sources;
46 /// the parser itself never touches it.
47 pub struct MarketplaceDocument {
48 pub catalog_id: MarketplaceCatalogId,
49 pub format: MarketplaceFormat,
50 pub root: Value,
51 pub base: Option<String>,
52 }
53
54 /// Parse one catalog document. `MarketplaceFormat::Auto` detects the
55 /// format from documented structural markers only, and reports ambiguity
56 /// as an error diagnostic rather than guessing.
57 pub fn parse_catalog(document: MarketplaceDocument) -> MarketplaceCatalog {
58 let format = match document.format {
59 MarketplaceFormat::Auto => match detect_format(&document.root) {
60 Ok(format) => format,
61 Err(diagnostic) => {
62 let name = document.catalog_id.as_str().to_string();
63 return MarketplaceCatalog {
64 id: document.catalog_id,
65 format: MarketplaceFormat::Auto,
66 name,
67 display_name: None,
68 description: None,
69 version: None,
70 base: document.base,
71 provenance: super::types::CatalogProvenance::default(),
72 candidates: Vec::new(),
73 diagnostics: vec![diagnostic],
74 };
75 }
76 },
77 explicit => explicit,
78 };
79
80 match format {
81 MarketplaceFormat::Kimi => kimi::parse_kimi_catalog(document),
82 MarketplaceFormat::Claude => claude::parse_claude_catalog(document),
83 MarketplaceFormat::Codex => codex::parse_codex_catalog(document),
84 MarketplaceFormat::Codewhale => codewhale::parse_codewhale_catalog(document),
85 MarketplaceFormat::Auto => unreachable!("resolved above"),
86 }
87 }
88
89 /// Detection uses only markers each format's own documentation defines:
90 ///
91 /// - **Kimi**: `plugins[]` whose entries carry `id` + `source` (Kimi uses
92 /// `id`; the Claude-family formats use `name`).
93 /// - **Claude**: top-level `owner` object, a `plugins[]` entry with a
94 /// `source` object using the Claude discriminators (`github`, `url`,
95 /// `git-subdir`, `npm`, `archive`, `command`), or `metadata.pluginRoot`.
96 /// - **Codex**: a `plugins[]` entry with a `policy` object, a `source`
97 /// object with the `local` discriminator, or a top-level `interface`
98 /// object.
99 /// - **Codewhale**: `plugins[]` entries with `name` + a string `source`
100 /// that is a Codewhale install spec (`github:`, `path:`, URL).
101 ///
102 /// Documents matching no documented marker are ambiguous, not guessed.
103 fn detect_format(root: &Value) -> Result<MarketplaceFormat, MarketplaceDiagnostic> {
104 let Some(obj) = root.as_object() else {
105 return Err(MarketplaceDiagnostic::error(
106 "NOT_AN_OBJECT",
107 "marketplace catalog must be a JSON object",
108 None,
109 None,
110 ));
111 };
112 let Some(entries) = obj.get("plugins").and_then(Value::as_array) else {
113 return Err(MarketplaceDiagnostic::error(
114 "UNKNOWN_FORMAT",
115 "catalog has no documented marker: expected Kimi `plugins` with `id` entries, \
116 Claude `owner`/`plugins`, Codex `policy`/`interface`, or Codewhale `plugins` \
117 with install-spec sources",
118 None,
119 None,
120 ));
121 };
122
123 // Claude: `owner` and `metadata.pluginRoot` are documented top-level
124 // fields no other format defines.
125 if obj.contains_key("owner")
126 || obj
127 .get("metadata")
128 .and_then(|m| m.get("pluginRoot"))
129 .is_some()
130 {
131 return Ok(MarketplaceFormat::Claude);
132 }
133
134 let entry_markers: Vec<MapMarker> = entries
135 .iter()
136 .map(|entry| {
137 let entry_obj = entry.as_object();
138 MapMarker {
139 has_id: entry_obj.is_some_and(|o| o.contains_key("id")),
140 has_name: entry_obj.is_some_and(|o| o.contains_key("name")),
141 source_kind: entry_obj.and_then(|o| o.get("source")).map(source_marker),
142 has_policy: entry_obj.is_some_and(|o| o.contains_key("policy")),
143 }
144 })
145 .collect();
146
147 // Codex: `policy` blocks or `local` sources are Codex-only markers.
148 if entry_markers
149 .iter()
150 .any(|m| m.has_policy || m.source_kind == Some(SourceMarker::Local))
151 || obj.contains_key("interface")
152 {
153 return Ok(MarketplaceFormat::Codex);
154 }
155
156 // Claude source discriminators other than `local`.
157 if entry_markers.iter().any(|m| {
158 matches!(
159 m.source_kind,
160 Some(SourceMarker::Github)
161 | Some(SourceMarker::Url)
162 | Some(SourceMarker::GitSubdir)
163 | Some(SourceMarker::Npm)
164 | Some(SourceMarker::Archive)
165 | Some(SourceMarker::Command)
166 )
167 }) {
168 return Ok(MarketplaceFormat::Claude);
169 }
170
171 // Kimi: `id`-keyed entries with string sources.
172 if entry_markers.iter().any(|m| {
173 m.has_id
174 && matches!(
175 m.source_kind,
176 Some(SourceMarker::String | SourceMarker::UrlString)
177 )
178 }) {
179 return Ok(MarketplaceFormat::Kimi);
180 }
181
182 // Codewhale: `name` entries with install-spec string sources.
183 if entry_markers.iter().any(|m| {
184 m.has_name
185 && matches!(
186 m.source_kind,
187 Some(SourceMarker::InstallSpec | SourceMarker::UrlString)
188 )
189 }) {
190 return Ok(MarketplaceFormat::Codewhale);
191 }
192
193 Err(MarketplaceDiagnostic::error(
194 "AMBIGUOUS_FORMAT",
195 "catalog markers match no documented format uniquely; pass the format explicitly",
196 None,
197 None,
198 ))
199 }
200
201 struct MapMarker {
202 has_id: bool,
203 has_name: bool,
204 source_kind: Option<SourceMarker>,
205 has_policy: bool,
206 }
207
208 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
209 enum SourceMarker {
210 String,
211 UrlString,
212 InstallSpec,
213 Github,
214 Url,
215 GitSubdir,
216 Npm,
217 Archive,
218 Command,
219 Local,
220 OtherObject,
221 }
222
223 fn source_marker(source: &Value) -> SourceMarker {
224 match source {
225 Value::String(s) => {
226 if s.starts_with("github:") || s.starts_with("path:") {
227 SourceMarker::InstallSpec
228 } else if s.starts_with("https://") || s.starts_with("http://") {
229 SourceMarker::UrlString
230 } else {
231 SourceMarker::String
232 }
233 }
234 Value::Object(o) => match o.get("source").and_then(Value::as_str) {
235 Some("github") => SourceMarker::Github,
236 Some("url") => SourceMarker::Url,
237 Some("git-subdir") => SourceMarker::GitSubdir,
238 Some("npm") => SourceMarker::Npm,
239 Some("archive") => SourceMarker::Archive,
240 Some("command") => SourceMarker::Command,
241 Some("local") => SourceMarker::Local,
242 _ => SourceMarker::OtherObject,
243 },
244 _ => SourceMarker::OtherObject,
245 }
246 }
247
248 /// Shared helper: read a documented string field; a wrong-typed value is
249 /// a per-entry warning and treated as absent, not a guess.
250 pub(super) fn str_field<'a>(
251 entry: &'a serde_json::Map<String, Value>,
252 field: &str,
253 ) -> (Option<&'a str>, Option<MarketplaceDiagnostic>) {
254 match entry.get(field) {
255 None | Some(Value::Null) => (None, None),
256 Some(Value::String(s)) => (Some(s.as_str()), None),
257 Some(other) => (
258 None,
259 Some(MarketplaceDiagnostic::warning(
260 "FIELD_TYPE",
261 format!("field `{field}` must be a string, got {}", type_name(other)),
262 None,
263 None,
264 )),
265 ),
266 }
267 }
268
269 /// Shared helper: read a documented string-array field.
270 pub(super) fn str_array_field(
271 entry: &serde_json::Map<String, Value>,
272 field: &str,
273 ) -> (Vec<String>, Option<MarketplaceDiagnostic>) {
274 match entry.get(field) {
275 None | Some(Value::Null) => (Vec::new(), None),
276 Some(Value::Array(items)) => {
277 let mut out = Vec::new();
278 let mut skipped = false;
279 for item in items {
280 match item {
281 Value::String(s) => out.push(s.clone()),
282 _ => skipped = true,
283 }
284 }
285 let diag = skipped.then(|| {
286 MarketplaceDiagnostic::warning(
287 "FIELD_TYPE",
288 format!(
289 "field `{field}` must be an array of strings; non-string items skipped"
290 ),
291 None,
292 None,
293 )
294 });
295 (out, diag)
296 }
297 Some(other) => (
298 Vec::new(),
299 Some(MarketplaceDiagnostic::warning(
300 "FIELD_TYPE",
301 format!(
302 "field `{field}` must be an array of strings, got {}",
303 type_name(other)
304 ),
305 None,
306 None,
307 )),
308 ),
309 }
310 }
311
312 /// Shared helper: warn on fields this format does not document. Unknown
313 /// fields are preserved in diagnostics so catalog authors see them; they
314 /// are never silently reinterpreted.
315 pub(super) fn unknown_fields_warning(
316 entry: &serde_json::Map<String, Value>,
317 known: &[&str],
318 ) -> Option<MarketplaceDiagnostic> {
319 let unknown: Vec<&String> = entry
320 .keys()
321 .filter(|k| !known.contains(&k.as_str()))
322 .collect();
323 if unknown.is_empty() {
324 return None;
325 }
326 Some(MarketplaceDiagnostic::warning(
327 "UNKNOWN_FIELD",
328 format!(
329 "undeclared field(s) ignored: {}",
330 unknown
331 .iter()
332 .map(|s| s.as_str())
333 .collect::<Vec<_>>()
334 .join(", ")
335 ),
336 None,
337 None,
338 ))
339 }
340
341 fn type_name(value: &Value) -> &'static str {
342 match value {
343 Value::Null => "null",
344 Value::Bool(_) => "a boolean",
345 Value::Number(_) => "a number",
346 Value::String(_) => "a string",
347 Value::Array(_) => "an array",
348 Value::Object(_) => "an object",
349 }
350 }
351
351 lines RUST