mirror of
https://github.com/github/spec-kit.git
synced 2026-08-15 14:02:04 +02:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0687f4b46 | ||
|
|
f4e3110560 | ||
|
|
2d8904a21e | ||
|
|
7ddb8194d9 | ||
|
|
84a2114338 | ||
|
|
b69147c841 | ||
|
|
14fab0a1af | ||
|
|
e9ffc9d8e7 | ||
|
|
4751777a38 | ||
|
|
15cb7d9a66 |
@@ -2,6 +2,31 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.15.2] - 2026-08-03
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(presets): restore core skills instead of deleting them on preset remove (#3929)
|
||||
- fix(manifests): reject non-string metadata instead of crashing on it (#3943)
|
||||
- fix: narrow bare except Exception in invoke separator resolution (#3856)
|
||||
- fix(workflows): keep the init step's documented ignore_agent_tools default on an explicit null (#3889)
|
||||
- fix(kimi): preserve non-UTF-8 user skills (#3895)
|
||||
- fix(presets): tolerate non-UTF-8 legacy commands (#3896)
|
||||
- feat: allow overriding default init integration via SPECKIT_INTEGRATION_DEFAULT (#3952)
|
||||
- Add adrkit extension to community catalog (#3947)
|
||||
- feat(extensions): scaffold config templates on extension add/enable (#2000)
|
||||
- fix(events): skip non-UTF-8 extension manifests (#3900)
|
||||
- fix(workflows): fail a gate whose on_reject is not abort/skip/retry (#3888)
|
||||
- fix(presets): validate required manifest mappings (#3898)
|
||||
- fix: eliminate TOCTOU race in zip packaging (#3855)
|
||||
- fix(workflows): fail a fan-in step whose output is not a mapping (#3887)
|
||||
- fix(workflows): refetch non-UTF-8 catalog caches (#3901)
|
||||
- fix(bundler): wrap local catalog decode failures (#3902)
|
||||
- Add `--extension` flag to `specify init` for opting into extensions at init time (#3914)
|
||||
- fix: bound response reads in extension catalog and download (#3775)
|
||||
- fix(workflows): reject a retry gate whose verdict enum forbids the reset value (#3912)
|
||||
- chore: release 0.15.1, begin 0.15.2.dev0 development (#3913)
|
||||
|
||||
## [0.15.1] - 2026-07-31
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -25,6 +25,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
|
||||
| Extension | Purpose | Category | Effect | URL |
|
||||
|-----------|---------|----------|--------|-----|
|
||||
| adrkit — decision memory for spec-driven development | Pulls the decisions governing this work into agent context, checks produced plans against them, and drafts an ADR from a plan artifact | `process` | Read+Write | [adrkit](https://github.com/mbeacom/adrkit) |
|
||||
| Agent Assign | Assign specialized Claude Code agents to spec-kit tasks for targeted execution | `process` | Read+Write | [spec-kit-agent-assign](https://github.com/xymelon/spec-kit-agent-assign) |
|
||||
| Agent Governance | Generate agent-platform repository governance files from Spec Kit metadata | `process` | Read+Write | [spec-kit-agent-governance](https://github.com/bigsmartben/spec-kit-agent-governance) |
|
||||
| AI-Driven Engineering (AIDE) | A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation | `process` | Read+Write | [aide](https://github.com/mnriem/spec-kit-extensions/tree/main/aide) |
|
||||
|
||||
@@ -25,7 +25,7 @@ Creates a new Spec Kit project with the necessary directory structure, templates
|
||||
|
||||
Use `<project_name>` to create a new directory, or `--here` (or `.`) to initialize in the current directory. If the directory already has files, use `--force` to merge without confirmation.
|
||||
|
||||
When `--integration` is omitted, interactive terminals prompt you to choose an integration. Non-interactive sessions, such as CI or piped runs, default to GitHub Copilot; pass `--integration <key>` to choose a different integration explicitly.
|
||||
When `--integration` is omitted, interactive terminals prompt you to choose an integration. Non-interactive sessions, such as CI or piped runs, default to GitHub Copilot; pass `--integration <key>` to choose a different integration explicitly, or set `SPECKIT_INTEGRATION_DEFAULT` to change the fallback (see [Environment Variables](#environment-variables)).
|
||||
|
||||
### Examples
|
||||
|
||||
@@ -50,6 +50,7 @@ specify init my-project --integration copilot --preset compliance
|
||||
|
||||
| Variable | Description |
|
||||
| ----------------- | ------------------------------------------------------------------------ |
|
||||
| `SPECKIT_INTEGRATION_DEFAULT` | Override the fallback integration used by `specify init` when `--integration` is omitted (interactive prompt default and non-interactive fallback). Set it to any registered integration key (e.g. `gemini`, `claude`). An unrecognized value is ignored with a warning and the built-in default (`copilot`) is used. An explicit `--integration <key>` always takes precedence. |
|
||||
| `SPECIFY_INIT_DIR` | Target a member project from outside its directory (e.g. a monorepo root) without `cd`, for non-interactive / CI use. Set it to the **project root** — the directory *containing* `.specify/` (relative paths resolve against the current directory). The path must exist and contain `.specify/`, otherwise the command errors and does **not** fall back to the current directory. Resolved once in the core root helper (`get_repo_root` in Bash, `Get-RepoRoot` in PowerShell), so it is honored by the core feature scripts (`/speckit.plan`, `/speckit.tasks`, …) and the Git extension's feature-branch creation, which inherit it. The `specify` CLI applies the **same** validation rules to every project-scoped subcommand (`specify integration …`, `specify extension …`, `specify workflow …`, `specify preset …`, and the rest that operate on a `.specify/` project), so those can target a member project too. When unset, Bash/PowerShell helpers keep their existing upward search; the `specify` CLI keeps its project-scoped resolver cwd-only unless a command explicitly defines broader detection (for example, bundle commands). |
|
||||
| `SPECIFY_FEATURE_DIRECTORY` | Override the active feature directory *within* the resolved project (takes precedence over `.specify/feature.json`). Relative paths resolve under the project root. Combine with `SPECIFY_INIT_DIR` to pick both the project and the feature non-interactively. |
|
||||
| `SPECIFY_FEATURE` | Override feature detection for non-Git repositories. Set to the feature directory name (e.g., `001-photo-albums`) to work on a specific feature when not using Git branches. Must be set in the context of the agent prior to using `/speckit.plan` or follow-up commands. |
|
||||
|
||||
@@ -1,8 +1,43 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-29T00:00:00Z",
|
||||
"updated_at": "2026-08-03T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"adrkit": {
|
||||
"name": "adrkit — decision memory for spec-driven development",
|
||||
"id": "adrkit",
|
||||
"description": "Pulls the decisions governing this work into agent context, checks produced plans against them, and drafts an ADR from a plan artifact.",
|
||||
"author": "Mark Beacom (@mbeacom)",
|
||||
"version": "0.1.2",
|
||||
"download_url": "https://github.com/mbeacom/adrkit/releases/download/spec-kit-v0.1.2/adrkit.zip",
|
||||
"repository": "https://github.com/mbeacom/adrkit",
|
||||
"homepage": "https://adrkit.dev",
|
||||
"documentation": "https://github.com/mbeacom/adrkit/blob/main/packages/adapters/spec-kit/README.md",
|
||||
"changelog": "https://github.com/mbeacom/adrkit/blob/main/CHANGELOG.md",
|
||||
"license": "Apache-2.0",
|
||||
"category": "process",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.13.0,<0.16.0",
|
||||
"tools": [{ "name": "adr", "version": ">=0.3.0", "required": true }]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"adr",
|
||||
"governance",
|
||||
"decision-records",
|
||||
"architecture",
|
||||
"compliance"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-08-03T00:00:00Z",
|
||||
"updated_at": "2026-08-03T00:00:00Z"
|
||||
},
|
||||
"aide": {
|
||||
"name": "AI-Driven Engineering (AIDE)",
|
||||
"id": "aide",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.15.2.dev0"
|
||||
version = "0.15.2"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -77,7 +77,9 @@ from ._version import (
|
||||
from ._agent_config import (
|
||||
AGENT_CONFIG as AGENT_CONFIG,
|
||||
DEFAULT_INIT_INTEGRATION as DEFAULT_INIT_INTEGRATION,
|
||||
DEFAULT_INIT_INTEGRATION_ENV_VAR as DEFAULT_INIT_INTEGRATION_ENV_VAR,
|
||||
SCRIPT_TYPE_CHOICES as SCRIPT_TYPE_CHOICES,
|
||||
resolve_default_init_integration as resolve_default_init_integration,
|
||||
)
|
||||
from ._init_options import (
|
||||
INIT_OPTIONS_FILE as INIT_OPTIONS_FILE,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Agent configuration constants derived from the integration registry."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
@@ -17,6 +19,36 @@ AGENT_CONFIG: dict[str, dict[str, Any]] = _build_agent_config()
|
||||
|
||||
DEFAULT_INIT_INTEGRATION = "copilot"
|
||||
|
||||
#: Environment variable used to override the fallback integration that
|
||||
#: ``specify init`` selects in non-interactive sessions. Follows the existing
|
||||
#: ``SPECKIT_INTEGRATION_*`` namespace (see ``SPECKIT_INTEGRATION_<KEY>_EXECUTABLE``
|
||||
#: and ``SPECKIT_INTEGRATION_CATALOG_URL``).
|
||||
DEFAULT_INIT_INTEGRATION_ENV_VAR = "SPECKIT_INTEGRATION_DEFAULT"
|
||||
|
||||
|
||||
def resolve_default_init_integration() -> str:
|
||||
"""Return the default init integration, honoring an env-var override.
|
||||
|
||||
Reads :data:`DEFAULT_INIT_INTEGRATION_ENV_VAR`
|
||||
(``SPECKIT_INTEGRATION_DEFAULT``). When it names a registered integration
|
||||
key, that key is returned; otherwise the hardcoded
|
||||
:data:`DEFAULT_INIT_INTEGRATION` (``"copilot"``) is used. An invalid value
|
||||
emits a warning to stderr rather than silently falling back, so operators
|
||||
can tell a typo from an intentional default.
|
||||
"""
|
||||
override = (os.environ.get(DEFAULT_INIT_INTEGRATION_ENV_VAR) or "").strip()
|
||||
if not override:
|
||||
return DEFAULT_INIT_INTEGRATION
|
||||
if override in AGENT_CONFIG:
|
||||
return override
|
||||
print(
|
||||
f"Warning: {DEFAULT_INIT_INTEGRATION_ENV_VAR}='{override}' is not a "
|
||||
f"recognized integration; falling back to '{DEFAULT_INIT_INTEGRATION}'. "
|
||||
f"Choose from: {', '.join(sorted(AGENT_CONFIG.keys()))}.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return DEFAULT_INIT_INTEGRATION
|
||||
|
||||
SCRIPT_TYPE_CHOICES: dict[str, str] = {
|
||||
"sh": "POSIX Shell (bash/zsh)",
|
||||
"ps": "PowerShell",
|
||||
|
||||
@@ -679,7 +679,7 @@ class CommandRegistrar:
|
||||
_integ = get_integration(agent_name)
|
||||
if _integ is not None:
|
||||
_sep = _integ.invoke_separator_for_mode(registrar_writes_skills)
|
||||
except Exception:
|
||||
except (ImportError, ValueError, KeyError):
|
||||
pass
|
||||
_prefix = get_invocation_prefix(agent_name, registrar_writes_skills)
|
||||
|
||||
|
||||
@@ -131,13 +131,13 @@ def _run_init(integration: str, *, script_type: str, offline: bool = False) -> N
|
||||
|
||||
def _resolve_init_integration(override: str | None, manifest) -> str:
|
||||
"""Precedence (FR-013): explicit override → bundle-declared → default."""
|
||||
from ..._agent_config import DEFAULT_INIT_INTEGRATION
|
||||
from ..._agent_config import resolve_default_init_integration
|
||||
|
||||
if override:
|
||||
return override
|
||||
if manifest is not None and manifest.integration is not None:
|
||||
return manifest.integration.id
|
||||
return DEFAULT_INIT_INTEGRATION
|
||||
return resolve_default_init_integration()
|
||||
|
||||
|
||||
# ===== Consume =====
|
||||
|
||||
@@ -14,8 +14,8 @@ from rich.panel import Panel
|
||||
|
||||
from .._agent_config import (
|
||||
AGENT_CONFIG,
|
||||
DEFAULT_INIT_INTEGRATION,
|
||||
SCRIPT_TYPE_CHOICES,
|
||||
resolve_default_init_integration,
|
||||
)
|
||||
from .._assets import (
|
||||
_locate_bundled_preset,
|
||||
@@ -466,17 +466,18 @@ def register(app: typer.Typer) -> None:
|
||||
raise typer.Exit(1)
|
||||
selected_ai = integration
|
||||
elif not _stdin_is_interactive():
|
||||
default_integration = resolve_default_init_integration()
|
||||
console.print(
|
||||
f"[dim]Non-interactive session detected: defaulting to '{DEFAULT_INIT_INTEGRATION}'. "
|
||||
f"[dim]Non-interactive session detected: defaulting to '{default_integration}'. "
|
||||
"Use --integration to choose a different agent.[/dim]"
|
||||
)
|
||||
selected_ai = DEFAULT_INIT_INTEGRATION
|
||||
selected_ai = default_integration
|
||||
else:
|
||||
ai_choices = {key: config["name"] for key, config in AGENT_CONFIG.items()}
|
||||
selected_ai = select_with_arrows(
|
||||
ai_choices,
|
||||
"Choose your coding agent integration:",
|
||||
DEFAULT_INIT_INTEGRATION,
|
||||
resolve_default_init_integration(),
|
||||
)
|
||||
|
||||
if not integration:
|
||||
|
||||
@@ -285,9 +285,25 @@ class ExtensionManifest:
|
||||
raise ValidationError(
|
||||
f"Invalid extension: expected a mapping, got {type(ext).__name__}"
|
||||
)
|
||||
# Check presence AND type: the format/version checks below feed these
|
||||
# values straight to ``re.match`` and ``packaging.Version``, both of
|
||||
# which raise a bare TypeError on a non-string. YAML makes that an easy
|
||||
# authoring slip -- unquoted ``version: 1.0`` parses as a float and
|
||||
# ``id: 2`` as an int -- and TypeError is not a ValidationError, so it
|
||||
# escapes every caller that already handles a malformed manifest (see
|
||||
# list_installed()'s "Corrupted extension" fallback, which catches
|
||||
# ValidationError only, making one bad extension exit ``specify
|
||||
# extension list`` with a raw traceback and hide the healthy ones).
|
||||
# Mirrors the sibling IntegrationDescriptor, which already type-checks
|
||||
# the same four fields.
|
||||
for field in ["id", "name", "version", "description"]:
|
||||
if field not in ext:
|
||||
raise ValidationError(f"Missing extension.{field}")
|
||||
if not isinstance(ext[field], str):
|
||||
raise ValidationError(
|
||||
f"Invalid extension.{field}: expected a string, "
|
||||
f"got {type(ext[field]).__name__}"
|
||||
)
|
||||
|
||||
# Validate extension ID format
|
||||
if not re.match(r"^[a-z0-9-]+$", ext["id"]):
|
||||
@@ -391,6 +407,16 @@ class ExtensionManifest:
|
||||
)
|
||||
if "name" not in cmd or "file" not in cmd:
|
||||
raise ValidationError("Command missing 'name' or 'file'")
|
||||
# The pattern match below would raise a bare TypeError on a
|
||||
# non-string name (``name: 2``), escaping the ValidationError
|
||||
# contract. The 'file' field needs no check here:
|
||||
# relative_extension_path_violation() below already rejects a
|
||||
# non-string value.
|
||||
if not isinstance(cmd["name"], str):
|
||||
raise ValidationError(
|
||||
f"Invalid command name: expected a string, "
|
||||
f"got {type(cmd['name']).__name__}"
|
||||
)
|
||||
|
||||
# Validate the 'file' field at manifest-load time using the single
|
||||
# shared policy in relative_extension_path_violation(), so manifest
|
||||
@@ -562,6 +588,14 @@ class ExtensionManifest:
|
||||
"""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."""
|
||||
@@ -2489,6 +2523,149 @@ class ExtensionManager:
|
||||
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()
|
||||
|
||||
@staticmethod
|
||||
def _target_follows_preserved_convention(target_name: str) -> bool:
|
||||
"""True when a scaffold target survives remove/backup/restore.
|
||||
|
||||
Those paths only handle top-level ``*-config.yml`` and
|
||||
``*-config.local.yml`` files, so anything nested or otherwise named is
|
||||
not preserved across an update.
|
||||
"""
|
||||
if "/" in target_name or "\\" in target_name:
|
||||
return False
|
||||
return target_name.endswith("-config.yml") or target_name.endswith(
|
||||
"-config.local.yml"
|
||||
)
|
||||
|
||||
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
|
||||
# Only scaffold what removal actually preserves. remove(keep_config)
|
||||
# keeps top-level files ending in -config.yml / -config.local.yml and
|
||||
# rmtree's every subdirectory; the backup path globs the same
|
||||
# top-level pattern. A nested or differently-named target would be
|
||||
# silently destroyed by `extension add --force` and replaced with the
|
||||
# template default, losing the user's customization.
|
||||
if not self._target_follows_preserved_convention(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()
|
||||
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 install_from_zip(
|
||||
self,
|
||||
zip_path: Path,
|
||||
|
||||
@@ -1071,8 +1071,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)
|
||||
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))}")
|
||||
@@ -2552,6 +2573,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(
|
||||
|
||||
@@ -317,7 +317,7 @@ def _is_speckit_generated_skill(skill_dir: Path) -> bool:
|
||||
|
||||
try:
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
except (OSError, UnicodeError):
|
||||
return False
|
||||
|
||||
if not content.startswith("---"):
|
||||
|
||||
@@ -307,9 +307,25 @@ class PresetManifest:
|
||||
|
||||
# Validate preset metadata
|
||||
pack = self.data["preset"]
|
||||
# Check presence AND type: the format/version checks below feed these
|
||||
# values straight to ``re.match`` and ``packaging.Version``, both of
|
||||
# which raise a bare TypeError on a non-string. YAML makes that an easy
|
||||
# authoring slip -- unquoted ``version: 1.0`` parses as a float and
|
||||
# ``id: 2`` as an int -- and TypeError is not a PresetValidationError,
|
||||
# so it escapes every caller that already handles a malformed manifest
|
||||
# (see list_installed()'s "Corrupted preset" fallback, which catches
|
||||
# PresetValidationError only, making one bad preset exit ``specify
|
||||
# preset list`` with a raw traceback and hide the healthy ones).
|
||||
# Mirrors the sibling IntegrationDescriptor, which already type-checks
|
||||
# the same four fields.
|
||||
for field in ["id", "name", "version", "description"]:
|
||||
if field not in pack:
|
||||
raise PresetValidationError(f"Missing preset.{field}")
|
||||
if not isinstance(pack[field], str):
|
||||
raise PresetValidationError(
|
||||
f"Invalid preset.{field}: expected a string, "
|
||||
f"got {type(pack[field]).__name__}"
|
||||
)
|
||||
|
||||
# Validate pack ID format
|
||||
if not re.match(r'^[a-z0-9-]+$', pack["id"]):
|
||||
@@ -367,6 +383,19 @@ class PresetManifest:
|
||||
"Template missing 'type', 'name', or 'file'"
|
||||
)
|
||||
|
||||
# 'name' feeds re.match and 'file' feeds os.path.normpath below;
|
||||
# both raise a bare TypeError on a non-string, which is not a
|
||||
# PresetValidationError and so escapes the callers that handle a
|
||||
# malformed manifest. The sibling extension manifest already
|
||||
# rejects a non-string command 'file' via
|
||||
# relative_extension_path_violation().
|
||||
for field in ("type", "name", "file"):
|
||||
if not isinstance(tmpl[field], str):
|
||||
raise PresetValidationError(
|
||||
f"Invalid template {field}: expected a string, "
|
||||
f"got {type(tmpl[field]).__name__}"
|
||||
)
|
||||
|
||||
if tmpl["type"] not in VALID_PRESET_TEMPLATE_TYPES:
|
||||
raise PresetValidationError(
|
||||
f"Invalid template type '{tmpl['type']}': "
|
||||
@@ -2157,7 +2186,10 @@ class PresetManager:
|
||||
]
|
||||
if dir_core_ext_names:
|
||||
self._unregister_skills_in_dir(
|
||||
dir_core_ext_names, skills_dir, dir_agent
|
||||
dir_core_ext_names,
|
||||
skills_dir,
|
||||
dir_agent,
|
||||
restore_from_bundled_core=True,
|
||||
)
|
||||
|
||||
for _skill_name, cmd_name, top_layer in override_skills:
|
||||
@@ -2992,6 +3024,7 @@ class PresetManager:
|
||||
preset_dir: Union[Path, str],
|
||||
*,
|
||||
additional_owned_sources: Optional[Dict[str, str]] = None,
|
||||
restore_from_bundled_core: bool = False,
|
||||
) -> Dict[Path, tuple[Optional[str], List[str]]]:
|
||||
"""Restore original SKILL.md files after a preset is removed.
|
||||
|
||||
@@ -2999,6 +3032,17 @@ class PresetManager:
|
||||
regenerate the skill from the core command template. If no core
|
||||
template exists, the skill directory is removed.
|
||||
|
||||
Args:
|
||||
restore_from_bundled_core: When True, a missing project-local
|
||||
core template (the common case — ``specify init`` never
|
||||
populates ``.specify/templates/commands``) falls back to
|
||||
the bundled core_pack/repo-root templates so the skill is
|
||||
restored instead of deleted (#3928). Callers that are
|
||||
retiring a skill because its command now renders elsewhere
|
||||
(a command file superseding it) must leave this False so
|
||||
the skill is removed rather than resurrected with core
|
||||
content that would duplicate the winning command.
|
||||
|
||||
``registered_skills`` records exactly which agent directories this
|
||||
preset actually wrote to (see :meth:`_register_skills`), so removal
|
||||
restores precisely those directories rather than guessing at every
|
||||
@@ -3072,6 +3116,7 @@ class PresetManager:
|
||||
renderer_agent,
|
||||
pack_id=pack_id,
|
||||
additional_owned_sources=additional_owned_sources,
|
||||
restore_from_bundled_core=restore_from_bundled_core,
|
||||
)
|
||||
if mutated_names:
|
||||
restored[skills_dir] = (
|
||||
@@ -3104,6 +3149,7 @@ class PresetManager:
|
||||
selected_ai,
|
||||
pack_id=pack_id,
|
||||
additional_owned_sources=additional_owned_sources,
|
||||
restore_from_bundled_core=restore_from_bundled_core,
|
||||
)
|
||||
return (
|
||||
{skills_dir: (selected_ai, mutated_names)}
|
||||
@@ -3177,6 +3223,7 @@ class PresetManager:
|
||||
*,
|
||||
pack_id: Optional[str] = None,
|
||||
additional_owned_sources: Optional[Dict[str, str]] = None,
|
||||
restore_from_bundled_core: bool = False,
|
||||
) -> List[str]:
|
||||
"""Restore original SKILL.md files within a single skills directory.
|
||||
|
||||
@@ -3187,6 +3234,7 @@ class PresetManager:
|
||||
placeholder resolution and argument-hint formatting.
|
||||
additional_owned_sources: Generated non-preset source markers
|
||||
accepted as owned for specific skill names.
|
||||
restore_from_bundled_core: See ``_unregister_skills``.
|
||||
|
||||
Returns:
|
||||
Skill names whose files were restored or removed.
|
||||
@@ -3262,9 +3310,34 @@ class PresetManager:
|
||||
if current_source not in owned_sources:
|
||||
continue
|
||||
|
||||
# Try to find the core command template
|
||||
core_file = core_templates_dir / f"{short_name}.md" if core_templates_dir.exists() else None
|
||||
if core_file and not core_file.exists():
|
||||
extension_restore = extension_restore_index.get(skill_name)
|
||||
|
||||
# Try to find the core command template. Project-local overrides
|
||||
# in core_templates_dir take precedence, but that directory is
|
||||
# rarely populated — the real core commands ship in the bundled
|
||||
# core_pack (wheel install) or the repo-root templates/ tree
|
||||
# (source checkout). Callers that want a genuine restore (a
|
||||
# preset was removed outright, not superseded by another
|
||||
# renderer) opt into that fallback via restore_from_bundled_core
|
||||
# so the skill is restored instead of deleted (#3928). An
|
||||
# installed extension providing a core-named command resolves
|
||||
# ahead of bundled core elsewhere, so skip the bundled fallback
|
||||
# when an extension restore exists — otherwise it would win
|
||||
# over the higher-priority extension layer below.
|
||||
core_file = core_templates_dir / f"{short_name}.md"
|
||||
if (
|
||||
not core_file.exists()
|
||||
and restore_from_bundled_core
|
||||
and extension_restore is None
|
||||
):
|
||||
from .. import _locate_core_pack, _repo_root
|
||||
|
||||
_core_pack = _locate_core_pack()
|
||||
if _core_pack is not None:
|
||||
core_file = _core_pack / "commands" / f"{short_name}.md"
|
||||
else:
|
||||
core_file = _repo_root() / "templates" / "commands" / f"{short_name}.md"
|
||||
if not core_file.exists():
|
||||
core_file = None
|
||||
|
||||
if core_file:
|
||||
@@ -3309,7 +3382,6 @@ class PresetManager:
|
||||
mutated_names.append(skill_name)
|
||||
continue
|
||||
|
||||
extension_restore = extension_restore_index.get(skill_name)
|
||||
if extension_restore:
|
||||
content = extension_restore["source_file"].read_text(encoding="utf-8")
|
||||
frontmatter, body = registrar.parse_frontmatter(content)
|
||||
@@ -3448,7 +3520,9 @@ class PresetManager:
|
||||
"registered_skills", registered_skills
|
||||
)
|
||||
if persisted_skills:
|
||||
self._unregister_skills(persisted_skills, dest_dir)
|
||||
self._unregister_skills(
|
||||
persisted_skills, dest_dir, restore_from_bundled_core=True
|
||||
)
|
||||
try:
|
||||
if dest_dir.exists():
|
||||
shutil.rmtree(dest_dir)
|
||||
@@ -3786,6 +3860,7 @@ class PresetManager:
|
||||
restorable_skills,
|
||||
pack_dir,
|
||||
additional_owned_sources=override_sources,
|
||||
restore_from_bundled_core=True,
|
||||
)
|
||||
try:
|
||||
from ..agents import CommandRegistrar
|
||||
@@ -5297,7 +5372,7 @@ class PresetResolver:
|
||||
fm_strategy = fm_data.get("strategy")
|
||||
if isinstance(fm_strategy, str) and fm_strategy.lower() in VALID_PRESET_STRATEGIES:
|
||||
strategy = fm_strategy.lower()
|
||||
except (yaml.YAMLError, OSError):
|
||||
except (UnicodeDecodeError, yaml.YAMLError, OSError):
|
||||
# Best-effort legacy frontmatter parsing: keep default
|
||||
# strategy ("replace") when content is unreadable/invalid.
|
||||
pass
|
||||
|
||||
@@ -11,7 +11,10 @@ from __future__ import annotations
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from specify_cli._agent_config import DEFAULT_INIT_INTEGRATION, SCRIPT_TYPE_CHOICES
|
||||
from specify_cli._agent_config import (
|
||||
SCRIPT_TYPE_CHOICES,
|
||||
resolve_default_init_integration,
|
||||
)
|
||||
from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
|
||||
from specify_cli.workflows.expressions import evaluate_expression
|
||||
|
||||
@@ -54,7 +57,8 @@ class InitStep(StepBase):
|
||||
Initialize in the target directory instead of creating a new one.
|
||||
``integration``
|
||||
Integration key (e.g. ``copilot``). Defaults to the workflow's
|
||||
default integration, then to ``DEFAULT_INIT_INTEGRATION``.
|
||||
default integration, then to the resolved default init integration
|
||||
(``SPECKIT_INTEGRATION_DEFAULT`` env var, else ``copilot``).
|
||||
``integration_options``
|
||||
Extra options for the integration (e.g. ``"--skills"`` or
|
||||
``"--commands-dir .myagent/cmds"``).
|
||||
@@ -81,7 +85,7 @@ class InitStep(StepBase):
|
||||
# Apply the same default that specify init uses in non-interactive mode
|
||||
# so that output.integration reflects the actual integration used.
|
||||
if not integration:
|
||||
integration = DEFAULT_INIT_INTEGRATION
|
||||
integration = resolve_default_init_integration()
|
||||
|
||||
integration_options = self._resolve(
|
||||
config.get("integration_options"), context
|
||||
@@ -91,9 +95,17 @@ class InitStep(StepBase):
|
||||
|
||||
force = self._resolve_bool(config.get("force"), context)
|
||||
# Workflows run unattended; skip the agent CLI presence check by default.
|
||||
ignore_agent_tools = self._resolve_bool(
|
||||
config.get("ignore_agent_tools", True), context
|
||||
)
|
||||
# ``config.get(key, True)`` applies that default only when the key is
|
||||
# ABSENT: a bare ``ignore_agent_tools:`` in YAML parses to None, which
|
||||
# ``_resolve_bool`` then turns into False -- flipping the documented
|
||||
# default and re-enabling the agent-CLI presence check this step promises
|
||||
# to skip, so an unattended run fails with "Agent Detection Error" for
|
||||
# any integration whose CLI is not installed. Normalize an explicit null
|
||||
# to the default, mirroring the while/do-while ``max_iterations`` handling.
|
||||
raw_ignore_agent_tools = config.get("ignore_agent_tools")
|
||||
if raw_ignore_agent_tools is None:
|
||||
raw_ignore_agent_tools = True
|
||||
ignore_agent_tools = self._resolve_bool(raw_ignore_agent_tools, context)
|
||||
|
||||
argv: list[str] = ["init"]
|
||||
if here:
|
||||
|
||||
@@ -44,6 +44,20 @@ def test_precedence_default_when_unspecified():
|
||||
assert _resolve_init_integration(None, None) == "copilot"
|
||||
|
||||
|
||||
def test_precedence_default_honors_env_var(monkeypatch):
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_DEFAULT", "gemini")
|
||||
# With no override and no bundle-declared integration, the env-var default
|
||||
# applies instead of the hardcoded "copilot".
|
||||
assert _resolve_init_integration(None, None) == "gemini"
|
||||
assert _resolve_init_integration(None, _manifest()) == "gemini"
|
||||
# Explicit override and bundle-declared integration still take precedence.
|
||||
assert _resolve_init_integration("claude", None) == "claude"
|
||||
assert (
|
||||
_resolve_init_integration(None, _manifest(integration={"id": "claude"}))
|
||||
== "claude"
|
||||
)
|
||||
|
||||
|
||||
def _build_mini(tmp_path: Path) -> Path:
|
||||
bundle = tmp_path / "mini"
|
||||
bundle.mkdir()
|
||||
|
||||
@@ -116,6 +116,68 @@ class TestInitIntegrationFlag:
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
assert data["integration"] == specify_cli.DEFAULT_INIT_INTEGRATION
|
||||
|
||||
def test_noninteractive_init_honors_default_integration_env_var(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
import specify_cli
|
||||
|
||||
def fail_select(*_args, **_kwargs):
|
||||
raise AssertionError("non-interactive init should not open the integration picker")
|
||||
|
||||
monkeypatch.setattr(specify_cli, "select_with_arrows", fail_select)
|
||||
monkeypatch.setenv(
|
||||
specify_cli.DEFAULT_INIT_INTEGRATION_ENV_VAR, "gemini"
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
project = tmp_path / "noninteractive_env"
|
||||
result = runner.invoke(app, [
|
||||
"init", str(project), "--script", "sh", "--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "defaulting to 'gemini'" in result.output
|
||||
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
assert data["integration"] == "gemini"
|
||||
|
||||
def test_interactive_init_picker_default_honors_env_var(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
# The interactive integration picker must receive the resolved
|
||||
# SPECKIT_INTEGRATION_DEFAULT value as its default_key, not the
|
||||
# hardcoded constant (guards the picker wiring against regression).
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
import specify_cli.commands.init as init_mod
|
||||
|
||||
monkeypatch.setattr(init_mod, "_stdin_is_interactive", lambda: True)
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_DEFAULT", "gemini")
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_select(options, prompt_text=None, default_key=None):
|
||||
# Only capture the integration picker (not the script picker).
|
||||
if "Choose your coding agent integration" in (prompt_text or ""):
|
||||
captured["default_key"] = default_key
|
||||
return default_key
|
||||
|
||||
monkeypatch.setattr(init_mod, "select_with_arrows", fake_select)
|
||||
|
||||
runner = CliRunner()
|
||||
project = tmp_path / "interactive_env"
|
||||
result = runner.invoke(app, [
|
||||
"init", str(project), "--script", "sh", "--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured.get("default_key") == "gemini"
|
||||
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
assert data["integration"] == "gemini"
|
||||
|
||||
def test_init_here_nonempty_noninteractive_errors_with_force_guidance(self, tmp_path):
|
||||
"""`init --here` on a non-empty directory with no confirmation input (empty
|
||||
stdin) must fail fast with guidance to use --force, instead of the bare
|
||||
|
||||
@@ -199,6 +199,24 @@ class TestKimiTeardownLegacyCleanup:
|
||||
|
||||
assert user_skill.exists()
|
||||
|
||||
def test_teardown_preserves_non_utf8_user_skill(self, tmp_path):
|
||||
i = get_integration("kimi")
|
||||
|
||||
user_skill = (
|
||||
tmp_path
|
||||
/ ".kimi"
|
||||
/ "skills"
|
||||
/ "speckit-user-owned"
|
||||
/ "SKILL.md"
|
||||
)
|
||||
user_skill.parent.mkdir(parents=True)
|
||||
user_skill.write_bytes(b"\xff\xfe")
|
||||
|
||||
m = IntegrationManifest("kimi", tmp_path)
|
||||
i.teardown(tmp_path, m)
|
||||
|
||||
assert user_skill.read_bytes() == b"\xff\xfe"
|
||||
|
||||
|
||||
class TestKimiCommandInvocation:
|
||||
"""Kimi dispatch must use the native ``/skill:`` slash command."""
|
||||
|
||||
@@ -50,3 +50,51 @@ def test_init_command_registered():
|
||||
cmd.callback.__name__ for cmd in app.registered_commands if cmd.callback
|
||||
]
|
||||
assert "init" in callback_names
|
||||
|
||||
|
||||
def test_resolve_default_init_integration_unset(monkeypatch):
|
||||
from specify_cli._agent_config import (
|
||||
DEFAULT_INIT_INTEGRATION,
|
||||
DEFAULT_INIT_INTEGRATION_ENV_VAR,
|
||||
resolve_default_init_integration,
|
||||
)
|
||||
monkeypatch.delenv(DEFAULT_INIT_INTEGRATION_ENV_VAR, raising=False)
|
||||
assert resolve_default_init_integration() == DEFAULT_INIT_INTEGRATION
|
||||
|
||||
|
||||
def test_resolve_default_init_integration_valid_override(monkeypatch):
|
||||
from specify_cli._agent_config import (
|
||||
DEFAULT_INIT_INTEGRATION_ENV_VAR,
|
||||
resolve_default_init_integration,
|
||||
)
|
||||
monkeypatch.setenv(DEFAULT_INIT_INTEGRATION_ENV_VAR, "gemini")
|
||||
assert resolve_default_init_integration() == "gemini"
|
||||
|
||||
|
||||
def test_resolve_default_init_integration_whitespace_trimmed(monkeypatch):
|
||||
from specify_cli._agent_config import (
|
||||
DEFAULT_INIT_INTEGRATION_ENV_VAR,
|
||||
resolve_default_init_integration,
|
||||
)
|
||||
monkeypatch.setenv(DEFAULT_INIT_INTEGRATION_ENV_VAR, " gemini ")
|
||||
assert resolve_default_init_integration() == "gemini"
|
||||
|
||||
|
||||
def test_resolve_default_init_integration_invalid_warns_and_falls_back(
|
||||
monkeypatch, capsys
|
||||
):
|
||||
from specify_cli._agent_config import (
|
||||
DEFAULT_INIT_INTEGRATION,
|
||||
DEFAULT_INIT_INTEGRATION_ENV_VAR,
|
||||
resolve_default_init_integration,
|
||||
)
|
||||
monkeypatch.setenv(DEFAULT_INIT_INTEGRATION_ENV_VAR, "not-a-real-agent")
|
||||
assert resolve_default_init_integration() == DEFAULT_INIT_INTEGRATION
|
||||
captured = capsys.readouterr()
|
||||
assert "not-a-real-agent" in captured.err
|
||||
assert DEFAULT_INIT_INTEGRATION_ENV_VAR in captured.err
|
||||
|
||||
|
||||
def test_resolve_default_init_integration_re_exported_from_init():
|
||||
from specify_cli import resolve_default_init_integration
|
||||
assert callable(resolve_default_init_integration)
|
||||
|
||||
@@ -671,6 +671,102 @@ class TestExtensionManifest:
|
||||
with pytest.raises(ValidationError, match=f"Invalid {section}"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
@pytest.mark.parametrize("field", ["id", "name", "version", "description"])
|
||||
@pytest.mark.parametrize("bad", [1.0, 5, None, ["a"], {"a": 1}, True])
|
||||
def test_extension_metadata_field_not_string_rejected(
|
||||
self, temp_dir, valid_manifest_data, field, bad
|
||||
):
|
||||
"""A non-string extension.<field> must raise ValidationError, not a raw
|
||||
TypeError.
|
||||
|
||||
The loop over these four fields only checked key PRESENCE, then fed the
|
||||
values to ``re.match`` (id) and ``packaging.Version`` (version), both of
|
||||
which raise a bare TypeError on a non-string. YAML makes that an easy
|
||||
authoring slip: unquoted ``version: 1.0`` parses as a float and ``id: 2``
|
||||
as an int. TypeError is not a ValidationError, so it escaped
|
||||
list_installed()'s "Corrupted extension" fallback and made
|
||||
`specify extension list` exit 1 with a raw traceback, hiding every
|
||||
healthy extension too. The sibling IntegrationDescriptor already
|
||||
type-checks the same four fields.
|
||||
"""
|
||||
import yaml
|
||||
|
||||
valid_manifest_data["extension"][field] = bad
|
||||
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
|
||||
with pytest.raises(ValidationError, match=f"Invalid extension.{field}"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
@pytest.mark.parametrize("bad", [1.0, 5, None, ["a"], {"a": 1}, True])
|
||||
def test_command_name_not_string_rejected(
|
||||
self, temp_dir, valid_manifest_data, bad
|
||||
):
|
||||
"""A non-string command name must raise ValidationError, not a raw
|
||||
TypeError from the name-pattern match.
|
||||
|
||||
The sibling ``file`` field was already covered, since
|
||||
relative_extension_path_violation() rejects a non-string value; ``name``
|
||||
went straight into EXTENSION_COMMAND_NAME_PATTERN.match().
|
||||
"""
|
||||
import yaml
|
||||
|
||||
valid_manifest_data["provides"]["commands"][0]["name"] = bad
|
||||
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
|
||||
with pytest.raises(ValidationError, match="Invalid command name"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_one_bad_manifest_does_not_hide_healthy_extensions(self, temp_dir):
|
||||
"""End-to-end guard for the symptom: an unquoted ``version: 1.0`` in one
|
||||
installed extension must degrade to "Corrupted extension" and still let
|
||||
list_installed() report the healthy ones, instead of raising TypeError
|
||||
out of the whole call.
|
||||
"""
|
||||
ext_root = temp_dir / ".specify" / "extensions"
|
||||
for ext_id, version in (("good-ext", '"1.0.0"'), ("bad-ext", "1.0")):
|
||||
ext_path = ext_root / ext_id
|
||||
ext_path.mkdir(parents=True, exist_ok=True)
|
||||
(ext_path / "extension.yml").write_text(
|
||||
f"""schema_version: "1.0"
|
||||
extension:
|
||||
id: {ext_id}
|
||||
name: {ext_id}
|
||||
version: {version}
|
||||
description: desc
|
||||
requires:
|
||||
speckit_version: ">=0.1.0"
|
||||
provides:
|
||||
commands:
|
||||
- name: speckit.{ext_id}.hello
|
||||
file: commands/hello.md
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(ext_root / ".registry").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"extensions": {
|
||||
"good-ext": {"version": "1.0.0", "enabled": True},
|
||||
"bad-ext": {"version": "1.0", "enabled": True},
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
listed = {row["id"]: row for row in ExtensionManager(temp_dir).list_installed()}
|
||||
|
||||
assert set(listed) == {"good-ext", "bad-ext"}
|
||||
assert "Corrupted" not in listed["good-ext"]["description"]
|
||||
assert "Corrupted" in listed["bad-ext"]["description"]
|
||||
|
||||
def test_empty_provides_mapping_is_still_accepted_with_hooks(
|
||||
self, temp_dir, valid_manifest_data
|
||||
):
|
||||
@@ -10454,3 +10550,310 @@ def test_forge_extension_info_hyphenates_command_names(
|
||||
# not the manifest's dotted name.
|
||||
assert "speckit-test-ext-hello" in output, output
|
||||
assert "speckit.test-ext.hello" not in output, 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 lands where ConfigManager reads it, not in .specify/ root."""
|
||||
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 == []
|
||||
# ConfigManager._get_project_config() reads
|
||||
# .specify/extensions/<id>/<name>, so that is where scaffolding must
|
||||
# put it. Deploying to the .specify/ root left the file somewhere the
|
||||
# extension never looks.
|
||||
assert (ext_dir / "test-config.yml").exists()
|
||||
assert (ext_dir / "test-config.yml").read_text() == "setting: default"
|
||||
assert not (specify_dir / "test-config.yml").exists()
|
||||
|
||||
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)
|
||||
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")
|
||||
(ext_dir / "test-config.yml").write_text("setting: custom")
|
||||
|
||||
manager = ExtensionManager(project)
|
||||
deployed, skipped, failed = manager.scaffold_config("test-ext")
|
||||
|
||||
assert deployed == []
|
||||
assert skipped == ["test-config.yml"]
|
||||
assert failed == []
|
||||
assert (ext_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") == ([], [], [])
|
||||
|
||||
def test_scaffold_config_rejects_symlinked_config_root(self, tmp_path):
|
||||
"""A symlinked .specify must not become the containment root.
|
||||
|
||||
Resolving .specify first and trusting the result lets a symlink point
|
||||
anywhere: every target then satisfies relative_to and copy2 writes
|
||||
outside the project.
|
||||
"""
|
||||
from specify_cli.extensions import ExtensionManager
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(project / ".specify").symlink_to(outside, target_is_directory=True)
|
||||
|
||||
ext_dir = outside / "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 == []
|
||||
assert failed == ["provides.config"]
|
||||
assert not (outside / "extensions" / "test-ext" / "test-config.yml").exists()
|
||||
|
||||
def test_scaffold_config_rejects_targets_removal_would_not_preserve(self, tmp_path):
|
||||
"""Only top-level *-config.yml targets are scaffolded.
|
||||
|
||||
remove(keep_config=True) rmtree's every subdirectory and keeps only
|
||||
top-level -config.yml / -config.local.yml files, and the backup path
|
||||
globs the same pattern. Scaffolding anything else would hand the user a
|
||||
file that `extension add --force` silently replaces with the template
|
||||
default.
|
||||
"""
|
||||
from specify_cli.extensions import ExtensionManager
|
||||
for target in ("nested/test-config.yml", "settings.yml", "test-config.yaml"):
|
||||
project = tmp_path / f"project-{target.replace('/', '_')}"
|
||||
specify_dir = project / ".specify"
|
||||
specify_dir.mkdir(parents=True)
|
||||
ext_dir = specify_dir / "extensions" / "test-ext"
|
||||
self._make_extension(ext_dir, config_entries=[{
|
||||
"name": target,
|
||||
"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 == [], target
|
||||
assert skipped == [], target
|
||||
assert failed == [target], target
|
||||
|
||||
def test_scaffold_config_accepts_local_override_name(self, tmp_path):
|
||||
"""*-config.local.yml is preserved by removal, so it may be scaffolded."""
|
||||
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.local.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.local.yml"]
|
||||
assert failed == []
|
||||
|
||||
@@ -274,3 +274,29 @@ class TestClineRealPostProcess:
|
||||
# _rewrite_handoff_references rewrote the dotted agent handoff
|
||||
assert "agent: speckit-foo" in content
|
||||
assert "agent: speckit.foo" not in content
|
||||
|
||||
|
||||
def test_register_commands_propagates_programming_errors(tmp_path):
|
||||
"""Regression: narrowed exception must not swallow TypeError/AttributeError.
|
||||
|
||||
The invoke separator resolution narrowed from bare 'except Exception' to
|
||||
'except (ImportError, ValueError, KeyError)'. Programming errors like
|
||||
TypeError must propagate instead of being silently swallowed.
|
||||
"""
|
||||
registrar = CommandRegistrar()
|
||||
commands = [{"name": "test.cmd", "file": "commands/test.md"}]
|
||||
|
||||
ext_dir = tmp_path / "ext"
|
||||
ext_dir.mkdir()
|
||||
|
||||
def _broken_get_integration(name):
|
||||
raise TypeError("intentional programming error")
|
||||
|
||||
import specify_cli.integrations as integ_mod
|
||||
original = integ_mod.get_integration
|
||||
integ_mod.get_integration = _broken_get_integration
|
||||
try:
|
||||
with pytest.raises(TypeError, match="intentional programming error"):
|
||||
registrar.register_commands("bob", commands, "ext", ext_dir, tmp_path)
|
||||
finally:
|
||||
integ_mod.get_integration = original
|
||||
|
||||
@@ -217,6 +217,102 @@ class TestPresetManifest:
|
||||
):
|
||||
PresetManifest(manifest_path)
|
||||
|
||||
@pytest.mark.parametrize("field", ["id", "name", "version", "description"])
|
||||
@pytest.mark.parametrize("bad", [1.0, 5, None, ["a"], {"a": 1}, True])
|
||||
def test_preset_metadata_field_not_string_raises_validation_error(
|
||||
self, temp_dir, valid_pack_data, field, bad
|
||||
):
|
||||
"""A non-string preset.<field> raises PresetValidationError, not a raw
|
||||
TypeError.
|
||||
|
||||
The loop over these four fields only checked key PRESENCE, then fed the
|
||||
values to ``re.match`` (id) and ``packaging.Version`` (version), both of
|
||||
which raise a bare TypeError on a non-string. YAML makes that an easy
|
||||
authoring slip: unquoted ``version: 1.0`` parses as a float and ``id: 2``
|
||||
as an int. TypeError is not a PresetValidationError, so it escaped
|
||||
list_installed()'s "Corrupted preset" fallback and made
|
||||
`specify preset list` exit 1 with a raw traceback, hiding every healthy
|
||||
preset too. The sibling IntegrationDescriptor already type-checks the
|
||||
same four fields.
|
||||
"""
|
||||
valid_pack_data["preset"][field] = bad
|
||||
manifest_path = temp_dir / "preset.yml"
|
||||
manifest_path.write_text(yaml.safe_dump(valid_pack_data), encoding="utf-8")
|
||||
|
||||
with pytest.raises(
|
||||
PresetValidationError,
|
||||
match=rf"Invalid preset\.{field}: expected a string",
|
||||
):
|
||||
PresetManifest(manifest_path)
|
||||
|
||||
@pytest.mark.parametrize("field", ["name", "file"])
|
||||
@pytest.mark.parametrize("bad", [1.0, 5, None, ["a"], {"a": 1}, True])
|
||||
def test_template_entry_field_not_string_raises_validation_error(
|
||||
self, temp_dir, valid_pack_data, field, bad
|
||||
):
|
||||
"""A non-string template ``name``/``file`` raises PresetValidationError.
|
||||
|
||||
``name`` reaches ``re.match`` and ``file`` reaches ``os.path.normpath``;
|
||||
both raise a bare TypeError on a non-string. The sibling extension
|
||||
manifest already rejects a non-string command ``file`` via
|
||||
relative_extension_path_violation().
|
||||
"""
|
||||
valid_pack_data["provides"]["templates"][0][field] = bad
|
||||
manifest_path = temp_dir / "preset.yml"
|
||||
manifest_path.write_text(yaml.safe_dump(valid_pack_data), encoding="utf-8")
|
||||
|
||||
with pytest.raises(
|
||||
PresetValidationError,
|
||||
match=rf"Invalid template {field}: expected a string",
|
||||
):
|
||||
PresetManifest(manifest_path)
|
||||
|
||||
def test_one_bad_manifest_does_not_hide_healthy_presets(self, temp_dir):
|
||||
"""End-to-end guard for the symptom: an unquoted ``version: 1.0`` in one
|
||||
installed preset must degrade to "Corrupted preset" and still let
|
||||
list_installed() report the healthy ones, instead of raising TypeError
|
||||
out of the whole call.
|
||||
"""
|
||||
preset_root = temp_dir / ".specify" / "presets"
|
||||
for pack_id, version in (("good-pack", '"1.0.0"'), ("bad-pack", "1.0")):
|
||||
pack_path = preset_root / pack_id
|
||||
pack_path.mkdir(parents=True, exist_ok=True)
|
||||
(pack_path / "preset.yml").write_text(
|
||||
f"""schema_version: "1.0"
|
||||
preset:
|
||||
id: {pack_id}
|
||||
name: {pack_id}
|
||||
version: {version}
|
||||
description: desc
|
||||
requires:
|
||||
speckit_version: ">=0.1.0"
|
||||
provides:
|
||||
templates:
|
||||
- type: template
|
||||
name: spec
|
||||
file: templates/spec.md
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(preset_root / ".registry").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"presets": {
|
||||
"good-pack": {"version": "1.0.0", "enabled": True},
|
||||
"bad-pack": {"version": "1.0", "enabled": True},
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
listed = {row["id"]: row for row in PresetManager(temp_dir).list_installed()}
|
||||
|
||||
assert set(listed) == {"good-pack", "bad-pack"}
|
||||
assert "Corrupted" not in listed["good-pack"]["description"]
|
||||
assert "Corrupted" in listed["bad-pack"]["description"]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad",
|
||||
[
|
||||
@@ -4925,6 +5021,103 @@ class TestPresetSkills:
|
||||
assert "templates/commands/specify.md" in content, "Should reference core template"
|
||||
assert "disable-model-invocation: false" in content
|
||||
|
||||
def test_skill_restored_on_preset_remove_without_project_core_templates(self, project_dir):
|
||||
"""Removing a preset must restore core skills even when the project
|
||||
has no ``.specify/templates/commands`` directory of its own — which
|
||||
is the normal case, since ``specify init`` never populates it. The
|
||||
real core commands live in the bundled core_pack/repo-root templates
|
||||
tree, and restoration must fall back there instead of deleting the
|
||||
skill outright (#3928).
|
||||
"""
|
||||
self._write_init_options(project_dir, ai="claude")
|
||||
skills_dir = project_dir / ".claude" / "skills"
|
||||
self._create_skill(skills_dir, "speckit-specify")
|
||||
|
||||
# The project_dir fixture's commands dir is empty, matching a real
|
||||
# project — specify init never populates project-local overrides
|
||||
# for unmodified core commands.
|
||||
core_cmds = project_dir / ".specify" / "templates" / "commands"
|
||||
assert core_cmds.exists() and not any(core_cmds.iterdir())
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
|
||||
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
|
||||
assert "preset:self-test" in skill_file.read_text(encoding="utf-8")
|
||||
|
||||
manager.remove("self-test")
|
||||
|
||||
assert skill_file.exists(), "Core skill must be restored, not deleted"
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
assert "preset:self-test" not in content
|
||||
assert "templates/commands/specify.md" in content
|
||||
assert "Create or update the feature specification" in content
|
||||
|
||||
def test_extension_wins_over_bundled_core_on_preset_remove(
|
||||
self, project_dir, monkeypatch
|
||||
):
|
||||
"""When an installed extension owns the same skill name as a core
|
||||
command, removing a preset that overrode that skill must restore it
|
||||
from the extension, not silently from the bundled core template.
|
||||
Extensions are resolved ahead of bundled core elsewhere, and the
|
||||
bundled-core fallback added for #3928 must not replace that
|
||||
higher-priority layer.
|
||||
|
||||
The extension-command namespace rules (``speckit.<ext>.<command>``)
|
||||
make a genuine end-to-end name collision with a core command
|
||||
cumbersome to construct through real manifests, so this stubs
|
||||
``_build_extension_skill_restore_index`` to exercise the priority
|
||||
ordering in ``_unregister_skills_in_dir`` directly -- the code path
|
||||
under test doesn't care how the index entry was produced, only that
|
||||
it wins over the bundled-core fallback when present.
|
||||
"""
|
||||
self._write_init_options(project_dir, ai="claude")
|
||||
skills_dir = project_dir / ".claude" / "skills"
|
||||
self._create_skill(skills_dir, "speckit-specify")
|
||||
|
||||
# No project-local core template override — the normal case, and
|
||||
# the one that makes the bundled-core fallback kick in at all.
|
||||
core_cmds = project_dir / ".specify" / "templates" / "commands"
|
||||
assert core_cmds.exists() and not any(core_cmds.iterdir())
|
||||
|
||||
extension_dir = project_dir / ".specify" / "extensions" / "fakeext"
|
||||
(extension_dir / "commands").mkdir(parents=True, exist_ok=True)
|
||||
ext_specify_file = extension_dir / "commands" / "specify.md"
|
||||
ext_specify_file.write_text(
|
||||
"---\ndescription: Extension specify command\n---\n\n"
|
||||
"extension:fakeext specify body\n"
|
||||
)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
|
||||
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
|
||||
assert "preset:self-test" in skill_file.read_text(encoding="utf-8")
|
||||
|
||||
fake_restore_index = {
|
||||
"speckit-specify": {
|
||||
"command_name": "speckit.fakeext.specify",
|
||||
"source_file": ext_specify_file,
|
||||
"source": "extension:fakeext",
|
||||
"extension_id": "fakeext",
|
||||
"extension_dir": extension_dir,
|
||||
}
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"_build_extension_skill_restore_index",
|
||||
lambda: fake_restore_index,
|
||||
)
|
||||
|
||||
manager.remove("self-test")
|
||||
|
||||
assert skill_file.exists()
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
assert "preset:self-test" not in content
|
||||
assert "source: extension:fakeext" in content
|
||||
assert "extension:fakeext specify body" in content
|
||||
assert "templates/commands/specify.md" not in content
|
||||
|
||||
def test_skill_restored_on_remove_resolves_script_placeholders(self, project_dir):
|
||||
"""Core restore should resolve {SCRIPT}/{ARGS} placeholders like other skill paths."""
|
||||
self._write_init_options(project_dir, ai="claude", ai_skills=True, script="sh")
|
||||
@@ -11626,6 +11819,24 @@ class TestResolveContent:
|
||||
class TestCollectAllLayers:
|
||||
"""Test PresetResolver.collect_all_layers() method."""
|
||||
|
||||
def test_non_utf8_legacy_command_keeps_replace_strategy(self, project_dir):
|
||||
presets_dir = project_dir / ".specify" / "presets"
|
||||
command_path = (
|
||||
presets_dir / "legacy-pack" / "commands" / "speckit.legacy.md"
|
||||
)
|
||||
command_path.parent.mkdir(parents=True)
|
||||
command_path.write_bytes(b"\xff\xfe")
|
||||
PresetRegistry(presets_dir).add(
|
||||
"legacy-pack", {"version": "1.0.0", "priority": 10}
|
||||
)
|
||||
|
||||
layers = PresetResolver(project_dir).collect_all_layers(
|
||||
"speckit.legacy", "command"
|
||||
)
|
||||
|
||||
assert layers[0]["path"] == command_path
|
||||
assert layers[0]["strategy"] == "replace"
|
||||
|
||||
def test_single_core_layer(self, project_dir):
|
||||
"""Test collecting layers with only core template."""
|
||||
resolver = PresetResolver(project_dir)
|
||||
|
||||
@@ -2176,6 +2176,59 @@ class TestInitStep:
|
||||
assert "--ignore-agent-tools" in argv
|
||||
assert (tmp_path / ".specify").is_dir()
|
||||
|
||||
def test_explicit_null_ignore_agent_tools_keeps_documented_default(
|
||||
self, tmp_path
|
||||
):
|
||||
"""A bare ``ignore_agent_tools:`` must keep the documented default.
|
||||
|
||||
The class docstring says "Because workflows run unattended, the step
|
||||
defaults to ``--ignore-agent-tools``" and the field docs say "defaults to
|
||||
``true``". But ``config.get(key, True)`` applies the default only when the
|
||||
key is ABSENT — a bare ``ignore_agent_tools:`` in YAML parses to None,
|
||||
which ``_resolve_bool`` turned into False, dropping the flag and
|
||||
re-enabling the agent-CLI presence check for an unattended run.
|
||||
"""
|
||||
from specify_cli.workflows.steps.init import InitStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
step = InitStep()
|
||||
ctx = StepContext(
|
||||
project_root=str(tmp_path), default_integration="copilot"
|
||||
)
|
||||
result = step.execute(
|
||||
{
|
||||
"id": "bootstrap",
|
||||
"here": True,
|
||||
"script": "sh",
|
||||
"ignore_agent_tools": None,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
assert result.status == StepStatus.COMPLETED
|
||||
assert "--ignore-agent-tools" in result.output["argv"]
|
||||
|
||||
def test_explicit_false_ignore_agent_tools_is_honoured(self, tmp_path):
|
||||
"""An explicit ``false`` must still opt in to the agent-CLI check."""
|
||||
from specify_cli.workflows.steps.init import InitStep
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
step = InitStep()
|
||||
ctx = StepContext(
|
||||
project_root=str(tmp_path), default_integration="copilot"
|
||||
)
|
||||
result = step.execute(
|
||||
{
|
||||
"id": "bootstrap",
|
||||
"here": True,
|
||||
"script": "sh",
|
||||
"ignore_agent_tools": False,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
assert "--ignore-agent-tools" not in result.output["argv"]
|
||||
|
||||
def test_default_integration_falls_back_to_workflow_default(self, tmp_path):
|
||||
from specify_cli.workflows.steps.init import InitStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
@@ -2190,6 +2243,24 @@ class TestInitStep:
|
||||
assert result.status == StepStatus.COMPLETED
|
||||
assert result.output["integration"] == "copilot"
|
||||
|
||||
def test_default_integration_honors_env_var(self, tmp_path, monkeypatch):
|
||||
# With no step-level and no workflow-level default, the resolved
|
||||
# SPECKIT_INTEGRATION_DEFAULT value must drive both output.integration
|
||||
# and the argv passed to init (guards against reverting to the constant).
|
||||
from specify_cli.workflows.steps.init import InitStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_DEFAULT", "gemini")
|
||||
step = InitStep()
|
||||
ctx = StepContext(project_root=str(tmp_path))
|
||||
result = step.execute(
|
||||
{"id": "bootstrap", "here": True, "script": "sh"}, ctx
|
||||
)
|
||||
assert result.status == StepStatus.COMPLETED
|
||||
assert result.output["integration"] == "gemini"
|
||||
argv = result.output["argv"]
|
||||
assert "--integration" in argv and "gemini" in argv
|
||||
|
||||
def test_project_name_creates_subdirectory(self, tmp_path):
|
||||
from specify_cli.workflows.steps.init import InitStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
Reference in New Issue
Block a user