返回 CodeWhale
types.rs
根目录 / crates / tui / src / plugins / marketplace / types.rs
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
442 lines RUST