Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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.
17 changes: 15 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 = 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}")
elif manifest.config:
console.print("\n[dim]Config files already exist (preserved).[/dim]")
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.

In extension_add, when manifest.config is present but scaffold_config() returns an empty list, the message always says “Config files already exist (preserved).” That’s also the result when templates are missing/unreadable and nothing was deployed, which is misleading. Consider having scaffold_config() distinguish “skipped because target existed” vs “skipped because template missing/invalid” (or return richer status) and adjust the CLI messaging accordingly (e.g., warn when templates declared in the manifest can’t be found).

Suggested change
console.print("\n[dim]Config files already exist (preserved).[/dim]")
# No configs were scaffolded even though the manifest declares them.
# Distinguish between "targets already exist" vs "templates/targets missing".
existing = False
missing = False
configs = manifest.config if isinstance(manifest.config, list) else []
for cfg in configs:
target_path: Optional[Path] = None
# Support both simple string entries and dict-based config entries.
if isinstance(cfg, str):
target_path = specify_dir / cfg
elif isinstance(cfg, dict):
target_value = cfg.get("target") or cfg.get("path")
if isinstance(target_value, str):
target_path = specify_dir / target_value
if target_path is not None:
if target_path.exists():
existing = True
else:
missing = True
if existing:
console.print("\n[dim]Config files already exist (preserved).[/dim]")
else:
console.print(
"\n[yellow]Warning:[/yellow] No config templates were scaffolded. "
"Declared config templates may be missing or invalid; please verify "
"your extension manifest and template files."
)

Copilot uses AI. Check for mistakes.

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

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

# Scaffold config templates on enable
deployed = 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}")


@extension_app.command("disable")
def extension_disable(
Expand Down
47 changes: 47 additions & 0 deletions src/specify_cli/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,11 @@ 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."""
return self.data.get("provides", {}).get("config", [])

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 +1130,48 @@ 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) -> 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:
List of deployed config file names (empty if all already existed)
"""
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 = []

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

template_path = ext_dir / template_name
if not template_path.exists():
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() only checks template_path.exists(). If the manifest points to a directory (or a symlink), shutil.copy2() may fail or copy unexpected content. Consider requiring template_path.is_file() (and potentially disallowing symlinks) before copying.

Suggested change
if not template_path.exists():
if not template_path.exists() or not template_path.is_file():

Copilot uses AI. Check for mistakes.
continue

target_path = self.project_root / ".specify" / target_name
if target_path.exists():
# Never overwrite user-customized config
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() uses template_name and target_name from the manifest to build paths via ext_dir / template_name and project_root/.specify/target_name without any path-traversal containment checks. A malicious or malformed manifest could use absolute paths or .. segments to read templates from outside the extension dir and/or write outside .specify/. Recommend rejecting non-file names (e.g., require Path(name).is_absolute() == False and len(Path(name).parts)==1, or otherwise resolve() + relative_to() checks to ensure template_path stays within ext_dir and target_path stays within project_root/.specify).

Copilot uses AI. Check for mistakes.
continue

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

return deployed

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

Expand Down
114 changes: 114 additions & 0 deletions tests/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -832,6 +832,120 @@ 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 = manager.scaffold_config("test-ext")

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

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

assert deployed == []

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

assert deployed == []


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