| 1 | //! Normalized marketplace catalog model. |
| 2 | //! |
| 3 | //! Every parser (`kimi`, `claude`, `codex`, `codewhale`) funnels foreign |
| 4 | //! catalog entries into [`MarketplaceCandidate`]. Parsers are pure: no |
| 5 | //! network, no filesystem, no process execution. Sources are normalized |
| 6 | //! and mapped onto an install plan, but nothing is fetched here. |
| 7 | //! |
| 8 | //! # Security invariants |
| 9 | //! |
| 10 | //! - Catalog labels (`official`, `curated`, `verified`, `partner`) are |
| 11 | //! display provenance only. They never grant trust, enablement, |
| 12 | //! installation, or any runtime permission. Every installed plugin still |
| 13 | //! enters Codewhale disabled and untrusted and goes through the existing |
| 14 | //! content/capability hash review. |
| 15 | //! - Foreign `policy` blocks (Codex `INSTALLED_BY_DEFAULT`) never trigger |
| 16 | //! auto-install; Codewhale only installs on an explicit operator action. |
| 17 | //! - A malformed entry degrades that entry alone; it never hides the rest |
| 18 | //! of the catalog. |
| 19 | |
| 20 | use std::fmt; |
| 21 | use std::path::PathBuf; |
| 22 | |
| 23 | use serde::{Deserialize, Serialize}; |
| 24 | |
| 25 | use super::super::install::PluginInstallSource; |
| 26 | use super::super::manifest::{PluginCompatibility, PluginInventory, PluginWhen}; |
| 27 | use super::super::types::{PluginDiagnosticLevel, PluginId}; |
| 28 | |
| 29 | /// Which real catalog format a document follows. |
| 30 | /// |
| 31 | /// Every variant corresponds to a published schema; there is no |
| 32 | /// synthetic or guessed format. `Auto` detection only uses documented |
| 33 | /// structural markers and reports ambiguity instead of guessing. |
| 34 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] |
| 35 | #[serde(rename_all = "snake_case")] |
| 36 | pub enum MarketplaceFormat { |
| 37 | /// Detect from documented structural markers; ambiguous documents fail. |
| 38 | #[default] |
| 39 | Auto, |
| 40 | /// Codewhale native catalog: `plugins[]` with `name` + install-spec |
| 41 | /// `source` strings. |
| 42 | Codewhale, |
| 43 | /// Kimi / Moonshot `marketplace.json`: `version` + `plugins[]` with |
| 44 | /// `id`, `displayName`, `tier`, `source` (path / GitHub URL / zip URL). |
| 45 | Kimi, |
| 46 | /// Claude `.claude-plugin/marketplace.json`: `name` + `owner` + |
| 47 | /// `plugins[]`; `source` is a `./`-relative string or a |
| 48 | /// `{source: github|url|git-subdir|npm|archive|command}` object. |
| 49 | Claude, |
| 50 | /// Codex `.agents/plugins/marketplace.json`: `name` + `plugins[]` with |
| 51 | /// `source` objects (`local`/`url`/`git-subdir`/`npm`) and `policy`. |
| 52 | Codex, |
| 53 | } |
| 54 | |
| 55 | impl MarketplaceFormat { |
| 56 | #[must_use] |
| 57 | pub fn as_str(self) -> &'static str { |
| 58 | match self { |
| 59 | Self::Auto => "auto", |
| 60 | Self::Codewhale => "codewhale", |
| 61 | Self::Kimi => "kimi", |
| 62 | Self::Claude => "claude", |
| 63 | Self::Codex => "codex", |
| 64 | } |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | impl fmt::Display for MarketplaceFormat { |
| 69 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 70 | f.write_str(self.as_str()) |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | /// Stable identifier for a marketplace catalog (typically its configured |
| 75 | /// name or path alias). |
| 76 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] |
| 77 | #[serde(transparent)] |
| 78 | pub struct MarketplaceCatalogId(pub String); |
| 79 | |
| 80 | impl MarketplaceCatalogId { |
| 81 | #[must_use] |
| 82 | pub fn new(id: impl Into<String>) -> Self { |
| 83 | Self(id.into()) |
| 84 | } |
| 85 | |
| 86 | #[must_use] |
| 87 | pub fn as_str(&self) -> &str { |
| 88 | &self.0 |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | impl fmt::Display for MarketplaceCatalogId { |
| 93 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 94 | f.write_str(&self.0) |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | /// Stable, deterministic identifier for one catalog entry: |
| 99 | /// `<catalog_id>:<canonical plugin name>`. |
| 100 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] |
| 101 | #[serde(transparent)] |
| 102 | pub struct MarketplaceCandidateId(pub String); |
| 103 | |
| 104 | impl MarketplaceCandidateId { |
| 105 | #[must_use] |
| 106 | pub fn new(catalog_id: &MarketplaceCatalogId, candidate_name: &str) -> Self { |
| 107 | Self(format!("{}:{candidate_name}", catalog_id.as_str())) |
| 108 | } |
| 109 | |
| 110 | #[must_use] |
| 111 | pub fn from_raw(raw: impl Into<String>) -> Self { |
| 112 | Self(raw.into()) |
| 113 | } |
| 114 | |
| 115 | #[must_use] |
| 116 | pub fn as_str(&self) -> &str { |
| 117 | &self.0 |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | impl fmt::Display for MarketplaceCandidateId { |
| 122 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 123 | f.write_str(&self.0) |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | /// Advisory curation tier carried from the catalog. Display only; never |
| 128 | /// grants trust (see module invariants). |
| 129 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] |
| 130 | #[serde(rename_all = "snake_case")] |
| 131 | pub enum CatalogTier { |
| 132 | #[default] |
| 133 | Community, |
| 134 | Official, |
| 135 | Curated, |
| 136 | Partner, |
| 137 | } |
| 138 | |
| 139 | impl CatalogTier { |
| 140 | #[must_use] |
| 141 | pub fn as_str(&self) -> &'static str { |
| 142 | match self { |
| 143 | Self::Community => "community", |
| 144 | Self::Official => "official", |
| 145 | Self::Curated => "curated", |
| 146 | Self::Partner => "partner", |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | /// Parse a catalog-declared tier string. Unknown strings stay |
| 151 | /// community-tier with the raw value preserved by the caller's |
| 152 | /// diagnostic; tiers are display-only either way. |
| 153 | #[must_use] |
| 154 | pub fn parse(raw: &str) -> Self { |
| 155 | match raw { |
| 156 | "official" => Self::Official, |
| 157 | "curated" => Self::Curated, |
| 158 | "partner" => Self::Partner, |
| 159 | _ => Self::Community, |
| 160 | } |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | impl fmt::Display for CatalogTier { |
| 165 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 166 | f.write_str(self.as_str()) |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | /// Display-only provenance for a catalog or candidate. |
| 171 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] |
| 172 | pub struct CatalogProvenance { |
| 173 | #[serde(default)] |
| 174 | pub tier: CatalogTier, |
| 175 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 176 | pub publisher: Option<String>, |
| 177 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 178 | pub source_url: Option<String>, |
| 179 | } |
| 180 | |
| 181 | impl CatalogProvenance { |
| 182 | /// Provenance never grants trust. Constant by construction; exists so |
| 183 | /// tests and reviewers can assert the invariant at call sites. |
| 184 | #[must_use] |
| 185 | pub fn grants_trust(&self) -> bool { |
| 186 | false |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | /// Normalized description of where a plugin bundle lives. Pure data: the |
| 191 | /// parser never fetches any of these. |
| 192 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 193 | #[serde(tag = "type", rename_all = "snake_case")] |
| 194 | pub enum MarketplaceSourceSpec { |
| 195 | /// Directory path. May be relative to the catalog's own location; |
| 196 | /// resolution happens at install time, not parse time. |
| 197 | LocalPath { path: PathBuf }, |
| 198 | /// GitHub `owner/repo`, optionally with a pinned ref or commit the |
| 199 | /// current installer cannot yet honor (recorded, warned). |
| 200 | GitHub { |
| 201 | owner: String, |
| 202 | repo: String, |
| 203 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 204 | git_ref: Option<String>, |
| 205 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 206 | sha: Option<String>, |
| 207 | }, |
| 208 | /// Tarball archive URL with an optional unverified sha256 pin. |
| 209 | ArchiveUrl { |
| 210 | url: String, |
| 211 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 212 | sha256: Option<String>, |
| 213 | }, |
| 214 | /// Non-GitHub git URL. The installer cannot fetch these yet; the plan |
| 215 | /// stays [`MarketplaceInstallPlan::Unsupported`]. |
| 216 | GitUrl { url: String }, |
| 217 | /// npm package declaration. Codewhale does not execute npm; the plan |
| 218 | /// stays unsupported. |
| 219 | Npm { package: String }, |
| 220 | /// A source form the catalog format defines but Codewhale refuses to |
| 221 | /// execute (e.g. Claude `command` sources), with the reason. |
| 222 | Refused { reason: String }, |
| 223 | /// Required source field present but not a documented shape for the |
| 224 | /// format; entry degrades with an error diagnostic. |
| 225 | Invalid { reason: String }, |
| 226 | } |
| 227 | |
| 228 | /// Whether and how Codewhale's existing installer could fetch a source. |
| 229 | /// Unsupported plans are visible, with an honest reason — a listed plugin |
| 230 | /// is never implied to be installable. |
| 231 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 232 | #[serde(tag = "status", rename_all = "snake_case")] |
| 233 | pub enum MarketplaceInstallPlan { |
| 234 | /// Maps onto a real [`PluginInstallSource`] spec. |
| 235 | Supported { spec: String, source_kind: String }, |
| 236 | /// Cannot be installed by Codewhale today, and why. |
| 237 | Unsupported { reason: String, raw: String }, |
| 238 | } |
| 239 | |
| 240 | impl MarketplaceInstallPlan { |
| 241 | #[must_use] |
| 242 | pub fn is_supported(&self) -> bool { |
| 243 | matches!(self, Self::Supported { .. }) |
| 244 | } |
| 245 | |
| 246 | #[must_use] |
| 247 | pub fn reason(&self) -> Option<&str> { |
| 248 | match self { |
| 249 | Self::Supported { .. } => None, |
| 250 | Self::Unsupported { reason, .. } => Some(reason.as_str()), |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | #[must_use] |
| 255 | pub fn to_install_source(&self) -> Option<PluginInstallSource> { |
| 256 | match self { |
| 257 | Self::Supported { spec, .. } => PluginInstallSource::parse(spec).ok(), |
| 258 | Self::Unsupported { .. } => None, |
| 259 | } |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | /// What a catalog entry is. |
| 264 | /// |
| 265 | /// A marketplace carries more than plugins: the Codewhale marketplace lists |
| 266 | /// standalone skills as their own entries, and imported third-party catalogs |
| 267 | /// carry whatever their format allows. The kind is how a surface knows which |
| 268 | /// pool an entry belongs to — in particular, only `Plugin` entries are plugin |
| 269 | /// suggestions. (grokbuild's `MarketplaceEntry` carries the same distinction |
| 270 | /// as inventory flags — `skill_count`, `has_mcp`, … — on an always-a-plugin |
| 271 | /// entry; we keep the kind explicit because our catalogs list skills |
| 272 | /// directly.) |
| 273 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] |
| 274 | #[serde(rename_all = "snake_case")] |
| 275 | pub enum MarketplaceEntryKind { |
| 276 | #[default] |
| 277 | Plugin, |
| 278 | /// A standalone skill (instruction pack). Installable, but never |
| 279 | /// suggested as a plugin. |
| 280 | Skill, |
| 281 | } |
| 282 | |
| 283 | /// A normalized catalog entry. Catalog-declared component lists are kept |
| 284 | /// for display; the reviewed staged-tree manifest at install time remains |
| 285 | /// the only authority on what a bundle actually contains. |
| 286 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 287 | pub struct MarketplaceCandidate { |
| 288 | pub id: MarketplaceCandidateId, |
| 289 | pub catalog_id: MarketplaceCatalogId, |
| 290 | /// What this entry is. Defaults to `Plugin` so stored snapshots and |
| 291 | /// formats that do not declare a kind keep their previous meaning. |
| 292 | #[serde(default)] |
| 293 | pub kind: MarketplaceEntryKind, |
| 294 | /// Canonical (Agent Plugins standard) plugin name. |
| 295 | pub name: String, |
| 296 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 297 | pub display_name: Option<String>, |
| 298 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 299 | pub icon: Option<String>, |
| 300 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 301 | pub description: Option<String>, |
| 302 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 303 | pub version: Option<String>, |
| 304 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 305 | pub author: Option<String>, |
| 306 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 307 | pub homepage: Option<String>, |
| 308 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 309 | pub repository: Option<String>, |
| 310 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 311 | pub license: Option<String>, |
| 312 | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
| 313 | pub keywords: Vec<String>, |
| 314 | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
| 315 | pub categories: Vec<String>, |
| 316 | pub source: MarketplaceSourceSpec, |
| 317 | pub install_plan: MarketplaceInstallPlan, |
| 318 | /// Components the catalog entry itself declares. Many formats declare |
| 319 | /// none; `None` compatibility then means "decided at install review". |
| 320 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 321 | pub declared_components: Option<PluginInventory>, |
| 322 | /// Compatibility of declared components with this build's activation |
| 323 | /// policy. `None` when the format does not declare components. |
| 324 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 325 | pub compatibility: Option<PluginCompatibility>, |
| 326 | pub provenance: CatalogProvenance, |
| 327 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 328 | pub when: Option<PluginWhen>, |
| 329 | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
| 330 | pub diagnostics: Vec<MarketplaceDiagnostic>, |
| 331 | } |
| 332 | |
| 333 | impl MarketplaceCandidate { |
| 334 | #[must_use] |
| 335 | pub fn plugin_id(&self) -> PluginId { |
| 336 | PluginId(self.name.clone()) |
| 337 | } |
| 338 | |
| 339 | #[must_use] |
| 340 | pub fn has_errors(&self) -> bool { |
| 341 | self.diagnostics |
| 342 | .iter() |
| 343 | .any(|d| d.level == PluginDiagnosticLevel::Error) |
| 344 | } |
| 345 | } |
| 346 | |
| 347 | /// A parsed catalog: survivors plus diagnostics. Fault isolation is |
| 348 | /// per-entry: one malformed entry never removes the others. |
| 349 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 350 | pub struct MarketplaceCatalog { |
| 351 | pub id: MarketplaceCatalogId, |
| 352 | pub format: MarketplaceFormat, |
| 353 | pub name: String, |
| 354 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 355 | pub display_name: Option<String>, |
| 356 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 357 | pub description: Option<String>, |
| 358 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 359 | pub version: Option<String>, |
| 360 | /// Resolution context: where the catalog document itself was read |
| 361 | /// from, so relative sources can be resolved at install time. |
| 362 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 363 | pub base: Option<String>, |
| 364 | pub provenance: CatalogProvenance, |
| 365 | pub candidates: Vec<MarketplaceCandidate>, |
| 366 | pub diagnostics: Vec<MarketplaceDiagnostic>, |
| 367 | } |
| 368 | |
| 369 | impl MarketplaceCatalog { |
| 370 | #[must_use] |
| 371 | pub fn candidate_by_name(&self, name: &str) -> Option<&MarketplaceCandidate> { |
| 372 | self.candidates.iter().find(|c| c.name == name) |
| 373 | } |
| 374 | |
| 375 | #[must_use] |
| 376 | pub fn total_candidates(&self) -> usize { |
| 377 | self.candidates.len() |
| 378 | } |
| 379 | |
| 380 | #[must_use] |
| 381 | pub fn error_count(&self) -> usize { |
| 382 | self.diagnostics |
| 383 | .iter() |
| 384 | .filter(|d| d.level == PluginDiagnosticLevel::Error) |
| 385 | .count() |
| 386 | } |
| 387 | |
| 388 | #[must_use] |
| 389 | pub fn warning_count(&self) -> usize { |
| 390 | self.diagnostics |
| 391 | .iter() |
| 392 | .filter(|d| d.level == PluginDiagnosticLevel::Warning) |
| 393 | .count() |
| 394 | } |
| 395 | } |
| 396 | |
| 397 | /// A diagnostic emitted while parsing a catalog or entry. |
| 398 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 399 | pub struct MarketplaceDiagnostic { |
| 400 | pub level: PluginDiagnosticLevel, |
| 401 | pub code: String, |
| 402 | pub message: String, |
| 403 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 404 | pub candidate_name: Option<String>, |
| 405 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 406 | pub entry_index: Option<usize>, |
| 407 | } |
| 408 | |
| 409 | impl MarketplaceDiagnostic { |
| 410 | #[must_use] |
| 411 | pub fn warning( |
| 412 | code: impl Into<String>, |
| 413 | message: impl Into<String>, |
| 414 | candidate_name: Option<String>, |
| 415 | entry_index: Option<usize>, |
| 416 | ) -> Self { |
| 417 | Self { |
| 418 | level: PluginDiagnosticLevel::Warning, |
| 419 | code: code.into(), |
| 420 | message: message.into(), |
| 421 | candidate_name, |
| 422 | entry_index, |
| 423 | } |
| 424 | } |
| 425 | |
| 426 | #[must_use] |
| 427 | pub fn error( |
| 428 | code: impl Into<String>, |
| 429 | message: impl Into<String>, |
| 430 | candidate_name: Option<String>, |
| 431 | entry_index: Option<usize>, |
| 432 | ) -> Self { |
| 433 | Self { |
| 434 | level: PluginDiagnosticLevel::Error, |
| 435 | code: code.into(), |
| 436 | message: message.into(), |
| 437 | candidate_name, |
| 438 | entry_index, |
| 439 | } |
| 440 | } |
| 441 | } |
| 442 |