Skip to content

Commit b909549

Browse files
mvanhornclaude
andcommitted
feat(extensions): scaffold config templates on extension add/enable
When an extension is installed via `extension add` or re-enabled via `extension enable`, automatically deploy config templates from the extension's `provides.config` section to the project's `.specify/` directory. Existing config files are preserved (never overwritten). This replaces the previous "Configuration may be required" warning with automatic scaffolding, per maintainer feedback on PR #1929. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent edaa5a7 commit b909549

File tree

3 files changed

+176
-2
lines changed

3 files changed

+176
-2
lines changed

src/specify_cli/__init__.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3666,8 +3666,14 @@ def extension_add(
36663666
if reg_skills:
36673667
console.print(f"\n[green]✓[/green] {len(reg_skills)} agent skill(s) auto-registered")
36683668

3669-
console.print("\n[yellow]⚠[/yellow] Configuration may be required")
3670-
console.print(f" Check: .specify/extensions/{manifest.id}/")
3669+
# Scaffold config templates automatically
3670+
deployed = manager.scaffold_config(manifest.id)
3671+
if deployed:
3672+
console.print("\n[bold cyan]Config scaffolded:[/bold cyan]")
3673+
for cfg in deployed:
3674+
console.print(f" • .specify/{cfg}")
3675+
elif manifest.config:
3676+
console.print("\n[dim]Config files already exist (preserved).[/dim]")
36713677

36723678
except ValidationError as e:
36733679
console.print(f"\n[red]Validation Error:[/red] {e}")
@@ -4470,6 +4476,13 @@ def extension_enable(
44704476

44714477
console.print(f"[green]✓[/green] Extension '{display_name}' enabled")
44724478

4479+
# Scaffold config templates on enable
4480+
deployed = manager.scaffold_config(extension_id)
4481+
if deployed:
4482+
console.print("\n[bold cyan]Config scaffolded:[/bold cyan]")
4483+
for cfg in deployed:
4484+
console.print(f" • .specify/{cfg}")
4485+
44734486

44744487
@extension_app.command("disable")
44754488
def extension_disable(

src/specify_cli/extensions.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,11 @@ def hooks(self) -> Dict[str, Any]:
233233
"""Get hook definitions."""
234234
return self.data.get("hooks", {})
235235

236+
@property
237+
def config(self) -> List[Dict[str, Any]]:
238+
"""Get list of provided config templates."""
239+
return self.data.get("provides", {}).get("config", [])
240+
236241
def get_hash(self) -> str:
237242
"""Calculate SHA256 hash of manifest file."""
238243
with open(self.path, 'rb') as f:
@@ -1125,6 +1130,48 @@ def install_from_zip(
11251130
# Install from extracted directory
11261131
return self.install_from_directory(extension_dir, speckit_version, priority=priority)
11271132

1133+
def scaffold_config(self, extension_id: str) -> List[str]:
1134+
"""Deploy config templates from an installed extension to the project.
1135+
1136+
Reads the extension's manifest provides.config section and copies
1137+
each config template to the project's .specify/ directory. Existing
1138+
config files are never overwritten (user customizations are preserved).
1139+
1140+
Args:
1141+
extension_id: ID of the installed extension
1142+
1143+
Returns:
1144+
List of deployed config file names (empty if all already existed)
1145+
"""
1146+
ext_dir = self.extensions_dir / extension_id
1147+
manifest_path = ext_dir / "extension.yml"
1148+
if not manifest_path.exists():
1149+
return []
1150+
1151+
manifest = ExtensionManifest(manifest_path)
1152+
deployed = []
1153+
1154+
for config_entry in manifest.config:
1155+
template_name = config_entry.get("template", "")
1156+
target_name = config_entry.get("name", template_name)
1157+
if not template_name:
1158+
continue
1159+
1160+
template_path = ext_dir / template_name
1161+
if not template_path.exists():
1162+
continue
1163+
1164+
target_path = self.project_root / ".specify" / target_name
1165+
if target_path.exists():
1166+
# Never overwrite user-customized config
1167+
continue
1168+
1169+
target_path.parent.mkdir(parents=True, exist_ok=True)
1170+
shutil.copy2(template_path, target_path)
1171+
deployed.append(target_name)
1172+
1173+
return deployed
1174+
11281175
def remove(self, extension_id: str, keep_config: bool = False) -> bool:
11291176
"""Remove an installed extension.
11301177

tests/test_extensions.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -832,6 +832,120 @@ def test_config_backup_on_remove(self, extension_dir, project_dir):
832832
assert backup_file.read_text() == "test: config"
833833

834834

835+
class TestExtensionConfigScaffolding:
836+
"""Test automatic config scaffolding during add/enable lifecycle."""
837+
838+
def _make_extension(self, ext_dir, config_entries=None):
839+
"""Create a minimal extension with optional config templates."""
840+
ext_dir.mkdir(parents=True, exist_ok=True)
841+
manifest = {
842+
"schema_version": "1.0",
843+
"extension": {
844+
"id": "test-ext",
845+
"name": "Test Extension",
846+
"version": "1.0.0",
847+
"description": "Test extension",
848+
"author": "Test",
849+
"repository": "https://github.com/test/test",
850+
"license": "MIT",
851+
"homepage": "https://github.com/test/test",
852+
},
853+
"requires": {"speckit_version": ">=0.1.0"},
854+
"provides": {
855+
"commands": [{
856+
"name": "speckit.test-ext.example",
857+
"file": "commands/example.md",
858+
"description": "Example command",
859+
}],
860+
},
861+
"tags": ["test"],
862+
}
863+
if config_entries:
864+
manifest["provides"]["config"] = config_entries
865+
import yaml
866+
(ext_dir / "extension.yml").write_text(yaml.dump(manifest, default_flow_style=False))
867+
# Create command file so validation passes
868+
(ext_dir / "commands").mkdir(exist_ok=True)
869+
(ext_dir / "commands" / "example.md").write_text("# Example")
870+
return manifest
871+
872+
def test_scaffold_config_deploys_template(self, tmp_path):
873+
"""Config template should be copied to .specify/ on scaffold."""
874+
from specify_cli.extensions import ExtensionManager
875+
project = tmp_path / "project"
876+
specify_dir = project / ".specify"
877+
specify_dir.mkdir(parents=True)
878+
ext_dir = specify_dir / "extensions" / "test-ext"
879+
self._make_extension(ext_dir, config_entries=[{
880+
"name": "test-config.yml",
881+
"template": "config-template.yml",
882+
"description": "Test config",
883+
"required": True,
884+
}])
885+
(ext_dir / "config-template.yml").write_text("setting: default")
886+
887+
manager = ExtensionManager(project)
888+
deployed = manager.scaffold_config("test-ext")
889+
890+
assert deployed == ["test-config.yml"]
891+
assert (specify_dir / "test-config.yml").exists()
892+
assert (specify_dir / "test-config.yml").read_text() == "setting: default"
893+
894+
def test_scaffold_config_preserves_existing(self, tmp_path):
895+
"""Existing config files should never be overwritten."""
896+
from specify_cli.extensions import ExtensionManager
897+
project = tmp_path / "project"
898+
specify_dir = project / ".specify"
899+
specify_dir.mkdir(parents=True)
900+
(specify_dir / "test-config.yml").write_text("setting: custom")
901+
ext_dir = specify_dir / "extensions" / "test-ext"
902+
self._make_extension(ext_dir, config_entries=[{
903+
"name": "test-config.yml",
904+
"template": "config-template.yml",
905+
"description": "Test config",
906+
"required": True,
907+
}])
908+
(ext_dir / "config-template.yml").write_text("setting: default")
909+
910+
manager = ExtensionManager(project)
911+
deployed = manager.scaffold_config("test-ext")
912+
913+
assert deployed == []
914+
assert (specify_dir / "test-config.yml").read_text() == "setting: custom"
915+
916+
def test_scaffold_config_no_config_section(self, tmp_path):
917+
"""Extensions without config section should return empty list."""
918+
from specify_cli.extensions import ExtensionManager
919+
project = tmp_path / "project"
920+
specify_dir = project / ".specify"
921+
specify_dir.mkdir(parents=True)
922+
ext_dir = specify_dir / "extensions" / "test-ext"
923+
self._make_extension(ext_dir)
924+
925+
manager = ExtensionManager(project)
926+
deployed = manager.scaffold_config("test-ext")
927+
928+
assert deployed == []
929+
930+
def test_scaffold_config_missing_template_file(self, tmp_path):
931+
"""Missing template file should be silently skipped."""
932+
from specify_cli.extensions import ExtensionManager
933+
project = tmp_path / "project"
934+
specify_dir = project / ".specify"
935+
specify_dir.mkdir(parents=True)
936+
ext_dir = specify_dir / "extensions" / "test-ext"
937+
self._make_extension(ext_dir, config_entries=[{
938+
"name": "test-config.yml",
939+
"template": "nonexistent.yml",
940+
"description": "Test config",
941+
}])
942+
943+
manager = ExtensionManager(project)
944+
deployed = manager.scaffold_config("test-ext")
945+
946+
assert deployed == []
947+
948+
835949
# ===== CommandRegistrar Tests =====
836950

837951
class TestCommandRegistrar:

0 commit comments

Comments
 (0)