@@ -847,6 +847,178 @@ def get_extension(self, extension_id: str) -> Optional[ExtensionManifest]:
847847 return None
848848
849849
850+ class ExtensionResolver :
851+ """Resolves and discovers templates provided by installed extensions.
852+
853+ Handles priority-based ordering of extensions, template resolution,
854+ and source attribution for extension-provided templates.
855+
856+ This class owns the extension tier of the template resolution stack.
857+ PresetResolver delegates to it for extension lookups rather than
858+ walking extension directories directly.
859+ """
860+
861+ def __init__ (self , project_root : Path ):
862+ self .project_root = project_root
863+ self .extensions_dir = project_root / ".specify" / "extensions"
864+
865+ def get_all_by_priority (self ) -> List [tuple ]:
866+ """Build unified list of registered and unregistered extensions sorted by priority.
867+
868+ Registered extensions use their stored priority; unregistered directories
869+ get implicit priority=10. Results are sorted by (priority, ext_id) for
870+ deterministic ordering.
871+
872+ Returns:
873+ List of (priority, ext_id, metadata_or_none) tuples sorted by priority.
874+ """
875+ if not self .extensions_dir .exists ():
876+ return []
877+
878+ registry = ExtensionRegistry (self .extensions_dir )
879+ registered_extension_ids = registry .keys ()
880+ all_registered = registry .list_by_priority (include_disabled = True )
881+
882+ all_extensions : list [tuple [int , str , dict | None ]] = []
883+
884+ for ext_id , metadata in all_registered :
885+ if not metadata .get ("enabled" , True ):
886+ continue
887+ priority = normalize_priority (metadata .get ("priority" ) if metadata else None )
888+ all_extensions .append ((priority , ext_id , metadata ))
889+
890+ for ext_dir in self .extensions_dir .iterdir ():
891+ if not ext_dir .is_dir () or ext_dir .name .startswith ("." ):
892+ continue
893+ if ext_dir .name not in registered_extension_ids :
894+ all_extensions .append ((10 , ext_dir .name , None ))
895+
896+ all_extensions .sort (key = lambda x : (x [0 ], x [1 ]))
897+ return all_extensions
898+
899+ def resolve (
900+ self ,
901+ template_name : str ,
902+ template_type : str = "template" ,
903+ ) -> Optional [Path ]:
904+ """Resolve a template name to its file path within extensions.
905+
906+ Args:
907+ template_name: Template name (e.g., "spec-template")
908+ template_type: Template type ("template", "command", or "script")
909+
910+ Returns:
911+ Path to the resolved template file, or None if not found
912+ """
913+ subdirs , ext = self ._type_config (template_type )
914+
915+ for _priority , ext_id , _metadata in self .get_all_by_priority ():
916+ ext_dir = self .extensions_dir / ext_id
917+ if not ext_dir .is_dir ():
918+ continue
919+ for subdir in subdirs :
920+ if subdir :
921+ candidate = ext_dir / subdir / f"{ template_name } { ext } "
922+ else :
923+ candidate = ext_dir / f"{ template_name } { ext } "
924+ if candidate .exists ():
925+ return candidate
926+
927+ return None
928+
929+ def resolve_with_source (
930+ self ,
931+ template_name : str ,
932+ template_type : str = "template" ,
933+ ) -> Optional [Dict [str , str ]]:
934+ """Resolve a template name and return source attribution.
935+
936+ Args:
937+ template_name: Template name (e.g., "spec-template")
938+ template_type: Template type ("template", "command", or "script")
939+
940+ Returns:
941+ Dictionary with 'path' and 'source' keys, or None if not found
942+ """
943+ subdirs , ext = self ._type_config (template_type )
944+
945+ for _priority , ext_id , ext_meta in self .get_all_by_priority ():
946+ ext_dir = self .extensions_dir / ext_id
947+ if not ext_dir .is_dir ():
948+ continue
949+ for subdir in subdirs :
950+ if subdir :
951+ candidate = ext_dir / subdir / f"{ template_name } { ext } "
952+ else :
953+ candidate = ext_dir / f"{ template_name } { ext } "
954+ if candidate .exists ():
955+ if ext_meta :
956+ version = ext_meta .get ("version" , "?" )
957+ source = f"extension:{ ext_id } v{ version } "
958+ else :
959+ source = f"extension:{ ext_id } (unregistered)"
960+ return {"path" : str (candidate ), "source" : source }
961+
962+ return None
963+
964+ def list_templates (
965+ self ,
966+ template_type : str = "template" ,
967+ ) -> List [Dict [str , str ]]:
968+ """List all templates of a given type provided by extensions.
969+
970+ Returns templates sorted by extension priority, then alphabetically.
971+
972+ Args:
973+ template_type: Template type ("template", "command", or "script")
974+
975+ Returns:
976+ List of dicts with 'name', 'path', and 'source' keys.
977+ """
978+ subdirs , ext = self ._type_config (template_type )
979+ results : List [Dict [str , str ]] = []
980+ seen : set [str ] = set ()
981+
982+ for _priority , ext_id , ext_meta in self .get_all_by_priority ():
983+ ext_dir = self .extensions_dir / ext_id
984+ if not ext_dir .is_dir ():
985+ continue
986+
987+ if ext_meta :
988+ version = ext_meta .get ("version" , "?" )
989+ source_label = f"extension:{ ext_id } v{ version } "
990+ else :
991+ source_label = f"extension:{ ext_id } (unregistered)"
992+
993+ for subdir in subdirs :
994+ scan_dir = ext_dir / subdir if subdir else ext_dir
995+ if not scan_dir .is_dir ():
996+ continue
997+ for f in sorted (scan_dir .iterdir ()):
998+ if f .is_file () and f .suffix == ext :
999+ name = f .stem
1000+ if name not in seen :
1001+ seen .add (name )
1002+ results .append ({
1003+ "name" : name ,
1004+ "path" : str (f ),
1005+ "source" : source_label ,
1006+ })
1007+
1008+ return results
1009+
1010+ @staticmethod
1011+ def _type_config (template_type : str ) -> tuple :
1012+ """Return (subdirs, file_extension) for a template type."""
1013+ if template_type == "template" :
1014+ return ["templates" , "" ], ".md"
1015+ elif template_type == "command" :
1016+ return ["commands" ], ".md"
1017+ elif template_type == "script" :
1018+ return ["scripts" ], ".sh"
1019+ return ["" ], ".md"
1020+
1021+
8501022def version_satisfies (current : str , required : str ) -> bool :
8511023 """Check if current version satisfies required version specifier.
8521024
0 commit comments