| 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 |