返回 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 // The name is the candidate's identity (registry lookup, `/plugin show`,
175 // install), so it is held to the plugin-name rules the other formats
176 // normalize to, rather than stored verbatim with controls or unbounded.
177 if crate::plugins::manifest::validate_plugin_name(name).is_err()
178 && !crate::plugins::agent_plugin::is_standard_plugin_name(name)
179 {
180 diagnostics.push(MarketplaceDiagnostic::error(
181 "INVALID_NAME",
182 format!(
183 "Codewhale plugin at index {index} has an invalid name {:?}: use lowercase letters, digits, and internal hyphens or dots",
184 name.chars().take(80).collect::<String>()
185 ),
186 None,
187 Some(index),
188 ));
189 return None;
190 }
191 let name = name.to_string();
192
193 let (source, bad_source) = str_field(obj, "source");
194 if let Some(diag) = bad_source {
195 entry_diags.push(diag);
196 }
197 let Some(source) = source else {
198 diagnostics.push(MarketplaceDiagnostic::error(
199 "MISSING_SOURCE",
200 format!("Codewhale plugin `{name}` is missing required `source`"),
201 Some(name.clone()),
202 Some(index),
203 ));
204 return None;
205 };
206
207 let install_plan = match PluginInstallSource::parse(source) {
208 Ok(parsed) => {
209 let source_kind = match &parsed {
210 PluginInstallSource::Remote(crate::skills::install::InstallSource::GitHubRepo(
211 _,
212 )) => "GitHub repository".to_string(),
213 PluginInstallSource::Remote(crate::skills::install::InstallSource::DirectUrl(
214 _,
215 )) => "Tarball URL".to_string(),
216 PluginInstallSource::LocalPath { .. } => "Local directory".to_string(),
217 PluginInstallSource::Dsh(_) => "DeepSeek Harness bundle package".to_string(),
218 PluginInstallSource::Remote(crate::skills::install::InstallSource::Registry(_)) => {
219 "Registry".to_string()
220 }
221 };
222 MarketplaceInstallPlan::Supported {
223 spec: source.to_string(),
224 source_kind,
225 }
226 }
227 Err(err) => MarketplaceInstallPlan::Unsupported {
228 reason: format!("invalid Codewhale install spec: {err}"),
229 raw: source.to_string(),
230 },
231 };
232 let normalized = normalize_native_source(source);
233
234 let (description, bad_desc) = str_field(obj, "description");
235 if let Some(diag) = bad_desc {
236 entry_diags.push(diag);
237 }
238 let (version, bad_version) = str_field(obj, "version");
239 if let Some(diag) = bad_version {
240 entry_diags.push(diag);
241 }
242 let (homepage, bad_home) = str_field(obj, "homepage");
243 if let Some(diag) = bad_home {
244 entry_diags.push(diag);
245 }
246
247 let mut labels = Vec::new();
248 for field in ["display_name", "author", "icon"] {
249 let (value, bad) = str_field(obj, field);
250 if let Some(diag) = bad {
251 entry_diags.push(diag);
252 }
253 let bounded = value.filter(|text| {
254 field == "icon" || (text.chars().count() <= 128 && !text.chars().any(char::is_control))
255 });
256 if value.is_some() && bounded.is_none() {
257 entry_diags.push(MarketplaceDiagnostic::error(
258 "INVALID_IDENTITY",
259 format!("{field} must fit on one line within 128 characters"),
260 Some(name.clone()),
261 Some(index),
262 ));
263 }
264 labels.push(bounded.map(ToString::to_string));
265 }
266 let mut icon = labels.pop().flatten();
267 if let Some(value) = &icon
268 && let Err(reason) = crate::plugins::manifest::validate_icon(value)
269 {
270 entry_diags.push(MarketplaceDiagnostic::error(
271 "INVALID_ICON",
272 reason,
273 Some(name.clone()),
274 Some(index),
275 ));
276 icon = None;
277 }
278 let author = labels.pop().flatten();
279 let display_name = labels.pop().flatten();
280 let platforms = obj
281 .get("platforms")
282 .and_then(Value::as_array)
283 .filter(|values| {
284 values.len() <= 3
285 && values
286 .iter()
287 .enumerate()
288 .all(|(i, value)| !values[..i].contains(value))
289 })
290 .and_then(|values| {
291 values
292 .iter()
293 .map(|value| {
294 value
295 .as_str()
296 .filter(|os| ["macos", "linux", "windows"].contains(os))
297 .map(ToString::to_string)
298 })
299 .collect::<Option<Vec<_>>>()
300 });
301 if obj.contains_key("platforms") && platforms.is_none() {
302 entry_diags.push(MarketplaceDiagnostic::error(
303 "INVALID_PLATFORMS",
304 "platforms must be an array of macos, linux or windows",
305 Some(name.clone()),
306 Some(index),
307 ));
308 }
309 // What this entry is. The Codewhale marketplace keeps plugins and
310 // skills in separate top-level directories, so the source path is the
311 // signal; a document may also declare `kind` explicitly. A skill entry
312 // stays installable, but it is not a plugin and is never suggested as
313 // one (#6290 rework).
314 let kind = match obj.get("kind").and_then(Value::as_str) {
315 Some("skill") => MarketplaceEntryKind::Skill,
316 Some("plugin") | None => {
317 let path = source
318 .strip_prefix("path:")
319 .unwrap_or(source)
320 .trim_start_matches("./");
321 if path == "skills" || path.starts_with("skills/") {
322 MarketplaceEntryKind::Skill
323 } else {
324 MarketplaceEntryKind::Plugin
325 }
326 }
327 Some(other) => {
328 entry_diags.push(MarketplaceDiagnostic::warning(
329 "UNKNOWN_ENTRY_KIND",
330 format!("unknown entry kind `{other}`; treated as a plugin"),
331 Some(name.clone()),
332 Some(index),
333 ));
334 MarketplaceEntryKind::Plugin
335 }
336 };
337 Some(MarketplaceCandidate {
338 id: MarketplaceCandidateId::new(catalog_id, &name),
339 catalog_id: catalog_id.clone(),
340 kind,
341 icon,
342 name,
343 display_name,
344 description: description.map(ToString::to_string),
345 version: version.map(ToString::to_string),
346 author,
347 homepage: homepage.map(ToString::to_string),
348 repository: None,
349 license: None,
350 keywords: Vec::new(),
351 categories: Vec::new(),
352 source: normalized,
353 install_plan,
354 declared_components: None,
355 compatibility: None,
356 provenance: CatalogProvenance {
357 tier: CatalogTier::Community,
358 publisher: None,
359 source_url: None,
360 },
361 when: platforms.map(|os| crate::plugins::manifest::PluginWhen {
362 os: Some(os),
363 binaries: None,
364 }),
365 diagnostics: entry_diags,
366 })
367 }
368
369 fn normalize_native_source(spec: &str) -> MarketplaceSourceSpec {
370 match PluginInstallSource::parse(spec) {
371 Ok(PluginInstallSource::LocalPath(path) | PluginInstallSource::Dsh(path)) => {
372 MarketplaceSourceSpec::LocalPath { path }
373 }
374 Ok(PluginInstallSource::Remote(crate::skills::install::InstallSource::GitHubRepo(
375 repo,
376 ))) => {
377 let (owner, name) = repo.split_once('/').unwrap_or((&repo, ""));
378 MarketplaceSourceSpec::GitHub {
379 owner: owner.to_string(),
380 repo: name.to_string(),
381 git_ref: None,
382 sha: None,
383 }
384 }
385 Ok(PluginInstallSource::Remote(crate::skills::install::InstallSource::DirectUrl(url))) => {
386 MarketplaceSourceSpec::ArchiveUrl { url, sha256: None }
387 }
388 Ok(other) => MarketplaceSourceSpec::Invalid {
389 reason: format!("registry source {other:?} is not a marketplace install"),
390 },
391 Err(err) => MarketplaceSourceSpec::Invalid {
392 reason: err.to_string(),
393 },
394 }
395 }
396
396 lines RUST