diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 91d8193fa1..5c2a069340 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -490,6 +490,14 @@ def commands(self) -> List[Dict[str, Any]]: """Get list of provided commands.""" return self.data.get("provides", {}).get("commands", []) + @property + def config(self) -> List[Dict[str, Any]]: + """Get list of provided config templates, normalized to dictionaries.""" + raw = self.data.get("provides", {}).get("config", []) + if not isinstance(raw, list) or not all(isinstance(entry, dict) for entry in raw): + return [] + return raw + @property def hooks(self) -> Dict[str, Any]: """Get hook definitions.""" @@ -2088,6 +2096,84 @@ def install_from_zip( extension_dir, speckit_version, priority=priority, force=force ) + def scaffold_config(self, extension_id: str) -> tuple[List[str], List[str], List[str]]: + """Deploy config templates from an installed extension to the project. + + Reads the extension's manifest provides.config section and copies + each config template to the project's .specify/ directory. Existing + config files are never overwritten (user customizations are preserved). + + Args: + extension_id: ID of the installed extension + + Returns: + Tuple of (deployed, skipped_existing, failed) where each is a list + of config file names. + """ + ext_dir = self.extensions_dir / extension_id + manifest_path = ext_dir / "extension.yml" + if not manifest_path.exists(): + return [], [], [] + + manifest = ExtensionManifest(manifest_path) + deployed = [] + skipped_existing = [] + failed = [] + + provides = manifest.data.get("provides", {}) + raw_config = provides.get("config", []) + config_is_malformed = ( + "config" in provides + and ( + not isinstance(raw_config, list) + or not all(isinstance(entry, dict) for entry in raw_config) + ) + ) + if config_is_malformed: + return deployed, skipped_existing, ["provides.config"] + + ext_dir_resolved = ext_dir.resolve() + specify_dir_resolved = (self.project_root / ".specify").resolve() + + for config_entry in manifest.config: + template_name = config_entry.get("template", "") + target_name = config_entry.get("name", template_name) + failure_name = target_name if isinstance(target_name, str) and target_name else "provides.config" + if not isinstance(template_name, str) or not template_name: + failed.append(failure_name) + continue + if not isinstance(target_name, str) or not target_name: + failed.append(failure_name) + continue + + template_candidate = ext_dir / template_name + template_path = template_candidate.resolve() + target_path = (self.project_root / ".specify" / target_name).resolve() + try: + template_path.relative_to(ext_dir_resolved) + target_path.relative_to(specify_dir_resolved) + except ValueError: + failed.append(failure_name) + continue + + if template_candidate.is_symlink() or not template_path.is_file(): + failed.append(failure_name) + continue + + if target_path.exists(): + skipped_existing.append(target_name) + continue + + target_path.parent.mkdir(parents=True, exist_ok=True) + try: + shutil.copy2(template_path, target_path) + except OSError: + failed.append(target_name) + continue + deployed.append(target_name) + + return deployed, skipped_existing, failed + def remove(self, extension_id: str, keep_config: bool = False) -> bool: """Remove an installed extension. diff --git a/src/specify_cli/extensions/_commands.py b/src/specify_cli/extensions/_commands.py index 7ae4f8e9f0..2d7fd885fe 100644 --- a/src/specify_cli/extensions/_commands.py +++ b/src/specify_cli/extensions/_commands.py @@ -657,6 +657,21 @@ def extension_add( if reg_skills: console.print(f"\n[green]✓[/green] {len(reg_skills)} agent skill(s) auto-registered") + # Scaffold config templates automatically + deployed, skipped, failed = manager.scaffold_config(manifest.id) + if deployed: + console.print("\n[bold cyan]Config scaffolded:[/bold cyan]") + for cfg in deployed: + console.print(f" • .specify/{_escape_markup(str(cfg))}") + if skipped: + console.print(f"\n[dim]Config files already exist (preserved): {_escape_markup(', '.join(skipped))}[/dim]") + if failed: + console.print( + f"\n[yellow]Warning:[/yellow] Config templates not scaffolded: " + f"{_escape_markup(', '.join(failed))}. " + "Verify the extension manifest and template files." + ) + console.print("\n[yellow]⚠[/yellow] Configuration may be required") console.print(f" Check: .specify/extensions/{_escape_markup(str(manifest.id))}/") @@ -1501,6 +1516,29 @@ def extension_enable( console.print(f"[green]✓[/green] Extension '{_escape_markup(str(display_name))}' enabled") + # Scaffold config templates on enable + try: + deployed, skipped, failed = manager.scaffold_config(extension_id) + except Exception as exc: + console.print( + f"\n[yellow]Warning:[/yellow] Failed to scaffold config for extension " + f"'{_escape_markup(str(display_name))}'." + ) + console.print(f"[dim]Details: {_escape_markup(str(exc))}[/dim]") + deployed, skipped, failed = [], [], [] + if deployed: + console.print("\n[bold cyan]Config scaffolded:[/bold cyan]") + for cfg in deployed: + console.print(f" • .specify/{_escape_markup(str(cfg))}") + if skipped: + console.print(f"\n[dim]Config files already exist (preserved): {_escape_markup(', '.join(skipped))}[/dim]") + if failed: + console.print( + f"\n[yellow]Warning:[/yellow] Config templates not scaffolded: " + f"{_escape_markup(', '.join(failed))}. " + "Verify the extension manifest and template files." + ) + @extension_app.command("disable") def extension_disable( diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 61826aad5b..50c3bbfce6 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -9115,3 +9115,224 @@ def test_forge_extension_install_listing_hyphenates_command_names( # Forge registers hyphenated command names, so the summary must match. assert "speckit-test-ext-hello" in result.output assert "speckit.test-ext.hello" not in result.output + + +# ===== Extension Config Scaffolding Tests ===== + + +class TestExtensionConfigScaffolding: + """Test automatic config scaffolding during add/enable lifecycle.""" + + def _make_extension(self, ext_dir, config_entries=None): + """Create a minimal extension with optional config templates.""" + ext_dir.mkdir(parents=True, exist_ok=True) + manifest = { + "schema_version": "1.0", + "extension": { + "id": "test-ext", + "name": "Test Extension", + "version": "1.0.0", + "description": "Test extension", + "author": "Test", + "repository": "https://github.com/test/test", + "license": "MIT", + "homepage": "https://github.com/test/test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [{ + "name": "speckit.test-ext.example", + "file": "commands/example.md", + "description": "Example command", + }], + }, + "tags": ["test"], + } + if config_entries: + manifest["provides"]["config"] = config_entries + import yaml + (ext_dir / "extension.yml").write_text(yaml.dump(manifest, default_flow_style=False)) + # Create command file so validation passes + (ext_dir / "commands").mkdir(exist_ok=True) + (ext_dir / "commands" / "example.md").write_text("# Example") + return manifest + + def test_scaffold_config_deploys_template(self, tmp_path): + """Config template should be copied to .specify/ on scaffold.""" + from specify_cli.extensions import ExtensionManager + project = tmp_path / "project" + specify_dir = project / ".specify" + specify_dir.mkdir(parents=True) + ext_dir = specify_dir / "extensions" / "test-ext" + self._make_extension(ext_dir, config_entries=[{ + "name": "test-config.yml", + "template": "config-template.yml", + "description": "Test config", + "required": True, + }]) + (ext_dir / "config-template.yml").write_text("setting: default") + + manager = ExtensionManager(project) + deployed, skipped, failed = manager.scaffold_config("test-ext") + + assert deployed == ["test-config.yml"] + assert skipped == [] + assert failed == [] + assert (specify_dir / "test-config.yml").exists() + assert (specify_dir / "test-config.yml").read_text() == "setting: default" + + def test_scaffold_config_preserves_existing(self, tmp_path): + """Existing config files should never be overwritten.""" + from specify_cli.extensions import ExtensionManager + project = tmp_path / "project" + specify_dir = project / ".specify" + specify_dir.mkdir(parents=True) + (specify_dir / "test-config.yml").write_text("setting: custom") + ext_dir = specify_dir / "extensions" / "test-ext" + self._make_extension(ext_dir, config_entries=[{ + "name": "test-config.yml", + "template": "config-template.yml", + "description": "Test config", + "required": True, + }]) + (ext_dir / "config-template.yml").write_text("setting: default") + + manager = ExtensionManager(project) + deployed, skipped, failed = manager.scaffold_config("test-ext") + + assert deployed == [] + assert skipped == ["test-config.yml"] + assert failed == [] + assert (specify_dir / "test-config.yml").read_text() == "setting: custom" + + def test_scaffold_config_no_config_section(self, tmp_path): + """Extensions without config section should return empty list.""" + from specify_cli.extensions import ExtensionManager + project = tmp_path / "project" + specify_dir = project / ".specify" + specify_dir.mkdir(parents=True) + ext_dir = specify_dir / "extensions" / "test-ext" + self._make_extension(ext_dir) + + manager = ExtensionManager(project) + deployed, skipped, failed = manager.scaffold_config("test-ext") + + assert deployed == [] + assert skipped == [] + assert failed == [] + + def test_scaffold_config_missing_template_file(self, tmp_path): + """Missing template files should be reported as failed.""" + from specify_cli.extensions import ExtensionManager + project = tmp_path / "project" + specify_dir = project / ".specify" + specify_dir.mkdir(parents=True) + ext_dir = specify_dir / "extensions" / "test-ext" + self._make_extension(ext_dir, config_entries=[{ + "name": "test-config.yml", + "template": "nonexistent.yml", + "description": "Test config", + }]) + + manager = ExtensionManager(project) + deployed, skipped, failed = manager.scaffold_config("test-ext") + + assert deployed == [] + assert skipped == [] + assert failed == ["test-config.yml"] + + def test_scaffold_config_rejects_path_traversal(self, tmp_path): + """Config names with path traversal should be rejected.""" + from specify_cli.extensions import ExtensionManager + project = tmp_path / "project" + specify_dir = project / ".specify" + specify_dir.mkdir(parents=True) + ext_dir = specify_dir / "extensions" / "test-ext" + self._make_extension(ext_dir, config_entries=[ + {"name": "../etc/passwd", "template": "config.yml"}, + {"name": "safe.yml", "template": "../../secrets.yml"}, + {"name": "/absolute/path.yml", "template": "config.yml"}, + ]) + (ext_dir / "config.yml").write_text("safe: true") + + manager = ExtensionManager(project) + deployed, skipped, failed = manager.scaffold_config("test-ext") + + assert deployed == [] + assert skipped == [] + assert failed == ["../etc/passwd", "safe.yml", "/absolute/path.yml"] + + def test_scaffold_config_rejects_directory_template(self, tmp_path): + """Directory templates should be rejected (must be regular files).""" + from specify_cli.extensions import ExtensionManager + project = tmp_path / "project" + specify_dir = project / ".specify" + specify_dir.mkdir(parents=True) + ext_dir = specify_dir / "extensions" / "test-ext" + self._make_extension(ext_dir, config_entries=[{ + "name": "test-config.yml", + "template": "config-dir", + }]) + (ext_dir / "config-dir").mkdir() + + manager = ExtensionManager(project) + deployed, skipped, failed = manager.scaffold_config("test-ext") + + assert deployed == [] + assert skipped == [] + assert failed == ["test-config.yml"] + + def test_scaffold_config_rejects_symlink_template(self, tmp_path): + """Symlink templates should not be copied.""" + from specify_cli.extensions import ExtensionManager + project = tmp_path / "project" + specify_dir = project / ".specify" + specify_dir.mkdir(parents=True) + ext_dir = specify_dir / "extensions" / "test-ext" + self._make_extension(ext_dir, config_entries=[{ + "name": "test-config.yml", + "template": "config-link.yml", + }]) + real_template = ext_dir / "config-template.yml" + real_template.write_text("setting: default") + (ext_dir / "config-link.yml").symlink_to(real_template) + + manager = ExtensionManager(project) + deployed, skipped, failed = manager.scaffold_config("test-ext") + + assert deployed == [] + assert skipped == [] + assert failed == ["test-config.yml"] + assert not (specify_dir / "test-config.yml").exists() + + def test_scaffold_config_malformed_manifest(self, tmp_path): + """Malformed config sections should not crash.""" + from specify_cli.extensions import ExtensionManager, ExtensionManifest + import yaml + project = tmp_path / "project" + specify_dir = project / ".specify" + specify_dir.mkdir(parents=True) + ext_dir = specify_dir / "extensions" / "test-ext" + manifest_data = self._make_extension(ext_dir) + manifest_data["provides"]["config"] = "not-a-list" + (ext_dir / "extension.yml").write_text(yaml.dump(manifest_data)) + + manifest = ExtensionManifest(ext_dir / "extension.yml") + assert manifest.config == [] + + manager = ExtensionManager(project) + deployed, skipped, failed = manager.scaffold_config("test-ext") + + assert deployed == [] + assert skipped == [] + assert failed == ["provides.config"] + + def test_scaffold_config_missing_manifest_returns_consistent_result(self, tmp_path): + """A missing extension manifest should return the documented tuple.""" + from specify_cli.extensions import ExtensionManager + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + + manager = ExtensionManager(project) + + assert manager.scaffold_config("missing") == ([], [], [])