| 1 | //! Codex `.agents/plugins/marketplace.json` parser. |
| 2 | //! |
| 3 | //! Schema source: OpenAI plugin packaging docs |
| 4 | //! (developers.openai.com/plugins/build/plugins). The marketplace file |
| 5 | //! lives at `$REPO_ROOT/.agents/plugins/marketplace.json`, |
| 6 | //! `~/.agents/plugins/marketplace.json`, or legacy |
| 7 | //! `.claude-plugin/marketplace.json`. |
| 8 | //! |
| 9 | //! ```json |
| 10 | //! { |
| 11 | //! "name": "local-repo", |
| 12 | //! "plugins": [ |
| 13 | //! { |
| 14 | //! "name": "my-plugin", |
| 15 | //! "source": { "source": "local", "path": "./plugins/my-plugin" }, |
| 16 | //! "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" }, |
| 17 | //! "category": "Productivity" |
| 18 | //! } |
| 19 | //! ] |
| 20 | //! } |
| 21 | //! ``` |
| 22 | //! |
| 23 | //! Top level: `name`; optional `interface` (`displayName`, …). Entry: |
| 24 | //! `name`, `source` object (`local` | `url` | `git-subdir` | `npm`), |
| 25 | //! optional `policy` (`installation`: `AVAILABLE` | |
| 26 | //! `INSTALLED_BY_DEFAULT` | `NOT_AVAILABLE`; `authentication`), optional |
| 27 | //! `category`. |
| 28 | //! |
| 29 | //! `policy` is display-only in Codewhale: `INSTALLED_BY_DEFAULT` never |
| 30 | //! triggers an install, and `NOT_AVAILABLE` only downgrades the install |
| 31 | //! plan with an honest reason. |
| 32 | |
| 33 | use serde_json::Value; |
| 34 | |
| 35 | use crate::plugins::agent_plugin::{is_standard_plugin_name, slugify_plugin_name}; |
| 36 | |
| 37 | use super::super::types::{ |
| 38 | CatalogProvenance, CatalogTier, MarketplaceCandidate, MarketplaceCandidateId, |
| 39 | MarketplaceCatalog, MarketplaceDiagnostic, MarketplaceEntryKind, MarketplaceFormat, |
| 40 | MarketplaceInstallPlan, MarketplaceSourceSpec, |
| 41 | }; |
| 42 | use super::{MarketplaceDocument, str_field, unknown_fields_warning}; |
| 43 | |
| 44 | const TOP_LEVEL_FIELDS: &[&str] = &["name", "plugins", "interface"]; |
| 45 | const ENTRY_FIELDS: &[&str] = &["name", "source", "policy", "category"]; |
| 46 | |
| 47 | pub fn parse_codex_catalog(document: MarketplaceDocument) -> MarketplaceCatalog { |
| 48 | let MarketplaceDocument { |
| 49 | catalog_id, |
| 50 | root, |
| 51 | base, |
| 52 | .. |
| 53 | } = document; |
| 54 | let mut diagnostics = Vec::new(); |
| 55 | |
| 56 | let Some(obj) = root.as_object() else { |
| 57 | return empty_catalog( |
| 58 | catalog_id, |
| 59 | base, |
| 60 | MarketplaceDiagnostic::error( |
| 61 | "NOT_AN_OBJECT", |
| 62 | "Codex marketplace must be a JSON object", |
| 63 | None, |
| 64 | None, |
| 65 | ), |
| 66 | ); |
| 67 | }; |
| 68 | |
| 69 | if let Some(diag) = unknown_fields_warning(obj, TOP_LEVEL_FIELDS) { |
| 70 | diagnostics.push(diag); |
| 71 | } |
| 72 | |
| 73 | let (name, bad_name) = str_field(obj, "name"); |
| 74 | if let Some(diag) = bad_name { |
| 75 | diagnostics.push(diag); |
| 76 | } |
| 77 | let name = name |
| 78 | .map(ToString::to_string) |
| 79 | .unwrap_or_else(|| catalog_id.as_str().to_string()); |
| 80 | |
| 81 | let display_name = obj |
| 82 | .get("interface") |
| 83 | .and_then(|i| i.get("displayName")) |
| 84 | .and_then(Value::as_str) |
| 85 | .map(ToString::to_string); |
| 86 | |
| 87 | let Some(entries) = obj.get("plugins").and_then(Value::as_array) else { |
| 88 | diagnostics.push(MarketplaceDiagnostic::error( |
| 89 | "MISSING_PLUGINS", |
| 90 | "Codex marketplace must contain a `plugins` array", |
| 91 | None, |
| 92 | None, |
| 93 | )); |
| 94 | return MarketplaceCatalog { |
| 95 | id: catalog_id, |
| 96 | format: MarketplaceFormat::Codex, |
| 97 | name, |
| 98 | display_name, |
| 99 | description: None, |
| 100 | version: None, |
| 101 | base, |
| 102 | provenance: CatalogProvenance::default(), |
| 103 | candidates: Vec::new(), |
| 104 | diagnostics, |
| 105 | }; |
| 106 | }; |
| 107 | |
| 108 | let mut candidates = Vec::new(); |
| 109 | for (index, entry) in entries.iter().enumerate() { |
| 110 | if let Some(candidate) = parse_codex_entry(&catalog_id, index, entry, &mut diagnostics) { |
| 111 | candidates.push(candidate); |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | MarketplaceCatalog { |
| 116 | id: catalog_id, |
| 117 | format: MarketplaceFormat::Codex, |
| 118 | name, |
| 119 | display_name, |
| 120 | description: None, |
| 121 | version: None, |
| 122 | base, |
| 123 | provenance: CatalogProvenance { |
| 124 | tier: CatalogTier::Community, |
| 125 | publisher: Some("Codex marketplace".to_string()), |
| 126 | source_url: None, |
| 127 | }, |
| 128 | candidates, |
| 129 | diagnostics, |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | fn empty_catalog( |
| 134 | catalog_id: super::super::types::MarketplaceCatalogId, |
| 135 | base: Option<String>, |
| 136 | diagnostic: MarketplaceDiagnostic, |
| 137 | ) -> MarketplaceCatalog { |
| 138 | MarketplaceCatalog { |
| 139 | id: catalog_id, |
| 140 | format: MarketplaceFormat::Codex, |
| 141 | name: String::new(), |
| 142 | display_name: None, |
| 143 | description: None, |
| 144 | version: None, |
| 145 | base, |
| 146 | provenance: CatalogProvenance::default(), |
| 147 | candidates: Vec::new(), |
| 148 | diagnostics: vec![diagnostic], |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | fn parse_codex_entry( |
| 153 | catalog_id: &super::super::types::MarketplaceCatalogId, |
| 154 | index: usize, |
| 155 | entry: &Value, |
| 156 | diagnostics: &mut Vec<MarketplaceDiagnostic>, |
| 157 | ) -> Option<MarketplaceCandidate> { |
| 158 | let Some(obj) = entry.as_object() else { |
| 159 | diagnostics.push(MarketplaceDiagnostic::error( |
| 160 | "MALFORMED_ENTRY", |
| 161 | format!("Codex plugin at index {index} must be a JSON object"), |
| 162 | None, |
| 163 | Some(index), |
| 164 | )); |
| 165 | return None; |
| 166 | }; |
| 167 | |
| 168 | let mut entry_diags = Vec::new(); |
| 169 | if let Some(diag) = unknown_fields_warning(obj, ENTRY_FIELDS) { |
| 170 | entry_diags.push(diag); |
| 171 | } |
| 172 | |
| 173 | let (raw_name, bad_name) = str_field(obj, "name"); |
| 174 | if let Some(diag) = bad_name { |
| 175 | entry_diags.push(diag); |
| 176 | } |
| 177 | let Some(raw_name) = raw_name else { |
| 178 | diagnostics.push(MarketplaceDiagnostic::error( |
| 179 | "MISSING_NAME", |
| 180 | format!("Codex plugin at index {index} is missing required `name`"), |
| 181 | None, |
| 182 | Some(index), |
| 183 | )); |
| 184 | return None; |
| 185 | }; |
| 186 | let name = if is_standard_plugin_name(raw_name) { |
| 187 | raw_name.to_string() |
| 188 | } else { |
| 189 | match slugify_plugin_name(raw_name) { |
| 190 | Ok(slug) => { |
| 191 | entry_diags.push(MarketplaceDiagnostic::warning( |
| 192 | "NON_STANDARD_NAME", |
| 193 | format!("Codex name `{raw_name}` normalized to `{slug}`"), |
| 194 | Some(slug.clone()), |
| 195 | Some(index), |
| 196 | )); |
| 197 | slug |
| 198 | } |
| 199 | Err(err) => { |
| 200 | diagnostics.push(MarketplaceDiagnostic::error( |
| 201 | "INVALID_NAME", |
| 202 | format!("Codex name `{raw_name}` cannot be normalized: {err}"), |
| 203 | Some(raw_name.to_string()), |
| 204 | Some(index), |
| 205 | )); |
| 206 | return None; |
| 207 | } |
| 208 | } |
| 209 | }; |
| 210 | |
| 211 | let Some(source_value) = obj.get("source") else { |
| 212 | diagnostics.push(MarketplaceDiagnostic::error( |
| 213 | "MISSING_SOURCE", |
| 214 | format!("Codex plugin `{name}` is missing required `source`"), |
| 215 | Some(name.clone()), |
| 216 | Some(index), |
| 217 | )); |
| 218 | return None; |
| 219 | }; |
| 220 | let (source, mut install_plan, source_diags) = normalize_codex_source(source_value); |
| 221 | entry_diags.extend(source_diags); |
| 222 | |
| 223 | // `policy.installation` is honored as display/availability metadata |
| 224 | // only. Codewhale never auto-installs and never auto-authenticates. |
| 225 | let installation = obj |
| 226 | .get("policy") |
| 227 | .and_then(|p| p.get("installation")) |
| 228 | .and_then(Value::as_str); |
| 229 | match installation { |
| 230 | Some("NOT_AVAILABLE") => { |
| 231 | if install_plan.is_supported() { |
| 232 | install_plan = MarketplaceInstallPlan::Unsupported { |
| 233 | reason: "catalog policy marks this plugin NOT_AVAILABLE".to_string(), |
| 234 | raw: source_value.to_string(), |
| 235 | }; |
| 236 | } |
| 237 | } |
| 238 | Some("INSTALLED_BY_DEFAULT") => { |
| 239 | entry_diags.push(MarketplaceDiagnostic::warning( |
| 240 | "NO_AUTO_INSTALL", |
| 241 | "Codex policy `INSTALLED_BY_DEFAULT` is ignored: Codewhale installs only on an explicit operator action".to_string(), |
| 242 | Some(name.clone()), |
| 243 | Some(index), |
| 244 | )); |
| 245 | } |
| 246 | Some("AVAILABLE") | None => {} |
| 247 | Some(other) => { |
| 248 | entry_diags.push(MarketplaceDiagnostic::warning( |
| 249 | "UNKNOWN_POLICY", |
| 250 | format!( |
| 251 | "Codex installation policy `{other}` is not documented; treated as AVAILABLE" |
| 252 | ), |
| 253 | Some(name.clone()), |
| 254 | Some(index), |
| 255 | )); |
| 256 | } |
| 257 | } |
| 258 | |
| 259 | let (category, bad_category) = str_field(obj, "category"); |
| 260 | if let Some(diag) = bad_category { |
| 261 | entry_diags.push(diag); |
| 262 | } |
| 263 | |
| 264 | Some(MarketplaceCandidate { |
| 265 | id: MarketplaceCandidateId::new(catalog_id, &name), |
| 266 | catalog_id: catalog_id.clone(), |
| 267 | kind: MarketplaceEntryKind::Plugin, |
| 268 | icon: None, |
| 269 | name, |
| 270 | display_name: None, |
| 271 | description: None, |
| 272 | version: None, |
| 273 | author: None, |
| 274 | homepage: None, |
| 275 | repository: None, |
| 276 | license: None, |
| 277 | keywords: Vec::new(), |
| 278 | categories: category.map(|c| vec![c.to_string()]).unwrap_or_default(), |
| 279 | source, |
| 280 | install_plan, |
| 281 | // Codex marketplace entries declare no component counts; the |
| 282 | // `.codex-plugin/plugin.json` manifest at install time decides. |
| 283 | declared_components: None, |
| 284 | compatibility: None, |
| 285 | provenance: CatalogProvenance { |
| 286 | tier: CatalogTier::Community, |
| 287 | publisher: Some("Codex marketplace".to_string()), |
| 288 | source_url: None, |
| 289 | }, |
| 290 | when: None, |
| 291 | diagnostics: entry_diags, |
| 292 | }) |
| 293 | } |
| 294 | |
| 295 | /// Only the four documented Codex source discriminators are accepted. |
| 296 | fn normalize_codex_source( |
| 297 | value: &Value, |
| 298 | ) -> ( |
| 299 | MarketplaceSourceSpec, |
| 300 | MarketplaceInstallPlan, |
| 301 | Vec<MarketplaceDiagnostic>, |
| 302 | ) { |
| 303 | let mut diags = Vec::new(); |
| 304 | let Some(obj) = value.as_object() else { |
| 305 | return ( |
| 306 | MarketplaceSourceSpec::Invalid { |
| 307 | reason: "Codex source must be a tagged object".to_string(), |
| 308 | }, |
| 309 | MarketplaceInstallPlan::Unsupported { |
| 310 | reason: "Codex source must be a `{source: local|url|git-subdir|npm}` object" |
| 311 | .to_string(), |
| 312 | raw: value.to_string(), |
| 313 | }, |
| 314 | diags, |
| 315 | ); |
| 316 | }; |
| 317 | let discriminator = obj |
| 318 | .get("source") |
| 319 | .and_then(Value::as_str) |
| 320 | .unwrap_or_default(); |
| 321 | let field = |name: &str| obj.get(name).and_then(Value::as_str); |
| 322 | match discriminator { |
| 323 | "local" => { |
| 324 | let Some(path) = field("path") else { |
| 325 | return invalid("local source requires `path`", value, &mut diags); |
| 326 | }; |
| 327 | ( |
| 328 | MarketplaceSourceSpec::LocalPath { path: path.into() }, |
| 329 | MarketplaceInstallPlan::Supported { |
| 330 | spec: format!("path:{path}"), |
| 331 | source_kind: "Local directory".to_string(), |
| 332 | }, |
| 333 | diags, |
| 334 | ) |
| 335 | } |
| 336 | "url" | "git-subdir" => { |
| 337 | let Some(url) = field("url") else { |
| 338 | return invalid( |
| 339 | &format!("{discriminator} source requires `url`"), |
| 340 | value, |
| 341 | &mut diags, |
| 342 | ); |
| 343 | }; |
| 344 | if let Some(rest) = url |
| 345 | .strip_prefix("https://github.com/") |
| 346 | .or_else(|| url.strip_prefix("http://github.com/")) |
| 347 | { |
| 348 | let mut parts = rest.trim_end_matches('/').split('/'); |
| 349 | if let (Some(owner), Some(repo), None) = (parts.next(), parts.next(), parts.next()) |
| 350 | { |
| 351 | let repo = repo.trim_end_matches(".git"); |
| 352 | if !owner.is_empty() && !repo.is_empty() { |
| 353 | diags.push(MarketplaceDiagnostic::warning( |
| 354 | "UNAPPLIED_PIN", |
| 355 | "Codewhale's GitHub installer resolves the default branch; ref/sha pins are recorded but not yet applied".to_string(), |
| 356 | None, |
| 357 | None, |
| 358 | )); |
| 359 | return ( |
| 360 | MarketplaceSourceSpec::GitHub { |
| 361 | owner: owner.to_string(), |
| 362 | repo: repo.to_string(), |
| 363 | git_ref: field("ref").map(ToString::to_string), |
| 364 | sha: field("sha").map(ToString::to_string), |
| 365 | }, |
| 366 | MarketplaceInstallPlan::Supported { |
| 367 | spec: format!("github:{owner}/{repo}"), |
| 368 | source_kind: "GitHub repository".to_string(), |
| 369 | }, |
| 370 | diags, |
| 371 | ); |
| 372 | } |
| 373 | } |
| 374 | } |
| 375 | ( |
| 376 | MarketplaceSourceSpec::GitUrl { |
| 377 | url: url.to_string(), |
| 378 | }, |
| 379 | MarketplaceInstallPlan::Unsupported { |
| 380 | reason: format!( |
| 381 | "{discriminator} installs of non-GitHub git URLs are not supported yet" |
| 382 | ), |
| 383 | raw: value.to_string(), |
| 384 | }, |
| 385 | diags, |
| 386 | ) |
| 387 | } |
| 388 | "npm" => { |
| 389 | let Some(package) = field("package") else { |
| 390 | return invalid("npm source requires `package`", value, &mut diags); |
| 391 | }; |
| 392 | ( |
| 393 | MarketplaceSourceSpec::Npm { package: package.to_string() }, |
| 394 | MarketplaceInstallPlan::Unsupported { |
| 395 | reason: "Codewhale does not execute npm; install the plugin from a GitHub repo, tarball, or local path".to_string(), |
| 396 | raw: value.to_string(), |
| 397 | }, |
| 398 | diags, |
| 399 | ) |
| 400 | } |
| 401 | other => invalid( |
| 402 | &format!("unknown Codex source discriminator `{other}`"), |
| 403 | value, |
| 404 | &mut diags, |
| 405 | ), |
| 406 | } |
| 407 | } |
| 408 | |
| 409 | fn invalid( |
| 410 | reason: &str, |
| 411 | value: &Value, |
| 412 | diags: &mut Vec<MarketplaceDiagnostic>, |
| 413 | ) -> ( |
| 414 | MarketplaceSourceSpec, |
| 415 | MarketplaceInstallPlan, |
| 416 | Vec<MarketplaceDiagnostic>, |
| 417 | ) { |
| 418 | diags.push(MarketplaceDiagnostic::error( |
| 419 | "INVALID_SOURCE", |
| 420 | reason.to_string(), |
| 421 | None, |
| 422 | None, |
| 423 | )); |
| 424 | ( |
| 425 | MarketplaceSourceSpec::Invalid { |
| 426 | reason: reason.to_string(), |
| 427 | }, |
| 428 | MarketplaceInstallPlan::Unsupported { |
| 429 | reason: reason.to_string(), |
| 430 | raw: value.to_string(), |
| 431 | }, |
| 432 | std::mem::take(diags), |
| 433 | ) |
| 434 | } |
| 435 |