Skip to content

Commit 3e45a8d

Browse files
committed
fix: team-ai-directives reference mode registration in extension registry
1 parent 945372e commit 3e45a8d

5 files changed

Lines changed: 143 additions & 44 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
All notable changes to the Specify CLI and templates are documented here.
44

5+
# [0.8.12+adlc18] - 2026-05-23
6+
7+
### Fixed
8+
9+
- **team-ai-directives reference mode**: Extension is now properly registered in `.specify/extensions/.registry` when using `--team-ai-directives` with a local directory path. Previously, the extension was not registered, causing `specify extension list` and `specify extension remove` to not recognize it, and hooks/commands were not registered with AI agents.
10+
511
# [0.8.12+adlc17] - 2026-05-22
612

713
### Added

FORK.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ Use `agentic-sdlc-v<version>` with plus:
3939

4040
| Version | Date | Base Upstream | Changes |
4141
|---------|------|---------------|---------|
42+
| 0.8.12+adlc18 | 2026-05-23 | 0.8.12 | Fix team-ai-directives reference mode registration in extension registry |
4243
| 0.8.12+adlc17 | 2026-05-22 | 0.8.12 | LevelUp extension v1.6.0: Repair command for CDR.md, .skills.json, and AGENTS.md reindexing |
4344
| 0.8.12+adlc16 | 2026-05-20 | 0.8.12 | Architect extension v2.1.0: Technology neutrality in Functional View, Functional-Development view parity, analyze Pass E.6/E.7 |
4445
| 0.8.12+adlc14 | 2026-05-19 | 0.8.12 | Product extension v1.5.6: In-section diagrams, remove Visual Summary, sections renumbered 1-12, delete visual templates, no .specify/product/visuals/ |

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "agentic-sdlc-specify-cli"
3-
version = "0.8.12+adlc17"
3+
version = "0.8.12+adlc18"
44
description = "Specify CLI (tikalk fork). Agentic SDLC toolkit for Spec-Driven Development with pre-installed extensions and AI integrations."
55
requires-python = ">=3.11"
66
dependencies = [

src/specify_cli/cli_customization.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -799,13 +799,18 @@ def sync_team_ai_directives(
799799
)
800800

801801
if not install:
802-
# Reference mode: use directory in-place without copying
803-
manifest = ExtensionManifest(manifest_path)
804-
if manifest.id != TEAM_DIRECTIVES_DIRNAME:
805-
raise ValueError(
806-
f"Extension ID mismatch: expected '{TEAM_DIRECTIVES_DIRNAME}', "
807-
f"got '{manifest.id}'"
808-
)
802+
# Reference mode: register extension without copying
803+
ext_manager = ExtensionManager(project_root)
804+
speckit_version = get_speckit_version()
805+
806+
# Force override: remove existing registration before re-registering
807+
if force and ext_manager.registry.is_installed(TEAM_DIRECTIVES_DIRNAME):
808+
ext_manager.remove(TEAM_DIRECTIVES_DIRNAME)
809+
810+
manifest = ext_manager.register_reference_extension(
811+
potential_path, speckit_version, priority=1
812+
)
813+
_store_extension_source_url(project_root, manifest.id, str(potential_path.resolve()))
809814
return ("reference", potential_path)
810815

811816
# Install mode: copy to .specify/extensions/

src/specify_cli/extensions.py

Lines changed: 123 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)