Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions src/specify_cli/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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()
Comment on lines +2135 to +2136

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
Comment on lines +2167 to +2172
deployed.append(target_name)

return deployed, skipped_existing, failed

def remove(self, extension_id: str, keep_config: bool = False) -> bool:
"""Remove an installed extension.

Expand Down
38 changes: 38 additions & 0 deletions src/specify_cli/extensions/_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +660 to +661
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))}/")

Expand Down Expand Up @@ -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(
Expand Down
221 changes: 221 additions & 0 deletions tests/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Comment on lines +9123 to +9124

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") == ([], [], [])