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
Binary file added docs/screenshots/scaffold-config-demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 17 additions & 2 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3666,8 +3666,14 @@ def extension_add(
if reg_skills:
console.print(f"\n[green]✓[/green] {len(reg_skills)} agent skill(s) auto-registered")

console.print("\n[yellow]⚠[/yellow] Configuration may be required")
console.print(f" Check: .specify/extensions/{manifest.id}/")
# Scaffold config templates automatically
deployed, skipped = manager.scaffold_config(manifest.id)
if deployed:
console.print("\n[bold cyan]Config scaffolded:[/bold cyan]")
for cfg in deployed:
console.print(f" • .specify/{cfg}")
if skipped:
console.print(f"\n[dim]Config files already exist (preserved): {', '.join(skipped)}[/dim]")

except ValidationError as e:
console.print(f"\n[red]Validation Error:[/red] {e}")
Expand Down Expand Up @@ -4470,6 +4476,15 @@ def extension_enable(

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

# Scaffold config templates on enable
deployed, skipped = manager.scaffold_config(extension_id)
if deployed:
console.print("\n[bold cyan]Config scaffolded:[/bold cyan]")
for cfg in deployed:
console.print(f" • .specify/{cfg}")
if skipped:
console.print(f"\n[dim]Config files already exist (preserved): {', '.join(skipped)}[/dim]")
Comment on lines +4480 to +4486
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extension_enable calls manager.scaffold_config(extension_id) without any error handling. Since scaffold_config currently instantiates ExtensionManifest (which can raise ValidationError on corrupted/missing manifests), this can cause specify extension enable to crash with a traceback. Consider making scaffold_config non-raising (return empty results on validation errors) and/or catching exceptions here to degrade gracefully with a warning.

Suggested change
deployed, skipped = manager.scaffold_config(extension_id)
if deployed:
console.print("\n[bold cyan]Config scaffolded:[/bold cyan]")
for cfg in deployed:
console.print(f" • .specify/{cfg}")
if skipped:
console.print(f"\n[dim]Config files already exist (preserved): {', '.join(skipped)}[/dim]")
try:
deployed, skipped = manager.scaffold_config(extension_id)
except Exception as exc:
console.print(
f"[yellow]Warning:[/yellow] Failed to scaffold config for extension '{display_name}'."
)
console.print(f"[dim]Details: {exc}[/dim]")
deployed, skipped = [], []
if deployed:
console.print("\n[bold cyan]Config scaffolded:[/bold cyan]")
for cfg in deployed:
console.print(f" • .specify/{cfg}")
if skipped:
console.print(
f"\n[dim]Config files already exist (preserved): {', '.join(skipped)}[/dim]"
)

Copilot uses AI. Check for mistakes.


@extension_app.command("disable")
def extension_disable(
Expand Down
59 changes: 59 additions & 0 deletions src/specify_cli/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,14 @@ def hooks(self) -> Dict[str, Any]:
"""Get hook definitions."""
return self.data.get("hooks", {})

@property
def config(self) -> List[Dict[str, Any]]:
"""Get list of provided config templates."""
raw = self.data.get("provides", {}).get("config", [])
if not isinstance(raw, list):
return []
return [entry for entry in raw if isinstance(entry, dict)]

Comment on lines +238 to +243
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ExtensionManifest.config returns the raw provides.config value without validating its shape. Because the manifest validator currently doesn’t enforce provides.config, a malformed manifest (e.g., config: {} or config: "foo") will make callers like scaffold_config() crash when iterating / calling .get() on entries. Consider normalizing here (return [] unless it’s a list of dicts) or adding validation in _validate() so invalid config sections raise a ValidationError deterministically.

This issue also appears on line 1154 of the same file.

Suggested change
"""Get list of provided config templates."""
return self.data.get("provides", {}).get("config", [])
"""Get list of provided config templates.
Always returns a list of dicts. If the manifest's ``provides.config``
field is missing or has an unexpected shape, this returns an empty
list instead of the raw value.
"""
provides = self.data.get("provides", {})
raw_config = provides.get("config", [])
# Normalize: require a list of dicts; otherwise, return an empty list.
if not isinstance(raw_config, list):
return []
if not all(isinstance(item, dict) for item in raw_config):
return []
return raw_config

Copilot uses AI. Check for mistakes.
def get_hash(self) -> str:
"""Calculate SHA256 hash of manifest file."""
with open(self.path, 'rb') as f:
Expand Down Expand Up @@ -1125,6 +1133,57 @@ def install_from_zip(
# Install from extracted directory
return self.install_from_directory(extension_dir, speckit_version, priority=priority)

def scaffold_config(self, extension_id: str) -> tuple:
"""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) 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 []

Comment on lines +1150 to +1154
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scaffold_config() is documented/used as returning a 2-tuple, but when extension.yml is missing it returns a single list (return []). This will raise a ValueError when callers unpack the result (e.g., in extension_add/extension_enable). Return a consistent 2-tuple like ([], []) (and consider tightening the return type annotation accordingly).

Copilot uses AI. Check for mistakes.
manifest = ExtensionManifest(manifest_path)
deployed = []

skipped_existing = []

for config_entry in manifest.config:
template_name = config_entry.get("template", "")
target_name = config_entry.get("name", template_name)
if not template_name:
continue

# Reject path traversal and absolute paths
if Path(template_name).is_absolute() or ".." in Path(template_name).parts:
continue
if Path(target_name).is_absolute() or ".." in Path(target_name).parts:
continue

template_path = ext_dir / template_name
if not template_path.exists() or not template_path.is_file():
continue

target_path = self.project_root / ".specify" / target_name
if target_path.exists():
skipped_existing.append(target_name)
continue

target_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(template_path, target_path)
deployed.append(target_name)

return deployed, skipped_existing

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

Expand Down
175 changes: 175 additions & 0 deletions tests/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -832,6 +832,181 @@ def test_config_backup_on_remove(self, extension_dir, project_dir):
assert backup_file.read_text() == "test: config"


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 = manager.scaffold_config("test-ext")

assert deployed == ["test-config.yml"]
assert skipped == []
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 = manager.scaffold_config("test-ext")

assert deployed == []
assert skipped == ["test-config.yml"]
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 = manager.scaffold_config("test-ext")

assert deployed == []
assert skipped == []

def test_scaffold_config_missing_template_file(self, tmp_path):
"""Missing template file should be silently skipped."""
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 = manager.scaffold_config("test-ext")

assert deployed == []
assert skipped == []

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 = manager.scaffold_config("test-ext")

assert deployed == []
assert skipped == []

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 = manager.scaffold_config("test-ext")

assert deployed == []

def test_scaffold_config_malformed_manifest(self, tmp_path):
"""Malformed config sections should not crash."""
from specify_cli.extensions import ExtensionManifest
import yaml
project = tmp_path / "project"
specify_dir = project / ".specify"
specify_dir.mkdir(parents=True)
ext_dir = specify_dir / "extensions" / "test-ext"
ext_dir.mkdir(parents=True)
manifest_data = {
"id": "test-ext",
"name": "Test Extension",
"version": "1.0.0",
"provides": {"config": "not-a-list"},
}
(ext_dir / "extension.yml").write_text(yaml.dump(manifest_data))
manifest = ExtensionManifest(ext_dir / "extension.yml")
assert manifest.config == []
Comment on lines +999 to +1007
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test writes an invalid manifest shape (missing required schema_version/extension/requires/provides.commands). ExtensionManifest(...) currently validates those required fields and will raise ValidationError, so the test will fail/crash rather than asserting manifest.config == []. Adjust the test to use a syntactically valid manifest with provides.config set to a non-list, or assert that invalid manifests raise ValidationError.

Copilot uses AI. Check for mistakes.


Comment on lines +1008 to +1009
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new scaffolding behavior is security-sensitive, but the tests don’t currently cover rejection of unsafe template/name values (absolute paths or .. traversal) or malformed provides.config shapes. Adding a couple of tests around scaffold_config() for these cases would help prevent regressions once path containment and type validation are implemented.

Suggested change
def test_scaffold_config_rejects_unsafe_paths(self, tmp_path):
"""Unsafe config names/templates (absolute or traversal) must 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"
# Create an extension that advertises unsafe config paths.
self._make_extension(ext_dir, config_entries=[
{
"name": "/absolute-config.yml",
"template": "config-template.yml",
"description": "Absolute path should be rejected",
},
{
"name": "../traversal-config.yml",
"template": "config-template.yml",
"description": "Path traversal name should be rejected",
},
{
"name": "ok-config.yml",
"template": "../traversal-template.yml",
"description": "Path traversal template should be rejected",
},
])
# Provide a real template so that any failure is due to path validation,
# not missing files.
(ext_dir / "config-template.yml").write_text("setting: default")
(ext_dir / "traversal-template.yml").write_text("setting: unsafe")
manager = ExtensionManager(project)
with pytest.raises(ExtensionError):
manager.scaffold_config("test-ext")
# Ensure that no unsafe files were created outside the .specify directory.
assert not (project.parent / "absolute-config.yml").exists()
assert not (project.parent / "traversal-config.yml").exists()
def test_scaffold_config_rejects_malformed_provides_config(self, tmp_path):
"""Malformed provides.config entries should cause validation failure."""
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"
# Start with a valid extension manifest, then corrupt provides.config.
self._make_extension(ext_dir, config_entries=[{
"name": "test-config.yml",
"template": "config-template.yml",
"description": "Test config",
}])
(ext_dir / "config-template.yml").write_text("setting: default")
# Overwrite the manifest so that provides.config has an invalid shape.
manifest_path = ext_dir / "extension.json"
if manifest_path.exists():
manifest_data = json.loads(manifest_path.read_text())
# Introduce multiple shape issues: config is not a list, and contains
# a non-mapping entry.
manifest_data.setdefault("provides", {})
manifest_data["provides"]["config"] = "not-a-list"
manifest_path.write_text(json.dumps(manifest_data))
manager = ExtensionManager(project)
with pytest.raises(ValidationError):
manager.scaffold_config("test-ext")
# No config file should have been created.
assert not (specify_dir / "test-config.yml").exists()

Copilot uses AI. Check for mistakes.
# ===== CommandRegistrar Tests =====

class TestCommandRegistrar:
Expand Down
Loading