返回 CodeWhale
codex.rs
根目录 / crates / tui / src / plugins / marketplace / parsers / codex.rs
1 //! Codex `.agents/plugins/marketplace.json` parser.
2 //!
3 //! Schema source: OpenAI plugin packaging docs
4 //! (developers.openai.com/plugins/build/plugins). The marketplace file
5 //! lives at `$REPO_ROOT/.agents/plugins/marketplace.json`,
6 //! `~/.agents/plugins/marketplace.json`, or legacy
7 //! `.claude-plugin/marketplace.json`.
8 //!
9 //! ```json
10 //! {
11 //! "name": "local-repo",
12 //! "plugins": [
13 //! {
14 //! "name": "my-plugin",
15 //! "source": { "source": "local", "path": "./plugins/my-plugin" },
16 //! "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" },
17 //! "category": "Productivity"
18 //! }
19 //! ]
20 //! }
21 //! ```
22 //!
23 //! Top level: `name`; optional `interface` (`displayName`, …). Entry:
24 //! `name`, `source` object (`local` | `url` | `git-subdir` | `npm`),
25 //! optional `policy` (`installation`: `AVAILABLE` |
26 //! `INSTALLED_BY_DEFAULT` | `NOT_AVAILABLE`; `authentication`), optional
27 //! `category`.
28 //!
29 //! `policy` is display-only in Codewhale: `INSTALLED_BY_DEFAULT` never
30 //! triggers an install, and `NOT_AVAILABLE` only downgrades the install
31 //! plan with an honest reason.
32
33 use serde_json::Value;
34
35 use crate::plugins::agent_plugin::{is_standard_plugin_name, slugify_plugin_name};
36
37 use super::super::types::{
38 CatalogProvenance, CatalogTier, MarketplaceCandidate, MarketplaceCandidateId,
39 MarketplaceCatalog, MarketplaceDiagnostic, MarketplaceEntryKind, MarketplaceFormat,
40 MarketplaceInstallPlan, MarketplaceSourceSpec,
41 };
42 use super::{MarketplaceDocument, str_field, unknown_fields_warning};
43
44 const TOP_LEVEL_FIELDS: &[&str] = &["name", "plugins", "interface"];
45 const ENTRY_FIELDS: &[&str] = &["name", "source", "policy", "category"];
46
47 pub fn parse_codex_catalog(document: MarketplaceDocument) -> MarketplaceCatalog {
48 let MarketplaceDocument {
49 catalog_id,
50 root,
51 base,
52 ..
53 } = document;
54 let mut diagnostics = Vec::new();
55
56 let Some(obj) = root.as_object() else {
57 return empty_catalog(
58 catalog_id,
59 base,
60 MarketplaceDiagnostic::error(
61 "NOT_AN_OBJECT",
62 "Codex marketplace must be a JSON object",
63 None,
64 None,
65 ),
66 );
67 };
68
69 if let Some(diag) = unknown_fields_warning(obj, TOP_LEVEL_FIELDS) {
70 diagnostics.push(diag);
71 }
72
73 let (name, bad_name) = str_field(obj, "name");
74 if let Some(diag) = bad_name {
75 diagnostics.push(diag);
76 }
77 let name = name
78 .map(ToString::to_string)
79 .unwrap_or_else(|| catalog_id.as_str().to_string());
80
81 let display_name = obj
82 .get("interface")
83 .and_then(|i| i.get("displayName"))
84 .and_then(Value::as_str)
85 .map(ToString::to_string);
86
87 let Some(entries) = obj.get("plugins").and_then(Value::as_array) else {
88 diagnostics.push(MarketplaceDiagnostic::error(
89 "MISSING_PLUGINS",
90 "Codex marketplace must contain a `plugins` array",
91 None,
92 None,
93 ));
94 return MarketplaceCatalog {
95 id: catalog_id,
96 format: MarketplaceFormat::Codex,
97 name,
98 display_name,
99 description: None,
100 version: None,
101 base,
102 provenance: CatalogProvenance::default(),
103 candidates: Vec::new(),
104 diagnostics,
105 };
106 };
107
108 let mut candidates = Vec::new();
109 for (index, entry) in entries.iter().enumerate() {
110 if let Some(candidate) = parse_codex_entry(&catalog_id, index, entry, &mut diagnostics) {
111 candidates.push(candidate);
112 }
113 }
114
115 MarketplaceCatalog {
116 id: catalog_id,
117 format: MarketplaceFormat::Codex,
118 name,
119 display_name,
120 description: None,
121 version: None,
122 base,
123 provenance: CatalogProvenance {
124 tier: CatalogTier::Community,
125 publisher: Some("Codex marketplace".to_string()),
126 source_url: None,
127 },
128 candidates,
129 diagnostics,
130 }
131 }
132
133 fn empty_catalog(
134 catalog_id: super::super::types::MarketplaceCatalogId,
135 base: Option<String>,
136 diagnostic: MarketplaceDiagnostic,
137 ) -> MarketplaceCatalog {
138 MarketplaceCatalog {
139 id: catalog_id,
140 format: MarketplaceFormat::Codex,
141 name: String::new(),
142 display_name: None,
143 description: None,
144 version: None,
145 base,
146 provenance: CatalogProvenance::default(),
147 candidates: Vec::new(),
148 diagnostics: vec![diagnostic],
149 }
150 }
151
152 fn parse_codex_entry(
153 catalog_id: &super::super::types::MarketplaceCatalogId,
154 index: usize,
155 entry: &Value,
156 diagnostics: &mut Vec<MarketplaceDiagnostic>,
157 ) -> Option<MarketplaceCandidate> {
158 let Some(obj) = entry.as_object() else {
159 diagnostics.push(MarketplaceDiagnostic::error(
160 "MALFORMED_ENTRY",
161 format!("Codex plugin at index {index} must be a JSON object"),
162 None,
163 Some(index),
164 ));
165 return None;
166 };
167
168 let mut entry_diags = Vec::new();
169 if let Some(diag) = unknown_fields_warning(obj, ENTRY_FIELDS) {
170 entry_diags.push(diag);
171 }
172
173 let (raw_name, bad_name) = str_field(obj, "name");
174 if let Some(diag) = bad_name {
175 entry_diags.push(diag);
176 }
177 let Some(raw_name) = raw_name else {
178 diagnostics.push(MarketplaceDiagnostic::error(
179 "MISSING_NAME",
180 format!("Codex plugin at index {index} is missing required `name`"),
181 None,
182 Some(index),
183 ));
184 return None;
185 };
186 let name = if is_standard_plugin_name(raw_name) {
187 raw_name.to_string()
188 } else {
189 match slugify_plugin_name(raw_name) {
190 Ok(slug) => {
191 entry_diags.push(MarketplaceDiagnostic::warning(
192 "NON_STANDARD_NAME",
193 format!("Codex name `{raw_name}` normalized to `{slug}`"),
194 Some(slug.clone()),
195 Some(index),
196 ));
197 slug
198 }
199 Err(err) => {
200 diagnostics.push(MarketplaceDiagnostic::error(
201 "INVALID_NAME",
202 format!("Codex name `{raw_name}` cannot be normalized: {err}"),
203 Some(raw_name.to_string()),
204 Some(index),
205 ));
206 return None;
207 }
208 }
209 };
210
211 let Some(source_value) = obj.get("source") else {
212 diagnostics.push(MarketplaceDiagnostic::error(
213 "MISSING_SOURCE",
214 format!("Codex plugin `{name}` is missing required `source`"),
215 Some(name.clone()),
216 Some(index),
217 ));
218 return None;
219 };
220 let (source, mut install_plan, source_diags) = normalize_codex_source(source_value);
221 entry_diags.extend(source_diags);
222
223 // `policy.installation` is honored as display/availability metadata
224 // only. Codewhale never auto-installs and never auto-authenticates.
225 let installation = obj
226 .get("policy")
227 .and_then(|p| p.get("installation"))
228 .and_then(Value::as_str);
229 match installation {
230 Some("NOT_AVAILABLE") => {
231 if install_plan.is_supported() {
232 install_plan = MarketplaceInstallPlan::Unsupported {
233 reason: "catalog policy marks this plugin NOT_AVAILABLE".to_string(),
234 raw: source_value.to_string(),
235 };
236 }
237 }
238 Some("INSTALLED_BY_DEFAULT") => {
239 entry_diags.push(MarketplaceDiagnostic::warning(
240 "NO_AUTO_INSTALL",
241 "Codex policy `INSTALLED_BY_DEFAULT` is ignored: Codewhale installs only on an explicit operator action".to_string(),
242 Some(name.clone()),
243 Some(index),
244 ));
245 }
246 Some("AVAILABLE") | None => {}
247 Some(other) => {
248 entry_diags.push(MarketplaceDiagnostic::warning(
249 "UNKNOWN_POLICY",
250 format!(
251 "Codex installation policy `{other}` is not documented; treated as AVAILABLE"
252 ),
253 Some(name.clone()),
254 Some(index),
255 ));
256 }
257 }
258
259 let (category, bad_category) = str_field(obj, "category");
260 if let Some(diag) = bad_category {
261 entry_diags.push(diag);
262 }
263
264 Some(MarketplaceCandidate {
265 id: MarketplaceCandidateId::new(catalog_id, &name),
266 catalog_id: catalog_id.clone(),
267 kind: MarketplaceEntryKind::Plugin,
268 icon: None,
269 name,
270 display_name: None,
271 description: None,
272 version: None,
273 author: None,
274 homepage: None,
275 repository: None,
276 license: None,
277 keywords: Vec::new(),
278 categories: category.map(|c| vec![c.to_string()]).unwrap_or_default(),
279 source,
280 install_plan,
281 // Codex marketplace entries declare no component counts; the
282 // `.codex-plugin/plugin.json` manifest at install time decides.
283 declared_components: None,
284 compatibility: None,
285 provenance: CatalogProvenance {
286 tier: CatalogTier::Community,
287 publisher: Some("Codex marketplace".to_string()),
288 source_url: None,
289 },
290 when: None,
291 diagnostics: entry_diags,
292 })
293 }
294
295 /// Only the four documented Codex source discriminators are accepted.
296 fn normalize_codex_source(
297 value: &Value,
298 ) -> (
299 MarketplaceSourceSpec,
300 MarketplaceInstallPlan,
301 Vec<MarketplaceDiagnostic>,
302 ) {
303 let mut diags = Vec::new();
304 let Some(obj) = value.as_object() else {
305 return (
306 MarketplaceSourceSpec::Invalid {
307 reason: "Codex source must be a tagged object".to_string(),
308 },
309 MarketplaceInstallPlan::Unsupported {
310 reason: "Codex source must be a `{source: local|url|git-subdir|npm}` object"
311 .to_string(),
312 raw: value.to_string(),
313 },
314 diags,
315 );
316 };
317 let discriminator = obj
318 .get("source")
319 .and_then(Value::as_str)
320 .unwrap_or_default();
321 let field = |name: &str| obj.get(name).and_then(Value::as_str);
322 match discriminator {
323 "local" => {
324 let Some(path) = field("path") else {
325 return invalid("local source requires `path`", value, &mut diags);
326 };
327 (
328 MarketplaceSourceSpec::LocalPath { path: path.into() },
329 MarketplaceInstallPlan::Supported {
330 spec: format!("path:{path}"),
331 source_kind: "Local directory".to_string(),
332 },
333 diags,
334 )
335 }
336 "url" | "git-subdir" => {
337 let Some(url) = field("url") else {
338 return invalid(
339 &format!("{discriminator} source requires `url`"),
340 value,
341 &mut diags,
342 );
343 };
344 if let Some(rest) = url
345 .strip_prefix("https://github.com/")
346 .or_else(|| url.strip_prefix("http://github.com/"))
347 {
348 let mut parts = rest.trim_end_matches('/').split('/');
349 if let (Some(owner), Some(repo), None) = (parts.next(), parts.next(), parts.next())
350 {
351 let repo = repo.trim_end_matches(".git");
352 if !owner.is_empty() && !repo.is_empty() {
353 diags.push(MarketplaceDiagnostic::warning(
354 "UNAPPLIED_PIN",
355 "Codewhale's GitHub installer resolves the default branch; ref/sha pins are recorded but not yet applied".to_string(),
356 None,
357 None,
358 ));
359 return (
360 MarketplaceSourceSpec::GitHub {
361 owner: owner.to_string(),
362 repo: repo.to_string(),
363 git_ref: field("ref").map(ToString::to_string),
364 sha: field("sha").map(ToString::to_string),
365 },
366 MarketplaceInstallPlan::Supported {
367 spec: format!("github:{owner}/{repo}"),
368 source_kind: "GitHub repository".to_string(),
369 },
370 diags,
371 );
372 }
373 }
374 }
375 (
376 MarketplaceSourceSpec::GitUrl {
377 url: url.to_string(),
378 },
379 MarketplaceInstallPlan::Unsupported {
380 reason: format!(
381 "{discriminator} installs of non-GitHub git URLs are not supported yet"
382 ),
383 raw: value.to_string(),
384 },
385 diags,
386 )
387 }
388 "npm" => {
389 let Some(package) = field("package") else {
390 return invalid("npm source requires `package`", value, &mut diags);
391 };
392 (
393 MarketplaceSourceSpec::Npm { package: package.to_string() },
394 MarketplaceInstallPlan::Unsupported {
395 reason: "Codewhale does not execute npm; install the plugin from a GitHub repo, tarball, or local path".to_string(),
396 raw: value.to_string(),
397 },
398 diags,
399 )
400 }
401 other => invalid(
402 &format!("unknown Codex source discriminator `{other}`"),
403 value,
404 &mut diags,
405 ),
406 }
407 }
408
409 fn invalid(
410 reason: &str,
411 value: &Value,
412 diags: &mut Vec<MarketplaceDiagnostic>,
413 ) -> (
414 MarketplaceSourceSpec,
415 MarketplaceInstallPlan,
416 Vec<MarketplaceDiagnostic>,
417 ) {
418 diags.push(MarketplaceDiagnostic::error(
419 "INVALID_SOURCE",
420 reason.to_string(),
421 None,
422 None,
423 ));
424 (
425 MarketplaceSourceSpec::Invalid {
426 reason: reason.to_string(),
427 },
428 MarketplaceInstallPlan::Unsupported {
429 reason: reason.to_string(),
430 raw: value.to_string(),
431 },
432 std::mem::take(diags),
433 )
434 }
435
435 lines RUST