返回 CodeWhale
kimi.rs
1 //! Kimi / Moonshot `marketplace.json` parser.
2 //!
3 //! Schema source: `MoonshotAI/kimi-code/plugins/marketplace.json` and
4 //! `docs/en/customization/plugins.md` ("Custom marketplace JSON"):
5 //!
6 //! ```json
7 //! {
8 //! "version": "2",
9 //! "plugins": [
10 //! { "id": "my-plugin", "displayName": "My Plugin", "source": "./my-plugin" }
11 //! ]
12 //! }
13 //! ```
14 //!
15 //! Entry fields: `id` (required), `source` (required: local path, zip
16 //! URL, or GitHub URL), `tier` (`official` | `curated`), `displayName`,
17 //! `version`, `description`, `homepage`, `keywords`. Nothing else is
18 //! documented; nothing else is parsed.
19
20 use serde_json::Value;
21
22 use crate::plugins::agent_plugin::{is_standard_plugin_name, slugify_plugin_name};
23
24 use super::super::types::{
25 CatalogProvenance, CatalogTier, MarketplaceCandidate, MarketplaceCandidateId,
26 MarketplaceCatalog, MarketplaceDiagnostic, MarketplaceEntryKind, MarketplaceFormat,
27 MarketplaceInstallPlan, MarketplaceSourceSpec,
28 };
29 use super::{MarketplaceDocument, str_array_field, str_field, unknown_fields_warning};
30
31 const TOP_LEVEL_FIELDS: &[&str] = &["version", "plugins"];
32 pub const KIMI_ZIP_UNSUPPORTED_REASON: &str = "kimi_zip_unsupported";
33 pub const KIMI_REMOTE_UNSUPPORTED_REASON: &str = "kimi_remote_archive_unsupported";
34 pub const KIMI_GZIP_TARBALL_SOURCE_KIND: &str = "kimi_gzip_tarball_url";
35 const ENTRY_FIELDS: &[&str] = &[
36 "id",
37 "source",
38 "tier",
39 "displayName",
40 "version",
41 "description",
42 "homepage",
43 "keywords",
44 ];
45
46 pub fn parse_kimi_catalog(document: MarketplaceDocument) -> MarketplaceCatalog {
47 let MarketplaceDocument {
48 catalog_id,
49 root,
50 base,
51 ..
52 } = document;
53 let mut diagnostics = Vec::new();
54
55 let obj = root.as_object();
56 let (version, bad_version) = obj.map(|o| str_field(o, "version")).unwrap_or((None, None));
57 let version = version.map(ToString::to_string);
58 if let Some(diag) = bad_version {
59 diagnostics.push(diag);
60 }
61 if let Some(diag) = obj.and_then(|o| unknown_fields_warning(o, TOP_LEVEL_FIELDS)) {
62 diagnostics.push(diag);
63 }
64
65 let name = catalog_id.as_str().to_string();
66
67 let Some(entries) = obj.and_then(|o| o.get("plugins")).and_then(Value::as_array) else {
68 diagnostics.push(MarketplaceDiagnostic::error(
69 "MISSING_PLUGINS",
70 "Kimi marketplace must contain a `plugins` array",
71 None,
72 None,
73 ));
74 return MarketplaceCatalog {
75 id: catalog_id,
76 format: MarketplaceFormat::Kimi,
77 name,
78 display_name: None,
79 description: None,
80 version,
81 base,
82 provenance: CatalogProvenance::default(),
83 candidates: Vec::new(),
84 diagnostics,
85 };
86 };
87
88 let mut candidates = Vec::new();
89 for (index, entry) in entries.iter().enumerate() {
90 match parse_kimi_entry(&catalog_id, index, entry, &mut diagnostics) {
91 Some(candidate) => candidates.push(candidate),
92 None => continue,
93 }
94 }
95
96 MarketplaceCatalog {
97 id: catalog_id,
98 format: MarketplaceFormat::Kimi,
99 name,
100 display_name: None,
101 description: None,
102 version,
103 base,
104 provenance: CatalogProvenance::default(),
105 candidates,
106 diagnostics,
107 }
108 }
109
110 fn parse_kimi_entry(
111 catalog_id: &super::super::types::MarketplaceCatalogId,
112 index: usize,
113 entry: &Value,
114 diagnostics: &mut Vec<MarketplaceDiagnostic>,
115 ) -> Option<MarketplaceCandidate> {
116 let Some(obj) = entry.as_object() else {
117 diagnostics.push(MarketplaceDiagnostic::error(
118 "MALFORMED_ENTRY",
119 format!("Kimi plugin at index {index} must be a JSON object"),
120 None,
121 Some(index),
122 ));
123 return None;
124 };
125
126 let mut entry_diags = Vec::new();
127 if let Some(diag) = unknown_fields_warning(obj, ENTRY_FIELDS) {
128 entry_diags.push(diag);
129 }
130
131 let (raw_id, bad_id) = str_field(obj, "id");
132 if let Some(diag) = bad_id {
133 entry_diags.push(diag);
134 }
135 let Some(raw_id) = raw_id else {
136 diagnostics.push(MarketplaceDiagnostic::error(
137 "MISSING_ID",
138 format!("Kimi plugin at index {index} is missing required `id`"),
139 None,
140 Some(index),
141 ));
142 return None;
143 };
144
145 // Kimi ids are already kebab-case; normalize only when they are not.
146 let (name, display_name) = if is_standard_plugin_name(raw_id) {
147 (raw_id.to_string(), None)
148 } else {
149 match slugify_plugin_name(raw_id) {
150 Ok(slug) => {
151 entry_diags.push(MarketplaceDiagnostic::warning(
152 "NON_STANDARD_NAME",
153 format!("Kimi id `{raw_id}` normalized to `{slug}`"),
154 Some(slug.clone()),
155 Some(index),
156 ));
157 (slug, Some(raw_id.to_string()))
158 }
159 Err(err) => {
160 diagnostics.push(MarketplaceDiagnostic::error(
161 "INVALID_NAME",
162 format!("Kimi id `{raw_id}` cannot be normalized: {err}"),
163 Some(raw_id.to_string()),
164 Some(index),
165 ));
166 return None;
167 }
168 }
169 };
170 let (explicit_display, bad_display) = str_field(obj, "displayName");
171 if let Some(diag) = bad_display {
172 entry_diags.push(diag);
173 }
174 let display_name = explicit_display.map(ToString::to_string).or(display_name);
175
176 let (source_raw, bad_source) = str_field(obj, "source");
177 if let Some(diag) = bad_source {
178 entry_diags.push(diag);
179 }
180 let Some(source_raw) = source_raw else {
181 diagnostics.push(MarketplaceDiagnostic::error(
182 "MISSING_SOURCE",
183 format!(
184 "Kimi plugin `{name}` is missing required `source` (path, zip URL, or GitHub URL)"
185 ),
186 Some(name.clone()),
187 Some(index),
188 ));
189 return None;
190 };
191
192 let (source, install_plan) = normalize_kimi_source(source_raw);
193
194 let (description, bad_desc) = str_field(obj, "description");
195 if let Some(diag) = bad_desc {
196 entry_diags.push(diag);
197 }
198 let (entry_version, bad_version) = str_field(obj, "version");
199 if let Some(diag) = bad_version {
200 entry_diags.push(diag);
201 }
202 let (homepage, bad_home) = str_field(obj, "homepage");
203 if let Some(diag) = bad_home {
204 entry_diags.push(diag);
205 }
206 let (tier_raw, bad_tier) = str_field(obj, "tier");
207 if let Some(diag) = bad_tier {
208 entry_diags.push(diag);
209 }
210 let tier = tier_raw.map(CatalogTier::parse).unwrap_or_default();
211 if let Some(raw) = tier_raw
212 && !matches!(raw, "official" | "curated")
213 {
214 entry_diags.push(MarketplaceDiagnostic::warning(
215 "UNKNOWN_TIER",
216 format!("Kimi tier `{raw}` is not documented; shown as community"),
217 Some(name.clone()),
218 Some(index),
219 ));
220 }
221 let (keywords, bad_keywords) = str_array_field(obj, "keywords");
222 if let Some(diag) = bad_keywords {
223 entry_diags.push(diag);
224 }
225
226 let provenance = CatalogProvenance {
227 tier,
228 publisher: Some("Kimi marketplace".to_string()),
229 source_url: homepage.map(ToString::to_string),
230 };
231
232 Some(MarketplaceCandidate {
233 id: MarketplaceCandidateId::new(catalog_id, &name),
234 catalog_id: catalog_id.clone(),
235 kind: MarketplaceEntryKind::Plugin,
236 icon: None,
237 name,
238 display_name,
239 description: description.map(ToString::to_string),
240 version: entry_version.map(ToString::to_string),
241 author: None,
242 homepage: homepage.map(ToString::to_string),
243 repository: repository_of(&source),
244 license: None,
245 keywords,
246 categories: Vec::new(),
247 source,
248 install_plan,
249 // Kimi marketplace entries declare no components; compatibility
250 // is decided by the reviewed manifest at install time.
251 declared_components: None,
252 compatibility: None,
253 provenance,
254 when: None,
255 diagnostics: entry_diags,
256 })
257 }
258
259 /// Kimi documents three source forms: local path, zip URL, GitHub URL.
260 /// Everything else is invalid for this format — no guessed fallbacks.
261 fn normalize_kimi_source(raw: &str) -> (MarketplaceSourceSpec, MarketplaceInstallPlan) {
262 if let Some(rest) = raw
263 .strip_prefix("https://github.com/")
264 .or_else(|| raw.strip_prefix("http://github.com/"))
265 {
266 let mut parts = rest.trim_end_matches('/').split('/');
267 if let (Some(owner), Some(repo), None) = (parts.next(), parts.next(), parts.next()) {
268 let repo = repo.trim_end_matches(".git");
269 if !owner.is_empty() && !repo.is_empty() {
270 let spec = format!("github:{owner}/{repo}");
271 return (
272 MarketplaceSourceSpec::GitHub {
273 owner: owner.to_string(),
274 repo: repo.to_string(),
275 git_ref: None,
276 sha: None,
277 },
278 MarketplaceInstallPlan::Supported {
279 spec,
280 source_kind: "GitHub repository".to_string(),
281 },
282 );
283 }
284 }
285 return (
286 MarketplaceSourceSpec::Invalid {
287 reason: format!("`{raw}` is not a plain GitHub owner/repo URL"),
288 },
289 MarketplaceInstallPlan::Unsupported {
290 reason: "Kimi source is a GitHub URL with extra path segments Codewhale cannot map"
291 .to_string(),
292 raw: raw.to_string(),
293 },
294 );
295 }
296 if raw.starts_with("https://") || raw.starts_with("http://") {
297 let lower = raw.to_ascii_lowercase();
298 if lower.ends_with(".zip") {
299 return (
300 MarketplaceSourceSpec::ArchiveUrl {
301 url: raw.to_string(),
302 sha256: None,
303 },
304 MarketplaceInstallPlan::Unsupported {
305 reason: KIMI_ZIP_UNSUPPORTED_REASON.to_string(),
306 raw: raw.to_string(),
307 },
308 );
309 }
310 if lower.ends_with(".tar.gz") || lower.ends_with(".tgz") {
311 return (
312 MarketplaceSourceSpec::ArchiveUrl {
313 url: raw.to_string(),
314 sha256: None,
315 },
316 MarketplaceInstallPlan::Supported {
317 spec: raw.to_string(),
318 source_kind: KIMI_GZIP_TARBALL_SOURCE_KIND.to_string(),
319 },
320 );
321 }
322 return (
323 MarketplaceSourceSpec::Invalid {
324 reason: format!(
325 "`{raw}` is not a documented Kimi source (path, zip URL, or GitHub URL)"
326 ),
327 },
328 MarketplaceInstallPlan::Unsupported {
329 reason: KIMI_REMOTE_UNSUPPORTED_REASON.to_string(),
330 raw: raw.to_string(),
331 },
332 );
333 }
334 (
335 MarketplaceSourceSpec::LocalPath { path: raw.into() },
336 MarketplaceInstallPlan::Supported {
337 spec: format!("path:{raw}"),
338 source_kind: "Local directory".to_string(),
339 },
340 )
341 }
342
343 fn repository_of(source: &MarketplaceSourceSpec) -> Option<String> {
344 match source {
345 MarketplaceSourceSpec::GitHub { owner, repo, .. } => {
346 Some(format!("https://github.com/{owner}/{repo}"))
347 }
348 _ => None,
349 }
350 }
351
351 lines RUST