| 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 |