Compare commits

...
Author SHA1 Message Date
github-actions[bot] a0687f4b46 chore: bump version to 0.15.2 2026-08-03 19:06:49 +00:00
chelsealongandGitHub f4e3110560 fix(presets): restore core skills instead of deleting them on preset remove (#3929)
* fix(presets): restore core skills instead of deleting them on preset remove

Skill restoration looked for core command templates under
.specify/templates/commands, a directory specify init never populates
in real projects. Since that lookup always missed, presets overriding
a core command (e.g. speckit.plan) had their skill deleted outright on
removal instead of restored — the actual core templates live in the
bundled core_pack (wheel install) or the repo-root templates/ tree.

Restoration now falls back to that bundled location, gated behind a
restore_from_bundled_core flag so the existing "retire a stale skill
superseded by a command-mode winner" path keeps deleting rather than
resurrecting a duplicate skill.

Fixes #3928

* fix(tests): use explicit utf-8 encoding reading restored skill content

read_text() defaults to the platform locale encoding, which is cp1252
("charmap") on Windows. The bundled specify.md core template contains a
UTF-8 multi-byte emoji whose bytes aren't valid cp1252, so the Windows CI
job failed decoding the restored SKILL.md with UnicodeDecodeError.

* fix(presets): keep extension restore priority over bundled-core fallback

The bundled-core fallback added for #3928 ran before the
extension_restore_index lookup, so a skill an installed extension owns
could be silently replaced by lower-priority bundled core content on
preset removal instead of preserving the extension's winning layer.
2026-08-03 13:54:12 -05:00
2d8904a21e fix(manifests): reject non-string metadata instead of crashing on it (#3943)
* fix(manifests): reject non-string metadata instead of crashing on it

`ExtensionManifest` and `PresetManifest` checked only key PRESENCE for
`id`/`name`/`version`/`description`, then fed the values straight to
`re.match()` and `packaging.Version()`. Both raise a bare `TypeError` on a
non-string, which is neither `ValidationError` nor `PresetValidationError`,
so it escaped every caller that already handles a malformed manifest.

YAML makes this an easy authoring slip rather than a contrived one: an
unquoted `version: 1.0` parses as a float and `id: 2` as an int.

The user-visible symptom is the one the in-tree comment above the section
guards was written to prevent (#3898 for presets, and its extension twin):
`list_installed()` degrades a bad manifest to "⚠️ Corrupted extension" but
catches only the domain error, so a single bad manifest made
`specify extension list` / `specify preset list` exit 1 with a raw
traceback and *no output at all* — hiding every healthy extension/preset
too, not just the broken one.

Also unguarded on the same path:
- extension `provides.commands[].name` → `TypeError` from the command-name
  pattern match. The sibling `file` field was already safe, since
  `relative_extension_path_violation()` rejects a non-string.
- preset `provides.templates[].name`/`.file` → `TypeError` from `re.match`
  and `os.path.normpath` respectively.

The third manifest twin, `IntegrationDescriptor`, is already hardened: it
type-checks the same four fields and catches `TypeError` alongside
`InvalidVersion`. This brings the other two in line with it.

Tests: 68 added across both suites, covering each field against float, int,
None, list, dict, and bool, plus an end-to-end guard per manifest type
asserting a healthy entry still lists while the bad one degrades. All 68
fail with the source change reverted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-03 13:53:08 -05:00
Quratulain-bilalandGitHub 7ddb8194d9 fix: narrow bare except Exception in invoke separator resolution (#3856)
* fix: narrow exception in invoke separator resolution and add regression test

Narrow 'except Exception' to 'except (ImportError, ValueError, KeyError)'
in register_commands() invoke separator resolution. Add regression test
that verifies TypeError propagates instead of being silently swallowed.

* fix: remove duplicate pass statement in agents.py

Remove redundant second pass statement in the except block
for invoke separator resolution. The narrowed exception handler
now has a single clean pass statement.

Assisted-by: GitHub Copilot (model: mimo-v2-free, supervised)
2026-08-03 13:49:37 -05:00
84a2114338 fix(workflows): keep the init step's documented ignore_agent_tools default on an explicit null (#3889)
The step documents the default twice:

  class docstring: "Because workflows run unattended, the step defaults to
                    ``--ignore-agent-tools``"
  field docs:      "Skip checks for the coding agent CLI (defaults to ``true``)"

It implements that with `config.get("ignore_agent_tools", True)`, which
applies the default only when the key is ABSENT. A bare
`ignore_agent_tools:` in YAML parses to None, and `_resolve_bool(None)`
returns False:

  key ABSENT                -> True   flag emitted: YES
  bare ignore_agent_tools:  -> False  flag emitted: NO   <-- bug
  explicit true             -> True   flag emitted: YES
  explicit false            -> False  flag emitted: NO

So the flag is dropped, `specify init` re-runs the agent-CLI presence
check, and an unattended run fails with "Agent Detection Error" for any
integration whose CLI is not installed on the runner.

Normalize an explicit null to the default, mirroring the while/do-while
`max_iterations` handling.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 13:14:31 -05:00
b69147c841 fix(kimi): preserve non-UTF-8 user skills (#3895)
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-03 13:11:19 -05:00
14fab0a1af fix(presets): tolerate non-UTF-8 legacy commands (#3896)
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-03 12:33:39 -05:00
e9ffc9d8e7 feat: allow overriding default init integration via SPECKIT_INTEGRATION_DEFAULT (#3952)
* feat: allow overriding default init integration via SPECKIT_INTEGRATION_DEFAULT

Resolve the non-interactive/init default integration from the
SPECKIT_INTEGRATION_DEFAULT environment variable, fitting the existing
SPECKIT_INTEGRATION_* namespace. Falls back to the hardcoded "copilot"
default when unset, and warns to stderr (rather than silently falling
back) when the value is not a registered integration key.

Wires the resolver into specify init (interactive prompt default and
non-interactive fallback), the init workflow step, and the bundle init
default. Adds unit and CLI tests and documents the variable.

Closes #3939

Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: eecda55f-fa13-42f7-99bf-bfb0bb8565a0

* test: cover env-var default wiring for picker, workflow step, and bundle

Address PR review: add regression tests so each SPECKIT_INTEGRATION_DEFAULT
wiring site cannot silently revert to the hardcoded constant.

- init.py: interactive picker receives the resolved key as default_key.
- workflow init step: no step/workflow default + env var drives output
  integration and argv.
- bundle _resolve_init_integration: env-var default applies when unspecified,
  while explicit override and manifest-declared integration still win.

Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: eecda55f-fa13-42f7-99bf-bfb0bb8565a0

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: eecda55f-fa13-42f7-99bf-bfb0bb8565a0
2026-08-03 12:20:49 -05:00
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>Copilot
4751777a38 Add adrkit extension to community catalog (#3947)
Add adrkit extension submitted by @mbeacom to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #3942


Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-03 11:56:04 -05:00
15cb7d9a66 feat(extensions): scaffold config templates on extension add/enable (#2000)
* feat(extensions): scaffold config templates on extension add/enable

Deploy an extension's provides.config templates into .specify/ when the
extension is added or enabled. Existing files are never overwritten, so
user customizations are preserved.

Addresses the review on #2000:
- ExtensionManifest.config returns [] unless provides.config is a list of
  dicts, so a malformed manifest cannot crash callers.
- scaffold_config returns a consistent (deployed, skipped_existing, failed)
  tuple on every path, including a missing manifest.
- Template paths must resolve inside the extension dir and targets inside
  .specify/; symlinks and non-regular files are rejected.
- Callers distinguish "already exists (preserved)" from "not scaffolded",
  and extension_enable no longer crashes on a corrupt manifest.
- Tests cover traversal, absolute paths, symlinks, directory templates,
  malformed provides.config, and the missing-manifest tuple shape.

Ported onto the extensions package introduced by #3014: the manager and
manifest changes land in extensions/__init__.py and the CLI wiring in
extensions/_commands.py.

* fix(extensions): deploy config where it is read, and contain the write

Addresses @Copilot's review.

Config now lands in .specify/extensions/<id>/ rather than the .specify/
root. ConfigManager._get_project_config() reads
.specify/extensions/<id>/<id>-config.yml, and the bundled scripts and
READMEs use the same path, so a scaffolded git-config.yml was being
written somewhere the git extension never looks.

Containment is checked component by component before .specify is used as
the root. Resolving it first and trusting the result let a symlinked
component point outside the project, after which every target satisfied
relative_to and copy2 wrote externally. This matches the project
safe-write path in shared_infra.

mkdir moved inside the OSError handler. A nested target like
foo/config.yml raised out of scaffolding when its parent could not be
created, and on extension add that happened after the extension was
already installed.

The 'Configuration may be required' warning is now conditional. It ran
unconditionally after the scaffolding block, so it contradicted the
success output directly above it and fired for extensions with no
provides.config at all.

Tests cover the corrected location, a symlinked config root, and an
uncreatable nested target.

* fix(extensions): only scaffold config targets that removal preserves

remove(keep_config=True) rmtree's every subdirectory and keeps only top-level
-config.yml / -config.local.yml files; the backup path globs the same
top-level pattern. Scaffolding a nested or differently-named target therefore
handed the user a file that 'extension add --force' silently deleted and
replaced with the template default, losing customization.

Constrain scaffold targets to that convention rather than widening four
removal paths.

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-08-03 11:24:21 -05:00
23 changed files with 1285 additions and 26 deletions
+25
View File
@@ -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
+1
View File
@@ -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) |
+2 -1
View File
@@ -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. |
+36 -1
View File
@@ -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
View File
@@ -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"
+2
View File
@@ -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,
+32
View 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",
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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 =====
+5 -4
View File
@@ -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:
+177
View File
@@ -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,
+47 -2
View File
@@ -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("---"):
+82 -7
View File
@@ -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()
+62
View File
@@ -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."""
+48
View File
@@ -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)
+403
View File
@@ -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 == []
+26
View File
@@ -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
+211
View File
@@ -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)
+71
View File
@@ -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