11use super :: { ExtensionError , PROVIDER_MANIFEST_FILE , ProviderManifest } ;
2- use serde:: { Deserialize , Serialize } ;
2+ use serde:: { Deserialize , Deserializer , Serialize , de :: Error as _ } ;
33use std:: collections:: BTreeSet ;
44use std:: fs:: { self , File } ;
55use std:: io:: { self , Read } ;
@@ -46,7 +46,7 @@ pub struct PluginManifest {
4646 pub version : String ,
4747 #[ serde( default ) ]
4848 pub description : Option < String > ,
49- #[ serde( default ) ]
49+ #[ serde( default , deserialize_with = "deserialize_author" ) ]
5050 pub author : Option < String > ,
5151 #[ serde( default ) ]
5252 pub homepage : Option < String > ,
@@ -62,6 +62,23 @@ fn default_plugin_manifest_version() -> u32 {
6262 PLUGIN_MANIFEST_VERSION
6363}
6464
65+ fn deserialize_author < ' de , D > ( deserializer : D ) -> Result < Option < String > , D :: Error >
66+ where
67+ D : Deserializer < ' de > ,
68+ {
69+ let value = Option :: < serde_json:: Value > :: deserialize ( deserializer) ?;
70+ match value {
71+ None => Ok ( None ) ,
72+ Some ( serde_json:: Value :: String ( author) ) => Ok ( Some ( author) ) ,
73+ Some ( serde_json:: Value :: Object ( author) ) => author
74+ . get ( "name" )
75+ . and_then ( serde_json:: Value :: as_str)
76+ . map ( |name| Some ( name. to_string ( ) ) )
77+ . ok_or_else ( || D :: Error :: custom ( "author object must contain a string name" ) ) ,
78+ Some ( _) => Err ( D :: Error :: custom ( "author must be a string or object" ) ) ,
79+ }
80+ }
81+
6582impl PluginManifest {
6683 fn validate ( & self ) -> Result < ( ) , BundleError > {
6784 if self . manifest_version != PLUGIN_MANIFEST_VERSION {
@@ -184,11 +201,10 @@ impl PluginBundle {
184201 . into_iter ( )
185202 . filter ( |path| path. is_file ( ) )
186203 . collect :: < Vec < _ > > ( ) ;
187- let manifest_path = match manifest_candidates. as_slice ( ) {
188- [ ] => return Err ( BundleError :: MissingManifest ) ,
189- [ path] => path. clone ( ) ,
190- paths => return Err ( BundleError :: AmbiguousManifest ( paths. to_vec ( ) ) ) ,
191- } ;
204+ let manifest_path = manifest_candidates
205+ . first ( )
206+ . cloned ( )
207+ . ok_or ( BundleError :: MissingManifest ) ?;
192208
193209 let manifest_bytes = read_bounded ( & manifest_path, MAX_PLUGIN_MANIFEST_BYTES ) . map_err (
194210 |error| match error {
@@ -210,6 +226,29 @@ impl PluginBundle {
210226 }
211227 } ) ?;
212228 manifest. validate ( ) ?;
229+ for candidate in manifest_candidates. iter ( ) . skip ( 1 ) {
230+ let candidate_bytes = read_bounded ( candidate, MAX_PLUGIN_MANIFEST_BYTES ) . map_err (
231+ |error| match error {
232+ BoundedReadError :: TooLarge => BundleError :: ManifestTooLarge {
233+ path : candidate. clone ( ) ,
234+ limit : MAX_PLUGIN_MANIFEST_BYTES ,
235+ } ,
236+ BoundedReadError :: Io ( source) => BundleError :: Read {
237+ path : candidate. clone ( ) ,
238+ source,
239+ } ,
240+ } ,
241+ ) ?;
242+ let candidate_manifest: PluginManifest = serde_json:: from_slice ( & candidate_bytes)
243+ . map_err ( |source| BundleError :: ParseManifest {
244+ path : candidate. clone ( ) ,
245+ source,
246+ } ) ?;
247+ candidate_manifest. validate ( ) ?;
248+ if !compatible_manifests ( & manifest, & candidate_manifest) {
249+ return Err ( BundleError :: AmbiguousManifest ( manifest_candidates) ) ;
250+ }
251+ }
213252
214253 let provider_path = root. join ( PROVIDER_MANIFEST_FILE ) ;
215254 let provider_manifest = if provider_path. is_file ( ) {
@@ -236,6 +275,22 @@ impl PluginBundle {
236275 }
237276}
238277
278+ fn compatible_manifests ( left : & PluginManifest , right : & PluginManifest ) -> bool {
279+ left. manifest_version == right. manifest_version
280+ && left. name == right. name
281+ && left. version == right. version
282+ && optional_field_matches ( & left. homepage , & right. homepage )
283+ && optional_field_matches ( & left. repository , & right. repository )
284+ && optional_field_matches ( & left. license , & right. license )
285+ }
286+
287+ fn optional_field_matches ( left : & Option < String > , right : & Option < String > ) -> bool {
288+ match ( left, right) {
289+ ( Some ( left) , Some ( right) ) => left == right,
290+ _ => true ,
291+ }
292+ }
293+
239294fn discover_skills ( root : & Path ) -> Result < Vec < SkillMetadata > , BundleError > {
240295 let skills_root = root. join ( "skills" ) ;
241296 if !skills_root. is_dir ( ) {
@@ -474,4 +529,50 @@ mod tests {
474529 Err ( BundleError :: AmbiguousManifest ( _) )
475530 ) ) ;
476531 }
532+
533+ #[ test]
534+ fn accepts_equivalent_claude_and_codex_manifests ( ) {
535+ let dir = tempdir ( ) . unwrap ( ) ;
536+ fs:: create_dir_all ( dir. path ( ) . join ( ".claude-plugin" ) ) . unwrap ( ) ;
537+ fs:: create_dir_all ( dir. path ( ) . join ( ".codex-plugin" ) ) . unwrap ( ) ;
538+ let manifest = r#"{"name":"shared-plugin","version":"1.0.0"}"# ;
539+ fs:: write ( dir. path ( ) . join ( ".claude-plugin/plugin.json" ) , manifest) . unwrap ( ) ;
540+ fs:: write ( dir. path ( ) . join ( ".codex-plugin/plugin.json" ) , manifest) . unwrap ( ) ;
541+
542+ let bundle = PluginBundle :: load ( dir. path ( ) ) . unwrap ( ) ;
543+ assert_eq ! ( bundle. manifest. name, "shared-plugin" ) ;
544+ }
545+
546+ #[ test]
547+ fn accepts_platform_specific_metadata_for_same_plugin_identity ( ) {
548+ let dir = tempdir ( ) . unwrap ( ) ;
549+ fs:: create_dir_all ( dir. path ( ) . join ( ".claude-plugin" ) ) . unwrap ( ) ;
550+ fs:: create_dir_all ( dir. path ( ) . join ( ".codex-plugin" ) ) . unwrap ( ) ;
551+ fs:: write (
552+ dir. path ( ) . join ( ".codex-plugin/plugin.json" ) ,
553+ r#"{"name":"shared-plugin","version":"1.0.0","description":"Codex description","keywords":["codex"],"repository":"https://github.com/example/shared-plugin"}"# ,
554+ )
555+ . unwrap ( ) ;
556+ fs:: write (
557+ dir. path ( ) . join ( ".claude-plugin/plugin.json" ) ,
558+ r#"{"name":"shared-plugin","version":"1.0.0","description":"Claude description","keywords":["claude"],"repository":"https://github.com/example/shared-plugin"}"# ,
559+ )
560+ . unwrap ( ) ;
561+
562+ let bundle = PluginBundle :: load ( dir. path ( ) ) . unwrap ( ) ;
563+ assert_eq ! ( bundle. manifest. name, "shared-plugin" ) ;
564+ }
565+
566+ #[ test]
567+ fn accepts_structured_author_metadata ( ) {
568+ let dir = tempdir ( ) . unwrap ( ) ;
569+ fs:: write (
570+ dir. path ( ) . join ( "plugin.json" ) ,
571+ r#"{"name":"structured-author","version":"1.0.0","author":{"name":"Jesse Vincent","email":"jesse@example.com"}}"# ,
572+ )
573+ . unwrap ( ) ;
574+
575+ let bundle = PluginBundle :: load ( dir. path ( ) ) . unwrap ( ) ;
576+ assert_eq ! ( bundle. manifest. author. as_deref( ) , Some ( "Jesse Vincent" ) ) ;
577+ }
477578}
0 commit comments