返回 CodeWhale
claude.rs
根目录 / crates / tui / src / plugins / marketplace / parsers / claude.rs
1 //! Claude `.claude-plugin/marketplace.json` parser.
2 //!
3 //! Schema source: <https://code.claude.com/docs/en/plugin-marketplaces>
4 //!
5 //! Top level: `name` (kebab-case, required), `owner` (`{name`, `email?`,
6 //! `url?}`, required), `plugins[]` (required); optional `$schema`,
7 //! `description`, `version`, `metadata.pluginRoot`, `renames`.
8 //!
9 //! Plugin entry: `name` (kebab-case) and `source` (required). `source`
10 //! is either a `./`-relative string or an object tagged with its
11 //! `source` discriminator: `github` (`repo`, `ref?`, `sha?`), `url`
12 //! (`url`, `ref?`, `sha?`), `git-subdir` (`url`, `path`, `ref?`, `sha?`),
13 //! `npm` (`package`, `version?`, `registry?`), `archive` (`url`,
14 //! `sha256?`), `command` (`command`, `timeout?`, `mode?`). Optional
15 //! entry fields: `displayName`, `description`, `version`, `author`,
16 //! `homepage`, `repository`, `license`, `keywords`, `category`, `tags`,
17 //! `strict`, and component config (`skills`, `commands`, `agents`,
18 //! `hooks`, `mcpServers`, `lspServers`) which this parser counts for
19 //! display only.
20 //!
21 //! No invented `entries`, `npm_package`, or `download_url` fields exist
22 //! in this format and none are parsed.
23
24 use serde_json::Value;
25
26 use crate::plugins::agent_plugin::{is_standard_plugin_name, slugify_plugin_name};
27 use crate::plugins::manifest::PluginInventory;
28
29 use super::super::types::{
30 CatalogProvenance, CatalogTier, MarketplaceCandidate, MarketplaceCandidateId,
31 MarketplaceCatalog, MarketplaceDiagnostic, MarketplaceEntryKind, MarketplaceFormat,
32 MarketplaceInstallPlan, MarketplaceSourceSpec,
33 };
34 use super::{MarketplaceDocument, str_array_field, str_field, unknown_fields_warning};
35
36 const TOP_LEVEL_FIELDS: &[&str] = &[
37 "$schema",
38 "name",
39 "owner",
40 "plugins",
41 "description",
42 "version",
43 "metadata",
44 "renames",
45 "allowCrossMarketplaceDependenciesOn",
46 ];
47 const ENTRY_FIELDS: &[&str] = &[
48 "name",
49 "source",
50 "displayName",
51 "description",
52 "version",
53 "author",
54 "homepage",
55 "repository",
56 "license",
57 "keywords",
58 "category",
59 "tags",
60 "strict",
61 "relevance",
62 "defaultEnabled",
63 "skills",
64 "commands",
65 "agents",
66 "hooks",
67 "mcpServers",
68 "lspServers",
69 "metadata",
70 ];
71
72 pub fn parse_claude_catalog(document: MarketplaceDocument) -> MarketplaceCatalog {
73 let MarketplaceDocument {
74 catalog_id,
75 root,
76 base,
77 ..
78 } = document;
79 let mut diagnostics = Vec::new();
80
81 let Some(obj) = root.as_object() else {
82 return empty_catalog(
83 catalog_id,
84 base,
85 MarketplaceDiagnostic::error(
86 "NOT_AN_OBJECT",
87 "Claude marketplace must be a JSON object",
88 None,
89 None,
90 ),
91 );
92 };
93
94 if let Some(diag) = unknown_fields_warning(obj, TOP_LEVEL_FIELDS) {
95 diagnostics.push(diag);
96 }
97
98 let (name, bad_name) = str_field(obj, "name");
99 if let Some(diag) = bad_name {
100 diagnostics.push(diag);
101 }
102 let name = name
103 .map(ToString::to_string)
104 .unwrap_or_else(|| catalog_id.as_str().to_string());
105
106 let (description, bad_desc) = str_field(obj, "description");
107 if let Some(diag) = bad_desc {
108 diagnostics.push(diag);
109 }
110 let (version, bad_version) = str_field(obj, "version");
111 if let Some(diag) = bad_version {
112 diagnostics.push(diag);
113 }
114
115 // `owner` is a documented required object with `name` required.
116 let publisher = obj
117 .get("owner")
118 .and_then(|o| o.get("name"))
119 .and_then(|n| n.as_str());
120 if obj
121 .get("owner")
122 .is_some_and(|o| o.get("name").and_then(Value::as_str).is_none())
123 {
124 diagnostics.push(MarketplaceDiagnostic::warning(
125 "FIELD_TYPE",
126 "Claude `owner.name` is missing or not a string",
127 None,
128 None,
129 ));
130 }
131
132 let Some(entries) = obj.get("plugins").and_then(Value::as_array) else {
133 diagnostics.push(MarketplaceDiagnostic::error(
134 "MISSING_PLUGINS",
135 "Claude marketplace must contain a `plugins` array",
136 None,
137 None,
138 ));
139 return MarketplaceCatalog {
140 id: catalog_id,
141 format: MarketplaceFormat::Claude,
142 name,
143 display_name: None,
144 description: description.map(ToString::to_string),
145 version: version.map(ToString::to_string),
146 base,
147 provenance: CatalogProvenance::default(),
148 candidates: Vec::new(),
149 diagnostics,
150 };
151 };
152
153 let mut candidates = Vec::new();
154 for (index, entry) in entries.iter().enumerate() {
155 if let Some(candidate) =
156 parse_claude_entry(&catalog_id, index, entry, publisher, &mut diagnostics)
157 {
158 candidates.push(candidate);
159 }
160 }
161
162 MarketplaceCatalog {
163 id: catalog_id,
164 format: MarketplaceFormat::Claude,
165 name,
166 display_name: None,
167 description: description.map(ToString::to_string),
168 version: version.map(ToString::to_string),
169 base,
170 provenance: CatalogProvenance {
171 tier: CatalogTier::Community,
172 publisher: publisher.map(ToString::to_string),
173 source_url: None,
174 },
175 candidates,
176 diagnostics,
177 }
178 }
179
180 fn empty_catalog(
181 catalog_id: super::super::types::MarketplaceCatalogId,
182 base: Option<String>,
183 diagnostic: MarketplaceDiagnostic,
184 ) -> MarketplaceCatalog {
185 MarketplaceCatalog {
186 id: catalog_id,
187 format: MarketplaceFormat::Claude,
188 name: String::new(),
189 display_name: None,
190 description: None,
191 version: None,
192 base,
193 provenance: CatalogProvenance::default(),
194 candidates: Vec::new(),
195 diagnostics: vec![diagnostic],
196 }
197 }
198
199 fn parse_claude_entry(
200 catalog_id: &super::super::types::MarketplaceCatalogId,
201 index: usize,
202 entry: &Value,
203 catalog_publisher: Option<&str>,
204 diagnostics: &mut Vec<MarketplaceDiagnostic>,
205 ) -> Option<MarketplaceCandidate> {
206 let Some(obj) = entry.as_object() else {
207 diagnostics.push(MarketplaceDiagnostic::error(
208 "MALFORMED_ENTRY",
209 format!("Claude plugin at index {index} must be a JSON object"),
210 None,
211 Some(index),
212 ));
213 return None;
214 };
215
216 let mut entry_diags = Vec::new();
217 if let Some(diag) = unknown_fields_warning(obj, ENTRY_FIELDS) {
218 entry_diags.push(diag);
219 }
220
221 let (raw_name, bad_name) = str_field(obj, "name");
222 if let Some(diag) = bad_name {
223 entry_diags.push(diag);
224 }
225 let Some(raw_name) = raw_name else {
226 diagnostics.push(MarketplaceDiagnostic::error(
227 "MISSING_NAME",
228 format!("Claude plugin at index {index} is missing required `name`"),
229 None,
230 Some(index),
231 ));
232 return None;
233 };
234 let (name, display_name) = if is_standard_plugin_name(raw_name) {
235 (raw_name.to_string(), None)
236 } else {
237 match slugify_plugin_name(raw_name) {
238 Ok(slug) => {
239 entry_diags.push(MarketplaceDiagnostic::warning(
240 "NON_STANDARD_NAME",
241 format!("Claude name `{raw_name}` normalized to `{slug}`"),
242 Some(slug.clone()),
243 Some(index),
244 ));
245 (slug, Some(raw_name.to_string()))
246 }
247 Err(err) => {
248 diagnostics.push(MarketplaceDiagnostic::error(
249 "INVALID_NAME",
250 format!("Claude name `{raw_name}` cannot be normalized: {err}"),
251 Some(raw_name.to_string()),
252 Some(index),
253 ));
254 return None;
255 }
256 }
257 };
258 let (explicit_display, bad_display) = str_field(obj, "displayName");
259 if let Some(diag) = bad_display {
260 entry_diags.push(diag);
261 }
262 let display_name = explicit_display.map(ToString::to_string).or(display_name);
263
264 let Some(source_value) = obj.get("source") else {
265 diagnostics.push(MarketplaceDiagnostic::error(
266 "MISSING_SOURCE",
267 format!("Claude plugin `{name}` is missing required `source`"),
268 Some(name.clone()),
269 Some(index),
270 ));
271 return None;
272 };
273 let (source, install_plan, source_diags) = normalize_claude_source(source_value);
274 entry_diags.extend(source_diags);
275
276 let (description, bad_desc) = str_field(obj, "description");
277 if let Some(diag) = bad_desc {
278 entry_diags.push(diag);
279 }
280 let (version, bad_version) = str_field(obj, "version");
281 if let Some(diag) = bad_version {
282 entry_diags.push(diag);
283 }
284 let (homepage, bad_home) = str_field(obj, "homepage");
285 if let Some(diag) = bad_home {
286 entry_diags.push(diag);
287 }
288 let (repository, bad_repo) = str_field(obj, "repository");
289 if let Some(diag) = bad_repo {
290 entry_diags.push(diag);
291 }
292 let (license, bad_license) = str_field(obj, "license");
293 if let Some(diag) = bad_license {
294 entry_diags.push(diag);
295 }
296 let (keywords, bad_keywords) = str_array_field(obj, "keywords");
297 if let Some(diag) = bad_keywords {
298 entry_diags.push(diag);
299 }
300 let (tags, bad_tags) = str_array_field(obj, "tags");
301 if let Some(diag) = bad_tags {
302 entry_diags.push(diag);
303 }
304 let (category, bad_category) = str_field(obj, "category");
305 if let Some(diag) = bad_category {
306 entry_diags.push(diag);
307 }
308 let author = obj
309 .get("author")
310 .and_then(|a| a.get("name"))
311 .and_then(|n| n.as_str());
312
313 let (declared, component_diags) = count_declared_components(obj);
314 entry_diags.extend(component_diags);
315 let compatibility = declared.as_ref().map(super::declared_catalog_compatibility);
316
317 if obj.get("strict") == Some(&Value::Bool(false)) {
318 entry_diags.push(MarketplaceDiagnostic::warning(
319 "NON_STRICT_ENTRY",
320 format!("Claude plugin `{name}` sets strict=false; Codewhale always reviews the installed manifest"),
321 Some(name.clone()),
322 Some(index),
323 ));
324 }
325
326 Some(MarketplaceCandidate {
327 id: MarketplaceCandidateId::new(catalog_id, &name),
328 catalog_id: catalog_id.clone(),
329 kind: MarketplaceEntryKind::Plugin,
330 icon: None,
331 name,
332 display_name,
333 description: description.map(ToString::to_string),
334 version: version.map(ToString::to_string),
335 author: author
336 .map(ToString::to_string)
337 .or_else(|| catalog_publisher.map(ToString::to_string)),
338 homepage: homepage.map(ToString::to_string),
339 repository: repository.map(ToString::to_string),
340 license: license.map(ToString::to_string),
341 keywords,
342 categories: category
343 .map(|c| vec![c.to_string()])
344 .into_iter()
345 .flatten()
346 .chain(tags)
347 .collect(),
348 source,
349 install_plan,
350 declared_components: declared,
351 compatibility,
352 provenance: CatalogProvenance {
353 tier: CatalogTier::Community,
354 publisher: author
355 .map(ToString::to_string)
356 .or_else(|| catalog_publisher.map(ToString::to_string)),
357 source_url: None,
358 },
359 when: None,
360 diagnostics: entry_diags,
361 })
362 }
363
364 /// Claude documents per-entry component config arrays (`skills`,
365 /// `commands`, `agents`, `hooks`, `lspServers`) and an `mcpServers` map.
366 /// These are catalog-side declarations; Codewhale counts them for
367 /// display and compatibility only — the reviewed installed manifest
368 /// remains the authority.
369 fn count_declared_components(
370 obj: &serde_json::Map<String, Value>,
371 ) -> (Option<PluginInventory>, Vec<MarketplaceDiagnostic>) {
372 let has_any = [
373 "skills",
374 "commands",
375 "agents",
376 "hooks",
377 "mcpServers",
378 "lspServers",
379 ]
380 .iter()
381 .any(|k| obj.contains_key(*k));
382 if !has_any {
383 return (None, Vec::new());
384 }
385 let mut diags = Vec::new();
386 let count = |key: &str, diags: &mut Vec<MarketplaceDiagnostic>| -> usize {
387 match obj.get(key) {
388 None | Some(Value::Null) => 0,
389 Some(Value::Array(items)) => items.len(),
390 Some(other) => {
391 diags.push(MarketplaceDiagnostic::warning(
392 "FIELD_TYPE",
393 format!("`{key}` must be an array, got {};", json_kind(other)),
394 None,
395 None,
396 ));
397 0
398 }
399 }
400 };
401 let mcp_servers = match obj.get("mcpServers") {
402 None | Some(Value::Null) => 0,
403 Some(Value::Object(map)) => map.len(),
404 Some(other) => {
405 diags.push(MarketplaceDiagnostic::warning(
406 "FIELD_TYPE",
407 format!("`mcpServers` must be an object, got {}", json_kind(other)),
408 None,
409 None,
410 ));
411 0
412 }
413 };
414 (
415 Some(PluginInventory {
416 skills: count("skills", &mut diags),
417 mcp_servers,
418 stdio_mcp_servers: 0,
419 remote_mcp_servers: 0,
420 commands: count("commands", &mut diags),
421 agents: count("agents", &mut diags),
422 hooks: count("hooks", &mut diags),
423 lsp: count("lspServers", &mut diags),
424 native: 0,
425 filesystem_roots: Vec::new(),
426 network_hosts: Vec::new(),
427 lifecycle_mutation: false,
428 }),
429 diags,
430 )
431 }
432
433 /// Normalize a documented Claude `source`. Only the six documented
434 /// discriminators plus the `./`-relative string form are accepted.
435 fn normalize_claude_source(
436 value: &Value,
437 ) -> (
438 MarketplaceSourceSpec,
439 MarketplaceInstallPlan,
440 Vec<MarketplaceDiagnostic>,
441 ) {
442 let mut diags = Vec::new();
443 if let Some(s) = value.as_str() {
444 if s.starts_with("./") || s == "." {
445 return (
446 MarketplaceSourceSpec::LocalPath { path: s.into() },
447 MarketplaceInstallPlan::Supported {
448 spec: format!("path:{s}"),
449 source_kind: "Marketplace-relative directory".to_string(),
450 },
451 diags,
452 );
453 }
454 return (
455 MarketplaceSourceSpec::Invalid {
456 reason: format!("Claude string sources must be `./`-relative (got `{s}`)"),
457 },
458 MarketplaceInstallPlan::Unsupported {
459 reason: "Claude string sources must be `./`-relative paths".to_string(),
460 raw: s.to_string(),
461 },
462 diags,
463 );
464 }
465 let Some(obj) = value.as_object() else {
466 return (
467 MarketplaceSourceSpec::Invalid {
468 reason: "source must be a string or a tagged object".to_string(),
469 },
470 MarketplaceInstallPlan::Unsupported {
471 reason: "Claude source must be a `./` string or a `{source: ...}` object"
472 .to_string(),
473 raw: value.to_string(),
474 },
475 diags,
476 );
477 };
478 let discriminator = obj
479 .get("source")
480 .and_then(Value::as_str)
481 .unwrap_or_default();
482 let field = |name: &str| obj.get(name).and_then(Value::as_str);
483 match discriminator {
484 "github" => {
485 let Some(repo) = field("repo") else {
486 return invalid("github source requires `repo` (\"owner/repo\")", value, &mut diags);
487 };
488 let (owner, repo) = match repo.split_once('/') {
489 Some((o, r)) if !o.is_empty() && !r.is_empty() => (o, r),
490 _ => return invalid("github `repo` must be \"owner/repo\"", value, &mut diags),
491 };
492 let git_ref = field("ref").map(ToString::to_string);
493 let sha = field("sha").map(ToString::to_string);
494 if git_ref.is_some() || sha.is_some() {
495 diags.push(MarketplaceDiagnostic::warning(
496 "UNAPPLIED_PIN",
497 "Codewhale's GitHub installer resolves the default branch; ref/sha pins are recorded but not yet applied".to_string(),
498 None,
499 None,
500 ));
501 }
502 (
503 MarketplaceSourceSpec::GitHub { owner: owner.to_string(), repo: repo.to_string(), git_ref, sha },
504 MarketplaceInstallPlan::Supported {
505 spec: format!("github:{owner}/{repo}"),
506 source_kind: "GitHub repository".to_string(),
507 },
508 diags,
509 )
510 }
511 "url" | "git-subdir" => {
512 let Some(url) = field("url") else {
513 return invalid(&format!("{discriminator} source requires `url`"), value, &mut diags);
514 };
515 if let Some((owner, repo)) = github_url_parts(url) {
516 diags.push(MarketplaceDiagnostic::warning(
517 "UNAPPLIED_PIN",
518 "Codewhale's GitHub installer resolves the default branch; ref/sha pins are recorded but not yet applied".to_string(),
519 None,
520 None,
521 ));
522 let spec = format!("github:{owner}/{repo}");
523 return (
524 MarketplaceSourceSpec::GitHub { owner, repo, git_ref: field("ref").map(ToString::to_string), sha: field("sha").map(ToString::to_string) },
525 MarketplaceInstallPlan::Supported {
526 spec,
527 source_kind: "GitHub repository".to_string(),
528 },
529 diags,
530 );
531 }
532 (
533 MarketplaceSourceSpec::GitUrl { url: url.to_string() },
534 MarketplaceInstallPlan::Unsupported {
535 reason: format!("{discriminator} installs of non-GitHub git URLs are not supported yet"),
536 raw: value.to_string(),
537 },
538 diags,
539 )
540 }
541 "npm" => {
542 let Some(package) = field("package") else {
543 return invalid("npm source requires `package`", value, &mut diags);
544 };
545 (
546 MarketplaceSourceSpec::Npm { package: package.to_string() },
547 MarketplaceInstallPlan::Unsupported {
548 reason: "Codewhale does not execute npm; install the plugin from a GitHub repo, tarball, or local path".to_string(),
549 raw: value.to_string(),
550 },
551 diags,
552 )
553 }
554 "archive" => {
555 let Some(url) = field("url") else {
556 return invalid("archive source requires `url`", value, &mut diags);
557 };
558 if let Some(pin) = field("sha256") {
559 diags.push(MarketplaceDiagnostic::warning(
560 "UNVERIFIED_PIN",
561 format!("archive sha256 pin `{pin}` is recorded but not verified during fetch"),
562 None,
563 None,
564 ));
565 }
566 (
567 MarketplaceSourceSpec::ArchiveUrl { url: url.to_string(), sha256: field("sha256").map(ToString::to_string) },
568 MarketplaceInstallPlan::Supported {
569 spec: url.to_string(),
570 source_kind: "Tarball archive URL".to_string(),
571 },
572 diags,
573 )
574 }
575 "command" => (
576 MarketplaceSourceSpec::Refused {
577 reason: "Claude `command` sources execute arbitrary shell commands and are never run by Codewhale".to_string(),
578 },
579 MarketplaceInstallPlan::Unsupported {
580 reason: "command sources are never executed".to_string(),
581 raw: value.to_string(),
582 },
583 diags,
584 ),
585 other => invalid(
586 &format!("unknown Claude source discriminator `{other}`"),
587 value,
588 &mut diags,
589 ),
590 }
591 }
592
593 fn invalid(
594 reason: &str,
595 value: &Value,
596 diags: &mut Vec<MarketplaceDiagnostic>,
597 ) -> (
598 MarketplaceSourceSpec,
599 MarketplaceInstallPlan,
600 Vec<MarketplaceDiagnostic>,
601 ) {
602 diags.push(MarketplaceDiagnostic::error(
603 "INVALID_SOURCE",
604 reason.to_string(),
605 None,
606 None,
607 ));
608 (
609 MarketplaceSourceSpec::Invalid {
610 reason: reason.to_string(),
611 },
612 MarketplaceInstallPlan::Unsupported {
613 reason: reason.to_string(),
614 raw: value.to_string(),
615 },
616 std::mem::take(diags),
617 )
618 }
619
620 fn github_url_parts(url: &str) -> Option<(String, String)> {
621 let rest = url
622 .strip_prefix("https://github.com/")
623 .or_else(|| url.strip_prefix("http://github.com/"))?;
624 let mut parts = rest.trim_end_matches('/').split('/');
625 match (parts.next(), parts.next(), parts.next()) {
626 (Some(owner), Some(repo), None) if !owner.is_empty() && !repo.is_empty() => {
627 Some((owner.to_string(), repo.trim_end_matches(".git").to_string()))
628 }
629 _ => None,
630 }
631 }
632
633 fn json_kind(value: &Value) -> &'static str {
634 match value {
635 Value::Null => "null",
636 Value::Bool(_) => "a boolean",
637 Value::Number(_) => "a number",
638 Value::String(_) => "a string",
639 Value::Array(_) => "an array",
640 Value::Object(_) => "an object",
641 }
642 }
643
643 lines RUST