返回 CodeWhale
codewhale.rs
根目录 / crates / tui / src / plugins / marketplace / parsers / codewhale.rs
1 //! Codewhale native catalog parser.
2 //!
3 //! This is Codewhale's own documented catalog format. It is deliberately
4 //! the smallest of the supported formats: entries carry a `name` and a
5 //! `source` that is exactly the install spec `/plugin install` accepts
6 //! (`github:owner/repo`, `path:dir`, an http(s) tarball URL).
7 //!
8 //! ```json
9 //! {
10 //! "name": "my-catalog",
11 //! "description": "Team plugins",
12 //! "version": "1",
13 //! "plugins": [
14 //! { "name": "formatter", "source": "github:owner/repo", "version": "2.1.0" }
15 //! ]
16 //! }
17 //! ```
18
19 use serde_json::Value;
20
21 use crate::plugins::install::PluginInstallSource;
22
23 use super::super::types::{
24 CatalogProvenance, CatalogTier, MarketplaceCandidate, MarketplaceCandidateId,
25 MarketplaceCatalog, MarketplaceDiagnostic, MarketplaceEntryKind, MarketplaceFormat,
26 MarketplaceInstallPlan, MarketplaceSourceSpec,
27 };
28 use super::{MarketplaceDocument, str_field, unknown_fields_warning};
29
30 const TOP_LEVEL_FIELDS: &[&str] = &["name", "description", "version", "plugins"];
31 const ENTRY_FIELDS: &[&str] = &[
32 "name",
33 "source",
34 "kind",
35 "description",
36 "version",
37 "homepage",
38 "display_name",
39 "author",
40 "icon",
41 "platforms",
42 ];
43
44 pub fn parse_codewhale_catalog(document: MarketplaceDocument) -> MarketplaceCatalog {
45 let MarketplaceDocument {
46 catalog_id,
47 root,
48 base,
49 ..
50 } = document;
51 let mut diagnostics = Vec::new();
52
53 let Some(obj) = root.as_object() else {
54 return MarketplaceCatalog {
55 id: catalog_id,
56 format: MarketplaceFormat::Codewhale,
57 name: String::new(),
58 display_name: None,
59 description: None,
60 version: None,
61 base,
62 provenance: CatalogProvenance::default(),
63 candidates: Vec::new(),
64 diagnostics: vec![MarketplaceDiagnostic::error(
65 "NOT_AN_OBJECT",
66 "Codewhale catalog must be a JSON object",
67 None,
68 None,
69 )],
70 };
71 };
72
73 if let Some(diag) = unknown_fields_warning(obj, TOP_LEVEL_FIELDS) {
74 diagnostics.push(diag);
75 }
76
77 let (name, bad_name) = str_field(obj, "name");
78 if let Some(diag) = bad_name {
79 diagnostics.push(diag);
80 }
81 let name = name
82 .map(ToString::to_string)
83 .unwrap_or_else(|| catalog_id.as_str().to_string());
84 let (description, bad_desc) = str_field(obj, "description");
85 if let Some(diag) = bad_desc {
86 diagnostics.push(diag);
87 }
88 let (version, bad_version) = str_field(obj, "version");
89 if let Some(diag) = bad_version {
90 diagnostics.push(diag);
91 }
92
93 let Some(entries) = obj.get("plugins").and_then(Value::as_array) else {
94 diagnostics.push(MarketplaceDiagnostic::error(
95 "MISSING_PLUGINS",
96 "Codewhale catalog must contain a `plugins` array",
97 None,
98 None,
99 ));
100 return MarketplaceCatalog {
101 id: catalog_id,
102 format: MarketplaceFormat::Codewhale,
103 name,
104 display_name: None,
105 description: description.map(ToString::to_string),
106 version: version.map(ToString::to_string),
107 base,
108 provenance: CatalogProvenance::default(),
109 candidates: Vec::new(),
110 diagnostics,
111 };
112 };
113
114 let mut candidates = Vec::new();
115 for (index, entry) in entries.iter().enumerate() {
116 if let Some(candidate) = parse_codewhale_entry(&catalog_id, index, entry, &mut diagnostics)
117 {
118 candidates.push(candidate);
119 }
120 }
121
122 MarketplaceCatalog {
123 id: catalog_id,
124 format: MarketplaceFormat::Codewhale,
125 name,
126 display_name: None,
127 description: description.map(ToString::to_string),
128 version: version.map(ToString::to_string),
129 base,
130 provenance: CatalogProvenance {
131 tier: CatalogTier::Community,
132 publisher: None,
133 source_url: None,
134 },
135 candidates,
136 diagnostics,
137 }
138 }
139
140 fn parse_codewhale_entry(
141 catalog_id: &super::super::types::MarketplaceCatalogId,
142 index: usize,
143 entry: &Value,
144 diagnostics: &mut Vec<MarketplaceDiagnostic>,
145 ) -> Option<MarketplaceCandidate> {
146 let Some(obj) = entry.as_object() else {
147 diagnostics.push(MarketplaceDiagnostic::error(
148 "MALFORMED_ENTRY",
149 format!("Codewhale plugin at index {index} must be a JSON object"),
150 None,
151 Some(index),
152 ));
153 return None;
154 };
155
156 let mut entry_diags = Vec::new();
157 if let Some(diag) = unknown_fields_warning(obj, ENTRY_FIELDS) {
158 entry_diags.push(diag);
159 }
160
161 let (name, bad_name) = str_field(obj, "name");
162 if let Some(diag) = bad_name {
163 entry_diags.push(diag);
164 }
165 let Some(name) = name else {
166 diagnostics.push(MarketplaceDiagnostic::error(
167 "MISSING_NAME",
168 format!("Codewhale plugin at index {index} is missing required `name`"),
169 None,
170 Some(index),
171 ));
172 return None;
173 };
174 let name = name.to_string();
175
176 let (source, bad_source) = str_field(obj, "source");
177 if let Some(diag) = bad_source {
178 entry_diags.push(diag);
179 }
180 let Some(source) = source else {
181 diagnostics.push(MarketplaceDiagnostic::error(
182 "MISSING_SOURCE",
183 format!("Codewhale plugin `{name}` is missing required `source`"),
184 Some(name.clone()),
185 Some(index),
186 ));
187 return None;
188 };
189
190 let install_plan = match PluginInstallSource::parse(source) {
191 Ok(parsed) => {
192 let source_kind = match &parsed {
193 PluginInstallSource::Remote(crate::skills::install::InstallSource::GitHubRepo(
194 _,
195 )) => "GitHub repository".to_string(),
196 PluginInstallSource::Remote(crate::skills::install::InstallSource::DirectUrl(
197 _,
198 )) => "Tarball URL".to_string(),
199 PluginInstallSource::LocalPath { .. } => "Local directory".to_string(),
200 PluginInstallSource::Remote(crate::skills::install::InstallSource::Registry(_)) => {
201 "Registry".to_string()
202 }
203 };
204 MarketplaceInstallPlan::Supported {
205 spec: source.to_string(),
206 source_kind,
207 }
208 }
209 Err(err) => MarketplaceInstallPlan::Unsupported {
210 reason: format!("invalid Codewhale install spec: {err}"),
211 raw: source.to_string(),
212 },
213 };
214 let normalized = normalize_native_source(source);
215
216 let (description, bad_desc) = str_field(obj, "description");
217 if let Some(diag) = bad_desc {
218 entry_diags.push(diag);
219 }
220 let (version, bad_version) = str_field(obj, "version");
221 if let Some(diag) = bad_version {
222 entry_diags.push(diag);
223 }
224 let (homepage, bad_home) = str_field(obj, "homepage");
225 if let Some(diag) = bad_home {
226 entry_diags.push(diag);
227 }
228
229 let mut labels = Vec::new();
230 for field in ["display_name", "author", "icon"] {
231 let (value, bad) = str_field(obj, field);
232 if let Some(diag) = bad {
233 entry_diags.push(diag);
234 }
235 let bounded = value.filter(|text| {
236 field == "icon" || (text.chars().count() <= 128 && !text.chars().any(char::is_control))
237 });
238 if value.is_some() && bounded.is_none() {
239 entry_diags.push(MarketplaceDiagnostic::error(
240 "INVALID_IDENTITY",
241 format!("{field} must fit on one line within 128 characters"),
242 Some(name.clone()),
243 Some(index),
244 ));
245 }
246 labels.push(bounded.map(ToString::to_string));
247 }
248 let mut icon = labels.pop().flatten();
249 if let Some(value) = &icon
250 && let Err(reason) = crate::plugins::manifest::validate_icon(value)
251 {
252 entry_diags.push(MarketplaceDiagnostic::error(
253 "INVALID_ICON",
254 reason,
255 Some(name.clone()),
256 Some(index),
257 ));
258 icon = None;
259 }
260 let author = labels.pop().flatten();
261 let display_name = labels.pop().flatten();
262 let platforms = obj
263 .get("platforms")
264 .and_then(Value::as_array)
265 .filter(|values| {
266 values.len() <= 3
267 && values
268 .iter()
269 .enumerate()
270 .all(|(i, value)| !values[..i].contains(value))
271 })
272 .and_then(|values| {
273 values
274 .iter()
275 .map(|value| {
276 value
277 .as_str()
278 .filter(|os| ["macos", "linux", "windows"].contains(os))
279 .map(ToString::to_string)
280 })
281 .collect::<Option<Vec<_>>>()
282 });
283 if obj.contains_key("platforms") && platforms.is_none() {
284 entry_diags.push(MarketplaceDiagnostic::error(
285 "INVALID_PLATFORMS",
286 "platforms must be an array of macos, linux or windows",
287 Some(name.clone()),
288 Some(index),
289 ));
290 }
291 // What this entry is. The Codewhale marketplace keeps plugins and
292 // skills in separate top-level directories, so the source path is the
293 // signal; a document may also declare `kind` explicitly. A skill entry
294 // stays installable, but it is not a plugin and is never suggested as
295 // one (#6290 rework).
296 let kind = match obj.get("kind").and_then(Value::as_str) {
297 Some("skill") => MarketplaceEntryKind::Skill,
298 Some("plugin") | None => {
299 let path = source
300 .strip_prefix("path:")
301 .unwrap_or(source)
302 .trim_start_matches("./");
303 if path == "skills" || path.starts_with("skills/") {
304 MarketplaceEntryKind::Skill
305 } else {
306 MarketplaceEntryKind::Plugin
307 }
308 }
309 Some(other) => {
310 entry_diags.push(MarketplaceDiagnostic::warning(
311 "UNKNOWN_ENTRY_KIND",
312 format!("unknown entry kind `{other}`; treated as a plugin"),
313 Some(name.clone()),
314 Some(index),
315 ));
316 MarketplaceEntryKind::Plugin
317 }
318 };
319 Some(MarketplaceCandidate {
320 id: MarketplaceCandidateId::new(catalog_id, &name),
321 catalog_id: catalog_id.clone(),
322 kind,
323 icon,
324 name,
325 display_name,
326 description: description.map(ToString::to_string),
327 version: version.map(ToString::to_string),
328 author,
329 homepage: homepage.map(ToString::to_string),
330 repository: None,
331 license: None,
332 keywords: Vec::new(),
333 categories: Vec::new(),
334 source: normalized,
335 install_plan,
336 declared_components: None,
337 compatibility: None,
338 provenance: CatalogProvenance {
339 tier: CatalogTier::Community,
340 publisher: None,
341 source_url: None,
342 },
343 when: platforms.map(|os| crate::plugins::manifest::PluginWhen {
344 os: Some(os),
345 binaries: None,
346 }),
347 diagnostics: entry_diags,
348 })
349 }
350
351 fn normalize_native_source(spec: &str) -> MarketplaceSourceSpec {
352 match PluginInstallSource::parse(spec) {
353 Ok(PluginInstallSource::LocalPath(path)) => MarketplaceSourceSpec::LocalPath { path },
354 Ok(PluginInstallSource::Remote(crate::skills::install::InstallSource::GitHubRepo(
355 repo,
356 ))) => {
357 let (owner, name) = repo.split_once('/').unwrap_or((&repo, ""));
358 MarketplaceSourceSpec::GitHub {
359 owner: owner.to_string(),
360 repo: name.to_string(),
361 git_ref: None,
362 sha: None,
363 }
364 }
365 Ok(PluginInstallSource::Remote(crate::skills::install::InstallSource::DirectUrl(url))) => {
366 MarketplaceSourceSpec::ArchiveUrl { url, sha256: None }
367 }
368 Ok(other) => MarketplaceSourceSpec::Invalid {
369 reason: format!("registry source {other:?} is not a marketplace install"),
370 },
371 Err(err) => MarketplaceSourceSpec::Invalid {
372 reason: err.to_string(),
373 },
374 }
375 }
376
376 lines RUST