@@ -1244,6 +1244,78 @@ def install_from_directory(
12441244
12451245 return manifest
12461246
1247+ def register_reference_extension (
1248+ self ,
1249+ source_dir : Path ,
1250+ speckit_version : str ,
1251+ register_commands : bool = True ,
1252+ priority : int = 10 ,
1253+ ) -> ExtensionManifest :
1254+ """Register an extension from a local directory without copying it.
1255+
1256+ Similar to install_from_directory() but:
1257+ - Does NOT copy files to .specify/extensions/
1258+ - Stores absolute path in registry for resolution
1259+ - Still registers commands, hooks, and skills
1260+
1261+ Args:
1262+ source_dir: Path to extension directory
1263+ speckit_version: Current spec-kit version
1264+ register_commands: If True, register commands with AI agents
1265+ priority: Resolution priority (lower = higher precedence, default 10)
1266+
1267+ Returns:
1268+ Registered extension manifest
1269+
1270+ Raises:
1271+ ValidationError: If manifest is invalid or priority is invalid
1272+ CompatibilityError: If extension is incompatible
1273+ ExtensionError: If extension is already installed
1274+ """
1275+ if priority < 1 :
1276+ raise ValidationError ("Priority must be a positive integer (1 or higher)" )
1277+
1278+ manifest_path = source_dir / "extension.yml"
1279+ manifest = ExtensionManifest (manifest_path )
1280+
1281+ self .check_compatibility (manifest , speckit_version )
1282+
1283+ if self .registry .is_installed (manifest .id ):
1284+ raise ExtensionError (
1285+ f"Extension '{ manifest .id } ' is already installed. "
1286+ f"Use 'specify extension remove { manifest .id } ' first."
1287+ )
1288+
1289+ self ._validate_install_conflicts (manifest )
1290+
1291+ registered_commands = {}
1292+ if register_commands :
1293+ registrar = CommandRegistrar ()
1294+ registered_commands = registrar .register_commands_for_all_agents (
1295+ manifest , source_dir , self .project_root
1296+ )
1297+
1298+ registered_skills = self ._register_extension_skills (manifest , source_dir )
1299+
1300+ hook_executor = HookExecutor (self .project_root )
1301+ hook_executor .register_hooks (manifest )
1302+
1303+ self .registry .add (
1304+ manifest .id ,
1305+ {
1306+ "version" : manifest .version ,
1307+ "source" : "reference" ,
1308+ "path" : str (source_dir .resolve ()),
1309+ "manifest_hash" : manifest .get_hash (),
1310+ "enabled" : True ,
1311+ "priority" : priority ,
1312+ "registered_commands" : registered_commands ,
1313+ "registered_skills" : registered_skills ,
1314+ },
1315+ )
1316+
1317+ return manifest
1318+
12471319 def install_from_zip (
12481320 self ,
12491321 zip_path : Path ,
@@ -1331,6 +1403,8 @@ def remove(self, extension_id: str, keep_config: bool = False) -> bool:
13311403 else :
13321404 registered_skills = []
13331405
1406+ # Check if this is a reference extension (no directory to delete)
1407+ is_reference = metadata and metadata .get ("source" ) == "reference"
13341408 extension_dir = self .extensions_dir / extension_id
13351409
13361410 # Unregister commands from all AI agents
@@ -1341,39 +1415,41 @@ def remove(self, extension_id: str, keep_config: bool = False) -> bool:
13411415 # Unregister agent skills
13421416 self ._unregister_extension_skills (registered_skills , extension_id )
13431417
1344- if keep_config :
1345- # Preserve config files, only remove non-config files
1346- if extension_dir .exists ():
1347- for child in extension_dir .iterdir ():
1348- # Keep top-level *-config.yml and *-config.local.yml files
1349- if child .is_file () and (
1350- child .name .endswith ("-config.yml" )
1351- or child .name .endswith ("-config.local.yml" )
1352- ):
1353- continue
1354- if child .is_dir ():
1355- shutil .rmtree (child )
1356- else :
1357- child .unlink ()
1358- else :
1359- # Backup config files before deleting
1360- if extension_dir .exists ():
1361- # Use subdirectory per extension to avoid name accumulation
1362- # (e.g., jira-jira-config.yml on repeated remove/install cycles)
1363- backup_dir = self .extensions_dir / ".backup" / extension_id
1364- backup_dir .mkdir (parents = True , exist_ok = True )
1365-
1366- # Backup both primary and local override config files
1367- config_files = list (extension_dir .glob ("*-config.yml" )) + list (
1368- extension_dir .glob ("*-config.local.yml" )
1369- )
1370- for config_file in config_files :
1371- backup_path = backup_dir / config_file .name
1372- shutil .copy2 (config_file , backup_path )
1418+ # Only remove directory for non-reference extensions
1419+ if not is_reference :
1420+ if keep_config :
1421+ # Preserve config files, only remove non-config files
1422+ if extension_dir .exists ():
1423+ for child in extension_dir .iterdir ():
1424+ # Keep top-level *-config.yml and *-config.local.yml files
1425+ if child .is_file () and (
1426+ child .name .endswith ("-config.yml" )
1427+ or child .name .endswith ("-config.local.yml" )
1428+ ):
1429+ continue
1430+ if child .is_dir ():
1431+ shutil .rmtree (child )
1432+ else :
1433+ child .unlink ()
1434+ else :
1435+ # Backup config files before deleting
1436+ if extension_dir .exists ():
1437+ # Use subdirectory per extension to avoid name accumulation
1438+ # (e.g., jira-jira-config.yml on repeated remove/install cycles)
1439+ backup_dir = self .extensions_dir / ".backup" / extension_id
1440+ backup_dir .mkdir (parents = True , exist_ok = True )
1441+
1442+ # Backup both primary and local override config files
1443+ config_files = list (extension_dir .glob ("*-config.yml" )) + list (
1444+ extension_dir .glob ("*-config.local.yml" )
1445+ )
1446+ for config_file in config_files :
1447+ backup_path = backup_dir / config_file .name
1448+ shutil .copy2 (config_file , backup_path )
13731449
1374- # Remove extension directory
1375- if extension_dir .exists ():
1376- shutil .rmtree (extension_dir )
1450+ # Remove extension directory
1451+ if extension_dir .exists ():
1452+ shutil .rmtree (extension_dir )
13771453
13781454 # Unregister hooks
13791455 hook_executor = HookExecutor (self .project_root )
@@ -1546,8 +1622,13 @@ def list_installed(self) -> List[Dict[str, Any]]:
15461622 # Ensure metadata is a dictionary to avoid AttributeError when using .get()
15471623 if not isinstance (metadata , dict ):
15481624 metadata = {}
1549- ext_dir = self .extensions_dir / ext_id
1550- manifest_path = ext_dir / "extension.yml"
1625+
1626+ # For reference extensions, use the stored path; otherwise use standard location
1627+ if metadata .get ("source" ) == "reference" and metadata .get ("path" ):
1628+ manifest_path = Path (metadata ["path" ]) / "extension.yml"
1629+ else :
1630+ ext_dir = self .extensions_dir / ext_id
1631+ manifest_path = ext_dir / "extension.yml"
15511632
15521633 try :
15531634 manifest = ExtensionManifest (manifest_path )
@@ -1594,8 +1675,14 @@ def get_extension(self, extension_id: str) -> Optional[ExtensionManifest]:
15941675 if not self .registry .is_installed (extension_id ):
15951676 return None
15961677
1597- ext_dir = self .extensions_dir / extension_id
1598- manifest_path = ext_dir / "extension.yml"
1678+ metadata = self .registry .get (extension_id )
1679+
1680+ # For reference extensions, use the stored path; otherwise use standard location
1681+ if metadata and metadata .get ("source" ) == "reference" and metadata .get ("path" ):
1682+ manifest_path = Path (metadata ["path" ]) / "extension.yml"
1683+ else :
1684+ ext_dir = self .extensions_dir / extension_id
1685+ manifest_path = ext_dir / "extension.yml"
15991686
16001687 try :
16011688 return ExtensionManifest (manifest_path )
0 commit comments