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
128 changes: 128 additions & 0 deletions src/specify_cli/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,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 @@ -2470,6 +2478,126 @@ def install_from_zip(
extension_dir, speckit_version, priority=priority, force=force
)

def _config_root_is_contained(self, specify_dir: Path) -> bool:
"""Report whether `.specify` is a real directory inside the project.

Checked component by component so a symlink anywhere on the path is
rejected before it becomes the containment root. A missing `.specify`
is fine: scaffolding creates it under the project root.
"""
try:
root = self.project_root.resolve()
except OSError:
return False
current = self.project_root
for part in specify_dir.relative_to(self.project_root).parts:
current = current / part
if current.is_symlink():
return False
if not current.exists():
return True
try:
if current.resolve().relative_to(root) is None:
return False
except (OSError, ValueError):
return False
return current.is_dir()

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()
# Config is deployed beneath the extension's own directory because that
# is where it is read from: ConfigManager._get_project_config() loads
# `.specify/extensions/<id>/<id>-config.yml`, and the bundled scripts
# and READMEs use the same location. Writing to `.specify/<name>` put
# the file somewhere nothing ever looks.
config_dir = self.project_root / ".specify" / "extensions" / extension_id
# Resolving that directory and trusting the result as the containment
# root lets a symlinked component point outside the project: every
# target would then satisfy relative_to and copy2 would write
# externally. Refuse a symlinked component up front, matching the
# project safe-write path in shared_infra.
if not self._config_root_is_contained(config_dir):
return deployed, skipped_existing, ["provides.config"]
config_dir_resolved = config_dir.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 = (config_dir / target_name).resolve()
Comment on lines +2569 to +2571
try:
template_path.relative_to(ext_dir_resolved)
target_path.relative_to(config_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

try:
# mkdir belongs inside the handler: a nested target like
# foo/config.yml must land in `failed` when `.specify/foo` is a
# file or cannot be created, not raise out of scaffolding after
# `extension add` has already installed the extension.
target_path.parent.mkdir(parents=True, exist_ok=True)
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.

Expand Down
49 changes: 47 additions & 2 deletions src/specify_cli/extensions/_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -711,8 +711,29 @@ 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/{_escape_markup(str(manifest.id))}/")
# Scaffold config templates automatically
deployed, skipped, failed = manager.scaffold_config(manifest.id)
Comment thread
mnriem marked this conversation as resolved.
config_home = f".specify/extensions/{_escape_markup(str(manifest.id))}"
if deployed:
console.print("\n[bold cyan]Config scaffolded:[/bold cyan]")
for cfg in deployed:
console.print(f" • {config_home}/{_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."
)

# Only warn when configuration is actually unresolved. Scaffolding that
# deployed or preserved every template has already answered this, and an
# extension without provides.config has nothing to configure; the blanket
# warning contradicted the output directly above it.
if failed or not (deployed or skipped):
console.print("\n[yellow]⚠[/yellow] Configuration may be required")
console.print(f" Check: {config_home}/")

except ValidationError as e:
console.print(f"\n[red]Validation Error:[/red] {_escape_markup(str(e))}")
Expand Down Expand Up @@ -2218,6 +2239,30 @@ def extension_enable(
# are re-emitted in installed integrations.
_refresh_events_and_warn(project_root)

# 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 = [], [], []
config_home = f".specify/extensions/{_escape_markup(str(extension_id))}"
if deployed:
console.print("\n[bold cyan]Config scaffolded:[/bold cyan]")
for cfg in deployed:
console.print(f" • {config_home}/{_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
Loading