mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b683c2292 | ||
|
|
f36634b5c1 | ||
|
|
6712665bba | ||
|
|
5827db5359 | ||
|
|
afbb2c7b65 | ||
|
|
6337ebfe59 | ||
|
|
e543147ccb | ||
|
|
6033c6957b | ||
|
|
13f2b135cc | ||
|
|
54396780f3 | ||
|
|
db5802b39b | ||
|
|
8394c8d536 | ||
|
|
89126f3a33 | ||
|
|
623466dc42 | ||
|
|
2ef96532d2 | ||
|
|
de54ff73fe | ||
|
|
f4a9b890cc | ||
|
|
b7b0e966cc | ||
|
|
884950f88a | ||
|
|
f8e474d6fd | ||
|
|
1fff7a196d | ||
|
|
b048e339a5 | ||
|
|
d99170fb5e | ||
|
|
f04a36a629 |
12
.github/scripts/check_security_requirements.py
vendored
12
.github/scripts/check_security_requirements.py
vendored
@@ -29,12 +29,20 @@ def _dependency_diff_refs() -> tuple[str, str]:
|
||||
def _dependency_inputs_changed() -> bool:
|
||||
base_ref, head_ref = _dependency_diff_refs()
|
||||
try:
|
||||
merge_base = subprocess.run(
|
||||
["git", "merge-base", base_ref, head_ref],
|
||||
check=True,
|
||||
cwd=REPO_ROOT,
|
||||
stderr=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
result = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"diff",
|
||||
"--name-only",
|
||||
base_ref,
|
||||
merge_base,
|
||||
head_ref,
|
||||
"--",
|
||||
*DEPENDENCY_INPUTS,
|
||||
@@ -77,6 +85,7 @@ def main() -> int:
|
||||
|
||||
generated_requirements = Path(generated_requirements_env)
|
||||
generated_requirements.parent.mkdir(parents=True, exist_ok=True)
|
||||
generated_requirements.write_bytes(COMMITTED_REQUIREMENTS.read_bytes())
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
@@ -87,7 +96,6 @@ def main() -> int:
|
||||
"--extra",
|
||||
"test",
|
||||
"--universal",
|
||||
"--upgrade",
|
||||
"--generate-hashes",
|
||||
"--quiet",
|
||||
"--no-header",
|
||||
|
||||
2
.github/workflows/security.yml
vendored
2
.github/workflows/security.yml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
- name: Check committed audit requirements are current
|
||||
env:
|
||||
DEPENDENCY_DIFF_BASE: ${{ github.event.pull_request.base.sha || github.event.before || '' }}
|
||||
DEPENDENCY_DIFF_HEAD: ${{ github.sha }}
|
||||
DEPENDENCY_DIFF_HEAD: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
GENERATED_REQUIREMENTS: ${{ runner.temp }}/security-audit-requirements.txt
|
||||
run: python .github/scripts/check_security_requirements.py
|
||||
|
||||
|
||||
28
CHANGELOG.md
28
CHANGELOG.md
@@ -2,6 +2,34 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.15.0] - 2026-07-30
|
||||
|
||||
### Changed
|
||||
|
||||
- Add yolo to community workflow catalog (#3864)
|
||||
- fix(workflows): guard the shell step's timeout check against OverflowError (#3865)
|
||||
- Add Intent Reconciliation extension to community catalog (#3858)
|
||||
- fix(workflows): validate prompt step 'timeout' like the shell step (#3847)
|
||||
- fix: add utf-8 encoding to registry file open calls (#3816)
|
||||
- fix: eliminate TOCTOU race in file unlink calls (#3815)
|
||||
- test(workflows): name the condition-rejection tests for the real boundary (#3808)
|
||||
- fix: eliminate TOCTOU race in file unlink calls (#3811)
|
||||
- fix(presets): escape user-supplied catalog name/URL in add/remove output (#3806)
|
||||
- fix: add missing utf-8 encoding to registry file open calls (#3810)
|
||||
- [bug-fix] Fix upgrade-overwrites-copilot-skills: pass force=True to extension skill re-registration after upgrade (#3853)
|
||||
- fix(integrations): don't abort uninstall when the manifest can't be deleted (#3805)
|
||||
- test(extensions): update stale manifest validation message assertion (#3859)
|
||||
- fix(agents): coerce a non-string description in TOML command rendering (#3799)
|
||||
- fix(workflows): make security requirements sync deterministic (#3832)
|
||||
- fix(cli): render the literal [suffix] in --tag help and rejection message (#3800)
|
||||
- fix(integrations): preserve non-UTF-8 VS Code settings (#3833)
|
||||
- fix(bundler): treat an explicit-null manifest field as missing, not the text "None" (#3798)
|
||||
- feat: first-class agent-native runtime hooks for integrations (#3704)
|
||||
- fix(extensions): guard the required manifest sections so one bad extension cannot break `extension list` (#3797)
|
||||
- fix(presets): escape installed preset metadata in Rich output (#3826)
|
||||
- fix(workflows): dispatch prompt steps via the resolved executable (#3793)
|
||||
- chore: release 0.14.4, begin 0.14.5.dev0 development (#3850)
|
||||
|
||||
## [0.14.4] - 2026-07-29
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -66,6 +66,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Improve Extension | Audits any codebase as a senior advisor and writes prioritized, self-contained spec prompts under specs/ that the spec-kit lifecycle can process | `process` | Read+Write | [spec-kit-improve](https://github.com/d0whc3r/spec-kit-improve) |
|
||||
| Intake | Normalize PRD, design, HTML SSOT, and test-case evidence into SDD-ready intake artifacts. | `docs` | Read+Write | [spec-kit-intake](https://github.com/bigsmartben/spec-kit-intake) |
|
||||
| Intelligent Agent Orchestrator | Cross-catalog agent discovery and intelligent prompt-to-command routing | `process` | Read+Write | [spec-kit-orchestrator](https://github.com/pragya247/spec-kit-orchestrator) |
|
||||
| Intent Reconciliation | Reconcile implementation-discovered decisions against approved feature intent | `process` | Read+Write | [spec-kit-reconcile](https://github.com/SuhaibAslam/spec-kit-reconcile) |
|
||||
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
|
||||
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
|
||||
| Jira Integration (Sync Engine) | Idempotent, drift-aware, fail-closed reconcile engine mirroring spec-kit specs into Jira (Epic per repo, Story per spec, Subtask per phase) | `integration` | Read+Write | [spec-kit-jira-sync](https://github.com/ashbrener/spec-kit-jira-sync) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-28T00:00:00Z",
|
||||
"updated_at": "2026-07-29T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -1861,6 +1861,40 @@
|
||||
"created_at": "2026-06-23T00:00:00Z",
|
||||
"updated_at": "2026-06-30T00:00:00Z"
|
||||
},
|
||||
"intent": {
|
||||
"name": "Intent Reconciliation",
|
||||
"id": "intent",
|
||||
"description": "Reconcile implementation-discovered decisions against approved feature intent",
|
||||
"author": "SuhaibAslam",
|
||||
"version": "1.0.2",
|
||||
"download_url": "https://github.com/SuhaibAslam/spec-kit-reconcile/archive/refs/tags/v1.0.2.zip",
|
||||
"repository": "https://github.com/SuhaibAslam/spec-kit-reconcile",
|
||||
"homepage": "https://github.com/SuhaibAslam/spec-kit-reconcile",
|
||||
"documentation": "https://github.com/SuhaibAslam/spec-kit-reconcile/blob/main/README.md",
|
||||
"changelog": "https://github.com/SuhaibAslam/spec-kit-reconcile/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "process",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.12.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"intent",
|
||||
"decisions",
|
||||
"reconciliation",
|
||||
"drift",
|
||||
"workflow"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-29T00:00:00Z",
|
||||
"updated_at": "2026-07-29T00:00:00Z"
|
||||
},
|
||||
"issue": {
|
||||
"name": "GitHub Issues Integration 2",
|
||||
"id": "issue",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.14.4"
|
||||
version = "0.15.0"
|
||||
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"
|
||||
|
||||
@@ -514,6 +514,11 @@ _register_extension_cmds(app)
|
||||
from .integrations._commands import register as _register_integration_cmds # noqa: E402
|
||||
_register_integration_cmds(app)
|
||||
|
||||
|
||||
# ===== Event Commands =====
|
||||
from .commands.event import register as _register_event_cmds # noqa: E402
|
||||
_register_event_cmds(app)
|
||||
|
||||
# Re-export selected helpers to preserve the public import surface.
|
||||
from .integrations._helpers import ( # noqa: E402
|
||||
_clear_init_options_for_integration as _clear_init_options_for_integration,
|
||||
|
||||
@@ -27,6 +27,7 @@ from pathlib import Path
|
||||
|
||||
import typer
|
||||
from packaging.version import InvalidVersion, Version
|
||||
from rich.markup import escape as _escape_markup
|
||||
|
||||
from ._download_security import MAX_JSON_METADATA_BYTES, read_response_limited
|
||||
from ._console import console
|
||||
@@ -1230,7 +1231,10 @@ def self_upgrade(
|
||||
tag: str | None = typer.Option(
|
||||
None,
|
||||
"--tag",
|
||||
help="Pin the target version (vX.Y.Z[suffix]). Without --tag, the "
|
||||
# Typer renders help through Rich, so escape the literal bracket (\[)
|
||||
# or `[suffix]` is parsed as a style tag and dropped -- `--help` then
|
||||
# advertises only `(vX.Y.Z)`, contradicting docs/upgrade.md and README.
|
||||
help="Pin the target version (vX.Y.Z\\[suffix]). Without --tag, the "
|
||||
"latest stable release is resolved via GitHub Releases.",
|
||||
),
|
||||
) -> None:
|
||||
@@ -1270,7 +1274,14 @@ def self_upgrade(
|
||||
try:
|
||||
tag = _validate_tag(tag)
|
||||
except typer.BadParameter as exc:
|
||||
console.print(str(exc), soft_wrap=True)
|
||||
# Escape at the print site rather than baking `\[` into
|
||||
# _INVALID_TAG_MESSAGE: the message is also raised through
|
||||
# typer.BadParameter, which Click renders without Rich, so the
|
||||
# constant must stay plain text. Unescaped, Rich parses the literal
|
||||
# `[suffix]` as a style tag and drops it, leaving the user with
|
||||
# "expected vMAJOR.MINOR.PATCH" -- implying a bare vX.Y.Z is the only
|
||||
# accepted form when -rc1 / .dev0 / +build.42 are all valid.
|
||||
console.print(_escape_markup(str(exc)), soft_wrap=True)
|
||||
raise typer.Exit(1) from exc
|
||||
|
||||
plan, failure_reason = _build_upgrade_plan(target_tag_override=tag)
|
||||
|
||||
@@ -302,8 +302,20 @@ class CommandRegistrar:
|
||||
toml_lines = []
|
||||
|
||||
if "description" in frontmatter:
|
||||
# Frontmatter comes from ``yaml.safe_load``, so ``description`` can
|
||||
# be any YAML type: ``description:`` with no value yields None,
|
||||
# ``description: 2`` an int, an unquoted ``true`` a bool.
|
||||
# ``_render_basic_toml_string`` iterates the value and calls ord()
|
||||
# on each character, so a non-string raises a raw TypeError -- and a
|
||||
# list of single-character items is silently concatenated into a
|
||||
# wrong value (``["a", "b"]`` -> ``"ab"``). Coerce first, matching
|
||||
# ``render_yaml_command`` below and ``TomlIntegration
|
||||
# ._extract_description``, which both normalise it already.
|
||||
description = frontmatter["description"]
|
||||
if not isinstance(description, str):
|
||||
description = str(description) if description is not None else ""
|
||||
toml_lines.append(
|
||||
f"description = {self._render_basic_toml_string(frontmatter['description'])}"
|
||||
f"description = {self._render_basic_toml_string(description)}"
|
||||
)
|
||||
toml_lines.append("")
|
||||
|
||||
|
||||
@@ -96,19 +96,19 @@ class BundleManifest:
|
||||
if not isinstance(data, dict):
|
||||
raise BundlerError("Manifest must be a YAML mapping at the top level.")
|
||||
|
||||
schema_version = str(data.get("schema_version", "")).strip()
|
||||
schema_version = _text(data.get("schema_version"))
|
||||
|
||||
bundle_raw = data.get("bundle")
|
||||
if not isinstance(bundle_raw, dict):
|
||||
raise BundlerError("Manifest is missing the required 'bundle' mapping.")
|
||||
meta = BundleMeta(
|
||||
id=str(bundle_raw.get("id", "")).strip(),
|
||||
name=str(bundle_raw.get("name", "")).strip(),
|
||||
version=str(bundle_raw.get("version", "")).strip(),
|
||||
role=str(bundle_raw.get("role", "")).strip(),
|
||||
description=str(bundle_raw.get("description", "")).strip(),
|
||||
author=str(bundle_raw.get("author", "")).strip(),
|
||||
license=str(bundle_raw.get("license", "")).strip(),
|
||||
id=_text(bundle_raw.get("id")),
|
||||
name=_text(bundle_raw.get("name")),
|
||||
version=_text(bundle_raw.get("version")),
|
||||
role=_text(bundle_raw.get("role")),
|
||||
description=_text(bundle_raw.get("description")),
|
||||
author=_text(bundle_raw.get("author")),
|
||||
license=_text(bundle_raw.get("license")),
|
||||
)
|
||||
|
||||
requires_raw = data.get("requires")
|
||||
@@ -117,7 +117,7 @@ class BundleManifest:
|
||||
elif not isinstance(requires_raw, dict):
|
||||
raise BundlerError("'requires' must be a mapping when present.")
|
||||
requires = Requires(
|
||||
speckit_version=str(requires_raw.get("speckit_version", "")).strip(),
|
||||
speckit_version=_text(requires_raw.get("speckit_version")),
|
||||
tools=_parse_str_list(requires_raw.get("tools"), "requires.tools"),
|
||||
mcp=_parse_str_list(requires_raw.get("mcp"), "requires.mcp"),
|
||||
)
|
||||
@@ -220,6 +220,22 @@ class BundleManifest:
|
||||
return self.integration is None
|
||||
|
||||
|
||||
def _text(raw: Any) -> str:
|
||||
"""Coerce a manifest scalar into stripped text, mapping an explicit null to ``""``.
|
||||
|
||||
A ``.get(key, "")`` default only covers a *missing* key. A key that is
|
||||
present but null -- how YAML spells an empty field (``author:`` with nothing
|
||||
after it) -- yields ``None``, and ``str(None)`` is the literal ``"None"``.
|
||||
That text is non-empty, so it sailed past the ``if not value`` required-field
|
||||
checks in :meth:`BundleManifest.structural_errors`: an empty required field
|
||||
was silently accepted and the bundle shipped ``"None"`` as its
|
||||
author/license/description.
|
||||
"""
|
||||
if raw is None:
|
||||
return ""
|
||||
return str(raw).strip()
|
||||
|
||||
|
||||
def _parse_str_list(raw: Any, field_name: str) -> tuple[str, ...]:
|
||||
"""Coerce a manifest list-of-strings field into a tuple of strings.
|
||||
|
||||
@@ -247,7 +263,7 @@ def _parse_refs(kind: str, raw: Any) -> list[ComponentRef]:
|
||||
refs.append(
|
||||
ComponentRef(
|
||||
kind=kind,
|
||||
id=str(item.get("id", "")).strip(),
|
||||
id=_text(item.get("id")),
|
||||
version=(str(item["version"]).strip() if item.get("version") else None),
|
||||
source=(str(item["source"]).strip() if item.get("source") else None),
|
||||
priority=priority,
|
||||
|
||||
39
src/specify_cli/commands/event.py
Normal file
39
src/specify_cli/commands/event.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""specify event * command handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import typer
|
||||
|
||||
event_app = typer.Typer(
|
||||
name="event",
|
||||
help="Manage and execute event-driven commands",
|
||||
add_completion=False,
|
||||
)
|
||||
|
||||
|
||||
@event_app.command("run")
|
||||
def event_run(
|
||||
command_name: str = typer.Argument(..., help="Name of the command to execute"),
|
||||
event_name: str = typer.Argument(..., help="Canonical event name (e.g., session_start)"),
|
||||
timeout: int = typer.Argument(
|
||||
120, help="Per-handler timeout in seconds (passed through from the native hook config)"
|
||||
),
|
||||
):
|
||||
"""Resolve and run an event-driven command script with stdin payload."""
|
||||
from ..events import resolve_and_run_event_command
|
||||
|
||||
# Read payload from stdin if available
|
||||
payload = sys.stdin.read() if not sys.stdin.isatty() else "{}"
|
||||
|
||||
# Run the event command
|
||||
project_root = Path.cwd() # The agent runs events from project root
|
||||
exit_code = resolve_and_run_event_command(
|
||||
command_name, event_name, payload, project_root, timeout=timeout
|
||||
)
|
||||
raise typer.Exit(code=exit_code)
|
||||
|
||||
|
||||
def register(app: typer.Typer) -> None:
|
||||
app.add_typer(event_app, name="event")
|
||||
@@ -443,12 +443,20 @@ def register(app: typer.Typer) -> None:
|
||||
if extra:
|
||||
integration_parsed_options.update(extra)
|
||||
|
||||
from ..events import resolve_events
|
||||
events_map = resolve_events(
|
||||
resolved_integration.key,
|
||||
resolved_integration.config,
|
||||
project_path,
|
||||
integration_parsed_options or None,
|
||||
)
|
||||
resolved_integration.setup(
|
||||
project_path,
|
||||
manifest,
|
||||
parsed_options=integration_parsed_options or None,
|
||||
script_type=selected_script,
|
||||
raw_options=integration_options,
|
||||
events=events_map,
|
||||
)
|
||||
manifest.save()
|
||||
|
||||
|
||||
2096
src/specify_cli/events.py
Normal file
2096
src/specify_cli/events.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -263,8 +263,25 @@ class ExtensionManifest:
|
||||
f"(expected {self.SCHEMA_VERSION})"
|
||||
)
|
||||
|
||||
# The REQUIRED_FIELDS loop above only checks key PRESENCE, so a section
|
||||
# that is written but left empty (``provides:`` -> None) or given the
|
||||
# wrong shape (``provides: []``) passes it and then fails on first use:
|
||||
# ``field not in None`` raises TypeError and ``None.get(...)`` raises
|
||||
# AttributeError. Neither is a ValidationError, so both escape the
|
||||
# callers that already handle malformed manifests -- list_installed()'s
|
||||
# "Corrupted extension" fallback catches ValidationError only, so one bad
|
||||
# extension made ``specify extension list`` exit 1 with a raw
|
||||
# AttributeError instead of listing the rest. Guard each required
|
||||
# section's shape, mirroring the nested guards below ("Invalid
|
||||
# provides.commands: expected a list", "Invalid hooks: expected a
|
||||
# mapping") and _load_yaml's document-root check.
|
||||
|
||||
# Validate extension metadata
|
||||
ext = self.data["extension"]
|
||||
if not isinstance(ext, dict):
|
||||
raise ValidationError(
|
||||
f"Invalid extension: expected a mapping, got {type(ext).__name__}"
|
||||
)
|
||||
for field in ["id", "name", "version", "description"]:
|
||||
if field not in ext:
|
||||
raise ValidationError(f"Missing extension.{field}")
|
||||
@@ -299,24 +316,37 @@ class ExtensionManifest:
|
||||
|
||||
# Validate requires section
|
||||
requires = self.data["requires"]
|
||||
if not isinstance(requires, dict):
|
||||
raise ValidationError(
|
||||
f"Invalid requires: expected a mapping, got {type(requires).__name__}"
|
||||
)
|
||||
if "speckit_version" not in requires:
|
||||
raise ValidationError("Missing requires.speckit_version")
|
||||
|
||||
# Validate provides section
|
||||
provides = self.data["provides"]
|
||||
if not isinstance(provides, dict):
|
||||
raise ValidationError(
|
||||
f"Invalid provides: expected a mapping, got {type(provides).__name__}"
|
||||
)
|
||||
commands = provides.get("commands", [])
|
||||
hooks = self.data.get("hooks")
|
||||
events = self.data.get("events")
|
||||
|
||||
if "commands" in provides and not isinstance(commands, list):
|
||||
raise ValidationError("Invalid provides.commands: expected a list")
|
||||
if "hooks" in self.data and not isinstance(hooks, dict):
|
||||
raise ValidationError("Invalid hooks: expected a mapping")
|
||||
if "events" in self.data:
|
||||
from ..events import validate_events
|
||||
validate_events(self.data)
|
||||
|
||||
has_commands = bool(commands)
|
||||
has_hooks = bool(hooks)
|
||||
has_events = bool(events)
|
||||
|
||||
if not has_commands and not has_hooks:
|
||||
raise ValidationError("Extension must provide at least one command or hook")
|
||||
if not has_commands and not has_hooks and not has_events:
|
||||
raise ValidationError("Extension must provide at least one command, hook, or event")
|
||||
|
||||
# Validate hook values (if present).
|
||||
# Each event is a single mapping or a list of mappings.
|
||||
@@ -440,6 +470,33 @@ class ExtensionManifest:
|
||||
f"The extension author should update the manifest."
|
||||
)
|
||||
|
||||
# C11: apply the same rename + alias-lift canonicalization to event
|
||||
# command references. Without this, an event referencing a command
|
||||
# that was auto-corrected (e.g. speckit.boot -> speckit.<id>.boot)
|
||||
# keeps the obsolete name, dispatch reports no command, and the event
|
||||
# silently no-ops.
|
||||
events_data = self.data.get("events", {})
|
||||
if isinstance(events_data, dict):
|
||||
for event_name, event_config in events_data.items():
|
||||
if not isinstance(event_config, dict):
|
||||
continue
|
||||
command_ref = event_config.get("command")
|
||||
if not isinstance(command_ref, str):
|
||||
continue
|
||||
after_rename = rename_map.get(command_ref, command_ref)
|
||||
parts = after_rename.split(".")
|
||||
if len(parts) == 2 and parts[0] == ext["id"]:
|
||||
final_ref = f"speckit.{ext['id']}.{parts[1]}"
|
||||
else:
|
||||
final_ref = after_rename
|
||||
if final_ref != command_ref:
|
||||
event_config["command"] = final_ref
|
||||
self.warnings.append(
|
||||
f"Event '{event_name}' referenced command '{command_ref}'; "
|
||||
f"updated to canonical form '{final_ref}'. "
|
||||
f"The extension author should update the manifest."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _try_correct_command_name(name: str, ext_id: str) -> Optional[str]:
|
||||
"""Try to auto-correct a non-conforming command name to the required pattern.
|
||||
@@ -535,7 +592,7 @@ class ExtensionRegistry:
|
||||
return {"schema_version": self.SCHEMA_VERSION, "extensions": {}}
|
||||
|
||||
try:
|
||||
with open(self.registry_path, "r") as f:
|
||||
with open(self.registry_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
# Validate loaded data is a dict (handles corrupted registry files)
|
||||
if not isinstance(data, dict):
|
||||
@@ -551,7 +608,7 @@ class ExtensionRegistry:
|
||||
def _save(self):
|
||||
"""Save registry to disk."""
|
||||
self.extensions_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.registry_path, "w") as f:
|
||||
with open(self.registry_path, "w", encoding="utf-8") as f:
|
||||
json.dump(self.data, f, indent=2)
|
||||
|
||||
def add(self, extension_id: str, metadata: dict):
|
||||
@@ -1267,6 +1324,7 @@ class ExtensionManager:
|
||||
manifest: ExtensionManifest,
|
||||
extension_dir: Path,
|
||||
link_outputs: bool = False,
|
||||
force: bool = False,
|
||||
) -> List[str]:
|
||||
"""Generate SKILL.md files for extension commands as agent skills.
|
||||
|
||||
@@ -1280,6 +1338,11 @@ class ExtensionManager:
|
||||
extension_dir: Installed extension directory.
|
||||
link_outputs: If True, create dev-mode symlinks for rendered
|
||||
skill files when supported by the OS.
|
||||
force: If True, overwrite existing SKILL.md files even when they
|
||||
are not dev-mode symlinks. Use in the upgrade path, where
|
||||
``setup()`` has just freshly regenerated core-template skill
|
||||
files and the skip guard would otherwise prevent extension
|
||||
content from being layered on top.
|
||||
|
||||
Returns:
|
||||
List of skill names that were created (for registry storage).
|
||||
@@ -1367,13 +1430,16 @@ class ExtensionManager:
|
||||
)
|
||||
# Do not overwrite user-customized skills, but allow dev-mode
|
||||
# symlinks that point back to this extension's generated cache
|
||||
# to be refreshed on a subsequent dev install.
|
||||
if not is_expected_dev_symlink:
|
||||
# to be refreshed on a subsequent dev install. In the upgrade
|
||||
# path (force=True) the file was just written by setup(), so
|
||||
# overwriting it with the composed extension content is correct.
|
||||
if not is_expected_dev_symlink and not force:
|
||||
continue
|
||||
elif skill_dir_preexists:
|
||||
elif skill_dir_preexists and not force:
|
||||
# Never add files to a pre-existing user directory. Without a
|
||||
# verifiable SKILL.md ownership marker, rollback/removal cannot
|
||||
# distinguish our output from unrelated user artifacts.
|
||||
# Skipped when force=True (upgrade path).
|
||||
continue
|
||||
|
||||
# Create skill directory; track whether we created it so we can clean
|
||||
@@ -2612,7 +2678,7 @@ class ExtensionManager:
|
||||
if updates:
|
||||
self.registry.update(ext_id, updates)
|
||||
|
||||
def register_enabled_extensions_for_agent(self, agent_name: str) -> None:
|
||||
def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool = False) -> None:
|
||||
"""Register installed, enabled extensions for ``agent_name``.
|
||||
|
||||
Command-file registration is scoped to the explicit ``agent_name``
|
||||
@@ -2730,7 +2796,7 @@ class ExtensionManager:
|
||||
if agent_name == active_agent:
|
||||
try:
|
||||
registered_skills = self._register_extension_skills(
|
||||
manifest, ext_dir
|
||||
manifest, ext_dir, force=force
|
||||
)
|
||||
except Exception as skills_err:
|
||||
# Skills are a companion artifact. If command registration
|
||||
@@ -3829,10 +3895,8 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
|
||||
def clear_cache(self):
|
||||
"""Clear the catalog cache (both legacy and URL-hash-based files)."""
|
||||
if self.cache_file.exists():
|
||||
self.cache_file.unlink()
|
||||
if self.cache_metadata_file.exists():
|
||||
self.cache_metadata_file.unlink()
|
||||
self.cache_file.unlink(missing_ok=True)
|
||||
self.cache_metadata_file.unlink(missing_ok=True)
|
||||
# Also clear any per-URL hash-based cache files
|
||||
if self.cache_dir.exists():
|
||||
for extra_cache in self.cache_dir.glob("catalog-*.json"):
|
||||
|
||||
@@ -71,6 +71,30 @@ def _display_project_path(*args, **kwargs):
|
||||
return _f(*args, **kwargs)
|
||||
|
||||
|
||||
def _refresh_events_and_warn(project_root: Path) -> None:
|
||||
"""Refresh native event config and surface failures (R3).
|
||||
|
||||
The extension has already been added/removed/enabled/disabled by the time
|
||||
this runs, so a refresh failure must not abort the command — but it must
|
||||
be surfaced, because a stale native hook may still be active (e.g. a
|
||||
disabled extension's hook still resolves and runs). Prints a warning with
|
||||
the per-integration failures so the user knows deactivation was incomplete.
|
||||
"""
|
||||
from ..events import EventRefreshError, refresh_integration_events
|
||||
|
||||
try:
|
||||
refresh_integration_events(project_root)
|
||||
except EventRefreshError as exc:
|
||||
console.print(
|
||||
f"\n[yellow]⚠[/yellow] Extension updated, but event refresh failed "
|
||||
f"for {len(exc.failures)} integration(s); a stale native hook may "
|
||||
f"still be active. Re-run [cyan]specify integration upgrade "
|
||||
f"<key>[cyan][/cyan][/cyan] to retry."
|
||||
)
|
||||
for key, detail in exc.failures:
|
||||
console.print(f" {key}: {_escape_markup(detail)}")
|
||||
|
||||
|
||||
def _load_catalog_command_config(project_root: Path, config_path: Path) -> dict:
|
||||
"""Load extension catalog CLI config with user-facing shape errors."""
|
||||
try:
|
||||
@@ -653,6 +677,10 @@ def extension_add(
|
||||
console.print(f"\n[bold]{_escape_markup(str(manifest.name))}[/bold] (v{_escape_markup(str(manifest.version))})")
|
||||
console.print(f" {_escape_markup(str(manifest.description))}")
|
||||
|
||||
# #1: regenerate native event config for installed event-capable
|
||||
# integrations so the new extension's events take effect immediately.
|
||||
_refresh_events_and_warn(project_root)
|
||||
|
||||
for warning in manifest.warnings:
|
||||
console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {_escape_markup(str(warning))}")
|
||||
|
||||
@@ -759,6 +787,10 @@ def extension_remove(
|
||||
console.print(f"\nConfig files preserved in .specify/extensions/{safe_extension_id}/")
|
||||
else:
|
||||
console.print(f"\nConfig files backed up to .specify/extensions/.backup/{safe_extension_id}/")
|
||||
|
||||
# #1: regenerate native event config so the removed extension's events
|
||||
# are stripped from installed integrations.
|
||||
_refresh_events_and_warn(project_root)
|
||||
console.print(f"\nTo reinstall: specify extension add {safe_extension_id}")
|
||||
else:
|
||||
console.print("[red]Error:[/red] Failed to remove extension")
|
||||
@@ -2126,6 +2158,13 @@ def extension_update(
|
||||
console.print(f" • {_escape_markup(str(ext_name))}: {_escape_markup(str(error))}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# S4: regenerate native event config after a successful update. An
|
||||
# update replaces the installed extension.yml, so any added/removed/
|
||||
# changed event declarations would otherwise leave native configs
|
||||
# stale until a manual integration upgrade.
|
||||
if updated_extensions:
|
||||
_refresh_events_and_warn(project_root)
|
||||
|
||||
except ValidationError as e:
|
||||
console.print(f"\n[red]Validation Error:[/red] {_escape_markup(str(e))}")
|
||||
raise typer.Exit(1)
|
||||
@@ -2175,6 +2214,10 @@ def extension_enable(
|
||||
|
||||
console.print(f"[green]✓[/green] Extension '{_escape_markup(str(display_name))}' enabled")
|
||||
|
||||
# #1: regenerate native event config so the enabled extension's events
|
||||
# are re-emitted in installed integrations.
|
||||
_refresh_events_and_warn(project_root)
|
||||
|
||||
|
||||
@extension_app.command("disable")
|
||||
def extension_disable(
|
||||
@@ -2219,6 +2262,10 @@ def extension_disable(
|
||||
console.print("\nCommands will no longer be available. Hooks will not execute.")
|
||||
console.print(f"To re-enable: specify extension enable {_escape_markup(str(extension_id))}")
|
||||
|
||||
# #1: regenerate native event config so the disabled extension's events
|
||||
# are stripped from installed integrations.
|
||||
_refresh_events_and_warn(project_root)
|
||||
|
||||
|
||||
@extension_app.command("set-priority")
|
||||
def extension_set_priority(
|
||||
|
||||
@@ -121,8 +121,7 @@ def _clear_init_options_for_integration(project_root: Path, integration_key: str
|
||||
def _remove_integration_json(project_root: Path) -> None:
|
||||
"""Remove ``.specify/integration.json`` if it exists."""
|
||||
path = project_root / INTEGRATION_JSON
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -395,6 +394,7 @@ def _register_extensions_for_agent(
|
||||
agent_key: str,
|
||||
*,
|
||||
continuing: str,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
"""Register all enabled extensions' commands/skills for ``agent_key``.
|
||||
|
||||
@@ -408,6 +408,11 @@ def _register_extensions_for_agent(
|
||||
before registering), so extension *skill* rendering — which is scoped to
|
||||
the active ``ai`` / ``ai_skills`` init-options — matches ``agent_key``.
|
||||
|
||||
When ``force=True``, existing skill files are overwritten even when they
|
||||
are not dev-mode symlinks. Pass ``force=True`` in the upgrade path so that
|
||||
extension content is layered on top of the core-template files that
|
||||
``setup()`` just regenerated (fixes the skip-guard bug for skills mode).
|
||||
|
||||
Best-effort: never aborts the surrounding integration operation. Callers
|
||||
invoke it *after* the use/upgrade/switch transaction has committed so a
|
||||
failure here cannot trigger a rollback.
|
||||
@@ -415,7 +420,7 @@ def _register_extensions_for_agent(
|
||||
_best_effort_extension_op(
|
||||
project_root,
|
||||
agent_key,
|
||||
lambda mgr, key: mgr.register_enabled_extensions_for_agent(key),
|
||||
lambda mgr, key: mgr.register_enabled_extensions_for_agent(key, force=force),
|
||||
phase="register extension artifacts for",
|
||||
continuing=continuing,
|
||||
)
|
||||
|
||||
@@ -143,12 +143,21 @@ def integration_install(
|
||||
integration.key, project_root, version=_get_speckit_version()
|
||||
)
|
||||
|
||||
from ..events import resolve_events
|
||||
events_map = resolve_events(
|
||||
integration.key,
|
||||
integration.config,
|
||||
project_root,
|
||||
parsed_options,
|
||||
)
|
||||
|
||||
try:
|
||||
integration.setup(
|
||||
project_root, manifest,
|
||||
parsed_options=parsed_options,
|
||||
script_type=selected_script,
|
||||
raw_options=raw_options,
|
||||
events=events_map,
|
||||
)
|
||||
manifest.save()
|
||||
new_installed = _dedupe_integration_keys([*installed_keys, integration.key])
|
||||
|
||||
@@ -466,12 +466,20 @@ def integration_switch(
|
||||
target_integration.key, project_root, version=_get_speckit_version()
|
||||
)
|
||||
|
||||
from ..events import resolve_events
|
||||
events_map = resolve_events(
|
||||
target_integration.key,
|
||||
target_integration.config,
|
||||
project_root,
|
||||
parsed_options,
|
||||
)
|
||||
try:
|
||||
target_integration.setup(
|
||||
project_root, manifest,
|
||||
parsed_options=parsed_options,
|
||||
script_type=selected_script,
|
||||
raw_options=raw_options,
|
||||
events=events_map,
|
||||
)
|
||||
manifest.save()
|
||||
_set_default_integration(
|
||||
@@ -763,6 +771,13 @@ def integration_upgrade(
|
||||
console.print(f"Upgrading integration: [cyan]{key}[/cyan]")
|
||||
new_manifest = IntegrationManifest(key, project_root, version=_get_speckit_version())
|
||||
|
||||
from ..events import resolve_events
|
||||
events_map = resolve_events(
|
||||
key,
|
||||
integration.config,
|
||||
project_root,
|
||||
parsed_options,
|
||||
)
|
||||
try:
|
||||
integration.setup(
|
||||
project_root,
|
||||
@@ -770,6 +785,7 @@ def integration_upgrade(
|
||||
parsed_options=parsed_options,
|
||||
script_type=selected_script,
|
||||
raw_options=raw_options,
|
||||
events=events_map,
|
||||
)
|
||||
settings = _with_integration_setting(
|
||||
current,
|
||||
@@ -870,6 +886,7 @@ def integration_upgrade(
|
||||
_register_extensions_for_agent(
|
||||
project_root,
|
||||
key,
|
||||
force=True,
|
||||
continuing="The integration was upgraded, but installed extensions may need re-registration.",
|
||||
)
|
||||
_register_presets_for_agent(
|
||||
|
||||
@@ -30,6 +30,7 @@ import yaml
|
||||
from .._invocation_style import get_invocation_prefix, is_dollar_skills_agent
|
||||
from .._toml_string import escape_toml_basic as _escape_toml_basic
|
||||
from .._toml_string import has_illegal_toml_control as _has_illegal_toml_control
|
||||
from ..events import install_integration_events, remove_integration_events
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .manifest import IntegrationManifest
|
||||
@@ -159,7 +160,17 @@ class IntegrationBase(ABC):
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
"""Return options this integration accepts. Default: none."""
|
||||
return []
|
||||
opts = []
|
||||
if bool(getattr(cls, "CANONICAL_TO_NATIVE", None) and getattr(cls, "events_config_file", None)):
|
||||
opts.append(
|
||||
IntegrationOption(
|
||||
"--events",
|
||||
is_flag=False,
|
||||
default="true",
|
||||
help="Enable/disable runtime events (true|false, default: true)",
|
||||
)
|
||||
)
|
||||
return opts
|
||||
|
||||
def effective_invoke_separator(
|
||||
self,
|
||||
@@ -480,7 +491,11 @@ class IntegrationBase(ABC):
|
||||
tracking) would otherwise be deleted even though they are still
|
||||
managed. Subclasses list such paths here to protect them.
|
||||
"""
|
||||
return set()
|
||||
exclusions = set()
|
||||
if self.supports_events():
|
||||
from ..events import events_stale_exclusions
|
||||
exclusions.update(events_stale_exclusions(self.key))
|
||||
return exclusions
|
||||
|
||||
def commands_dest(self, project_root: Path) -> Path:
|
||||
"""Return the absolute path to the commands output directory.
|
||||
@@ -916,8 +931,32 @@ class IntegrationBase(ABC):
|
||||
|
||||
Returns ``(removed, skipped)`` file lists.
|
||||
"""
|
||||
self.remove_events(project_root, manifest)
|
||||
return manifest.uninstall(project_root, force=force)
|
||||
|
||||
def emit_events(
|
||||
self,
|
||||
project_root: Path,
|
||||
manifest: IntegrationManifest,
|
||||
events: dict[str, dict[str, Any]] | None = None,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
**opts: Any,
|
||||
) -> list[Path]:
|
||||
"""Emit native event configuration for this integration."""
|
||||
return install_integration_events(self, project_root, manifest, events or {})
|
||||
|
||||
def remove_events(
|
||||
self,
|
||||
project_root: Path,
|
||||
manifest: IntegrationManifest,
|
||||
) -> None:
|
||||
"""Remove Specify-authored event entries from native config."""
|
||||
remove_integration_events(self, project_root, manifest)
|
||||
|
||||
def supports_events(self) -> bool:
|
||||
"""Return True if this integration supports agent-native events."""
|
||||
return bool(getattr(self, "CANONICAL_TO_NATIVE", None) and getattr(self, "events_config_file", None))
|
||||
|
||||
# -- Convenience helpers for subclasses -------------------------------
|
||||
|
||||
def install(
|
||||
@@ -1022,6 +1061,12 @@ class MarkdownIntegration(IntegrationBase):
|
||||
created.append(dst_file)
|
||||
|
||||
|
||||
# Install agent runtime events
|
||||
event_files = self.emit_events(
|
||||
project_root, manifest, events=opts.get("events"), parsed_options=parsed_options
|
||||
)
|
||||
created.extend(event_files)
|
||||
|
||||
return created
|
||||
|
||||
|
||||
@@ -1229,6 +1274,12 @@ class TomlIntegration(IntegrationBase):
|
||||
created.append(dst_file)
|
||||
|
||||
|
||||
# Install agent runtime events
|
||||
event_files = self.emit_events(
|
||||
project_root, manifest, events=opts.get("events"), parsed_options=parsed_options
|
||||
)
|
||||
created.extend(event_files)
|
||||
|
||||
return created
|
||||
|
||||
|
||||
@@ -1465,6 +1516,12 @@ class YamlIntegration(IntegrationBase):
|
||||
created.append(dst_file)
|
||||
|
||||
|
||||
# Install agent runtime events
|
||||
event_files = self.emit_events(
|
||||
project_root, manifest, events=opts.get("events"), parsed_options=parsed_options
|
||||
)
|
||||
created.extend(event_files)
|
||||
|
||||
return created
|
||||
|
||||
|
||||
@@ -1741,4 +1798,10 @@ class SkillsIntegration(IntegrationBase):
|
||||
created.append(dst)
|
||||
|
||||
|
||||
# Install agent runtime events
|
||||
event_files = self.emit_events(
|
||||
project_root, manifest, events=opts.get("events"), parsed_options=parsed_options
|
||||
)
|
||||
created.extend(event_files)
|
||||
|
||||
return created
|
||||
|
||||
@@ -54,6 +54,17 @@ class ClaudeIntegration(SkillsIntegration):
|
||||
}
|
||||
multi_install_safe = True
|
||||
|
||||
CANONICAL_TO_NATIVE = {
|
||||
"session_start": "SessionStart",
|
||||
"pre_tool_use": "PreToolUse",
|
||||
"post_tool_use": "PostToolUse",
|
||||
"session_end": "SessionEnd",
|
||||
"user_prompt_submit": "UserPromptSubmit",
|
||||
"stop": "Stop",
|
||||
}
|
||||
events_config_file = ".claude/settings.json"
|
||||
events_format = "json-nested"
|
||||
|
||||
@staticmethod
|
||||
def inject_argument_hint(content: str, hint: str) -> str:
|
||||
"""Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter.
|
||||
|
||||
@@ -29,6 +29,17 @@ class CodexIntegration(SkillsIntegration):
|
||||
dev_no_symlink = True
|
||||
multi_install_safe = True
|
||||
|
||||
CANONICAL_TO_NATIVE = {
|
||||
"session_start": "SessionStart",
|
||||
"pre_tool_use": "PreToolUse",
|
||||
"post_tool_use": "PostToolUse",
|
||||
"session_end": "SessionEnd",
|
||||
"user_prompt_submit": "UserPromptSubmit",
|
||||
"stop": "Stop",
|
||||
}
|
||||
events_config_file = ".codex/config.toml"
|
||||
events_format = "toml"
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
prompt: str,
|
||||
@@ -49,11 +60,13 @@ class CodexIntegration(SkillsIntegration):
|
||||
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
return [
|
||||
opts = super().options()
|
||||
opts.append(
|
||||
IntegrationOption(
|
||||
"--skills",
|
||||
is_flag=True,
|
||||
default=True,
|
||||
help="Install as agent skills (default for Codex)",
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
return opts
|
||||
|
||||
@@ -118,6 +118,19 @@ class CopilotIntegration(IntegrationBase):
|
||||
"extension": ".agent.md",
|
||||
}
|
||||
|
||||
CANONICAL_TO_NATIVE = {
|
||||
"session_start": "sessionStart",
|
||||
"pre_tool_use": "preToolUse",
|
||||
"post_tool_use": "postToolUse",
|
||||
"session_end": "sessionEnd",
|
||||
"user_prompt_submit": "userPromptSubmitted",
|
||||
# Copilot CLI supports the canonical per-turn stop lifecycle as native
|
||||
# agentStop (U3); mapping it so an extension's stop handler fires.
|
||||
"stop": "agentStop",
|
||||
}
|
||||
events_config_file = ".github/hooks/speckit.json"
|
||||
events_format = "copilot-json"
|
||||
|
||||
# Mutable flag set by setup() — indicates the active scaffolding mode.
|
||||
_skills_mode: bool = False
|
||||
|
||||
@@ -162,14 +175,19 @@ class CopilotIntegration(IntegrationBase):
|
||||
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
return [
|
||||
# Compose with super() so the base class declares --events for this
|
||||
# event-capable integration; otherwise --integration-options
|
||||
# "--events false" is rejected as unknown (#9).
|
||||
opts = super().options()
|
||||
opts.append(
|
||||
IntegrationOption(
|
||||
"--skills",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Scaffold commands as agent skills (speckit-<name>/SKILL.md) instead of .agent.md files",
|
||||
),
|
||||
]
|
||||
)
|
||||
return opts
|
||||
|
||||
def _resolve_executable(self) -> str:
|
||||
"""Return the Copilot CLI executable, respecting the env-var override.
|
||||
@@ -328,7 +346,9 @@ class CopilotIntegration(IntegrationBase):
|
||||
be flagged stale and deleted, destroying user settings (and the file
|
||||
the integration still manages).
|
||||
"""
|
||||
return {".vscode/settings.json"}
|
||||
exclusions = super().stale_cleanup_exclusions()
|
||||
exclusions.add(".vscode/settings.json")
|
||||
return exclusions
|
||||
|
||||
def post_process_skill_content(self, content: str) -> str:
|
||||
"""Inject shared hook guidance into Copilot skill content.
|
||||
@@ -355,10 +375,18 @@ class CopilotIntegration(IntegrationBase):
|
||||
parsed_options = parsed_options or {}
|
||||
self._skills_mode = bool(parsed_options.get("skills"))
|
||||
if self._skills_mode:
|
||||
return self._setup_skills(project_root, manifest, parsed_options, **opts)
|
||||
if "skills" not in parsed_options:
|
||||
_warn_legacy_markdown_default()
|
||||
return self._setup_default(project_root, manifest, parsed_options, **opts)
|
||||
created = self._setup_skills(project_root, manifest, parsed_options, **opts)
|
||||
else:
|
||||
if "skills" not in parsed_options:
|
||||
_warn_legacy_markdown_default()
|
||||
created = self._setup_default(project_root, manifest, parsed_options, **opts)
|
||||
|
||||
# Install agent runtime events
|
||||
event_files = self.emit_events(
|
||||
project_root, manifest, events=opts.get("events"), parsed_options=parsed_options
|
||||
)
|
||||
created.extend(event_files)
|
||||
return created
|
||||
|
||||
def _setup_default(
|
||||
self,
|
||||
@@ -497,7 +525,7 @@ class CopilotIntegration(IntegrationBase):
|
||||
"""
|
||||
try:
|
||||
existing = json.loads(dst.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
except (json.JSONDecodeError, UnicodeDecodeError, OSError):
|
||||
# Cannot parse existing file (likely JSONC with comments).
|
||||
# Skip merge to preserve the user's settings, but show
|
||||
# what they should add manually.
|
||||
|
||||
@@ -38,6 +38,17 @@ class CursorAgentIntegration(SkillsIntegration):
|
||||
|
||||
multi_install_safe = True
|
||||
|
||||
CANONICAL_TO_NATIVE = {
|
||||
"session_start": "sessionStart",
|
||||
"pre_tool_use": "preToolUse",
|
||||
"post_tool_use": "postToolUse",
|
||||
"session_end": "sessionEnd",
|
||||
"user_prompt_submit": "beforeSubmitPrompt",
|
||||
"stop": "stop",
|
||||
}
|
||||
events_config_file = ".cursor/hooks.json"
|
||||
events_format = "json-flat"
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
prompt: str,
|
||||
@@ -92,11 +103,13 @@ class CursorAgentIntegration(SkillsIntegration):
|
||||
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
return [
|
||||
opts = super().options()
|
||||
opts.append(
|
||||
IntegrationOption(
|
||||
"--skills",
|
||||
is_flag=True,
|
||||
default=True,
|
||||
help="Install as agent skills (recommended for Cursor)",
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
return opts
|
||||
|
||||
@@ -31,6 +31,20 @@ class DevinIntegration(SkillsIntegration):
|
||||
"extension": "/SKILL.md",
|
||||
}
|
||||
|
||||
CANONICAL_TO_NATIVE = {
|
||||
"session_start": "SessionStart",
|
||||
"pre_tool_use": "PreToolUse",
|
||||
"post_tool_use": "PostToolUse",
|
||||
"session_end": "SessionEnd",
|
||||
"user_prompt_submit": "UserPromptSubmit",
|
||||
"stop": "Stop",
|
||||
}
|
||||
events_config_file = ".devin/hooks.v1.json"
|
||||
# Devin's hooks.v1.json is a root event map ({"PreToolUse": [...]}) with no
|
||||
# top-level "hooks" wrapper (U2), unlike the settings.json formats. The
|
||||
# json-root-nested writer/remover operate directly on the root event keys.
|
||||
events_format = "json-root-nested"
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
prompt: str,
|
||||
@@ -55,11 +69,16 @@ class DevinIntegration(SkillsIntegration):
|
||||
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
return [
|
||||
# Compose with super() so the base class declares --events for this
|
||||
# event-capable integration; otherwise --integration-options
|
||||
# "--events false" is rejected as unknown (#8).
|
||||
opts = super().options()
|
||||
opts.append(
|
||||
IntegrationOption(
|
||||
"--skills",
|
||||
is_flag=True,
|
||||
default=True,
|
||||
help="Install as agent skills (default for Devin)",
|
||||
),
|
||||
]
|
||||
)
|
||||
return opts
|
||||
|
||||
@@ -19,3 +19,22 @@ class GeminiIntegration(TomlIntegration):
|
||||
"extension": ".toml",
|
||||
}
|
||||
multi_install_safe = True
|
||||
|
||||
CANONICAL_TO_NATIVE = {
|
||||
"session_start": "SessionStart",
|
||||
"pre_tool_use": "BeforeTool",
|
||||
"post_tool_use": "AfterTool",
|
||||
"session_end": "SessionEnd",
|
||||
# Gemini exposes BeforeAgent for the per-turn prompt-submit lifecycle
|
||||
# point (S6); its own Claude-hook migration maps UserPromptSubmit to
|
||||
# BeforeAgent. Mapping it so extension handlers fire.
|
||||
"user_prompt_submit": "BeforeAgent",
|
||||
"stop": "AfterAgent",
|
||||
}
|
||||
events_config_file = ".gemini/settings.json"
|
||||
events_format = "json-nested"
|
||||
# Gemini measures hook timeouts in milliseconds, unlike Claude/Cursor/Codex
|
||||
# which use seconds. The shared formatter converts via _native_timeout (#7)
|
||||
# so the default 60s becomes 60000ms instead of terminating the dispatcher
|
||||
# after 60ms.
|
||||
events_timeout_unit = "ms"
|
||||
|
||||
@@ -400,7 +400,19 @@ class IntegrationManifest:
|
||||
# Remove the manifest file itself
|
||||
manifest = root / ".specify" / "integrations" / f"{self.key}.manifest.json"
|
||||
if remove_manifest and manifest.exists():
|
||||
manifest.unlink()
|
||||
try:
|
||||
manifest.unlink()
|
||||
except OSError:
|
||||
# An undeletable manifest (read-only file, a directory left at
|
||||
# the path, a Windows lock) must not abort the uninstall after
|
||||
# the tracked files were already removed: the caller would lose
|
||||
# the (removed, skipped) result and never run its post-uninstall
|
||||
# bookkeeping. Report it like any other file we could not
|
||||
# remove, mirroring the path.unlink() guard above. The
|
||||
# empty-parent cleanup below is left unconditional: with the
|
||||
# manifest still on disk its parent is non-empty, so the first
|
||||
# rmdir() raises and breaks immediately.
|
||||
skipped.append(manifest)
|
||||
parent = manifest.parent
|
||||
while parent != root:
|
||||
try:
|
||||
|
||||
@@ -20,6 +20,15 @@ class OpencodeIntegration(MarkdownIntegration):
|
||||
"extension": ".md",
|
||||
}
|
||||
|
||||
CANONICAL_TO_NATIVE = {
|
||||
"pre_tool_use": "tool.execute.before",
|
||||
"post_tool_use": "tool.execute.after",
|
||||
"session_start": "session.created",
|
||||
"session_end": "session.deleted",
|
||||
}
|
||||
events_config_file = "opencode.json"
|
||||
events_format = "ts-plugin"
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
prompt: str,
|
||||
|
||||
@@ -19,3 +19,20 @@ class QwenIntegration(MarkdownIntegration):
|
||||
"extension": ".md",
|
||||
}
|
||||
multi_install_safe = True
|
||||
|
||||
CANONICAL_TO_NATIVE = {
|
||||
"session_start": "SessionStart",
|
||||
"pre_tool_use": "PreToolUse",
|
||||
"post_tool_use": "PostToolUse",
|
||||
"session_end": "SessionEnd",
|
||||
"user_prompt_submit": "UserPromptSubmit",
|
||||
"stop": "Stop",
|
||||
}
|
||||
events_config_file = ".qwen/settings.json"
|
||||
events_format = "json-nested"
|
||||
# Qwen Code's command hooks measure timeout in milliseconds (default
|
||||
# 60000), per the Qwen Code hooks documentation. Declaring the unit makes
|
||||
# the shared formatter convert the 60s default to 60000ms instead of
|
||||
# emitting timeout: 60 (60 ms), which would terminate the dispatcher
|
||||
# before it starts (U1).
|
||||
events_timeout_unit = "ms"
|
||||
|
||||
@@ -19,3 +19,23 @@ class TabnineIntegration(TomlIntegration):
|
||||
"extension": ".toml",
|
||||
}
|
||||
multi_install_safe = True
|
||||
|
||||
CANONICAL_TO_NATIVE = {
|
||||
"session_start": "SessionStart",
|
||||
"pre_tool_use": "BeforeTool",
|
||||
"post_tool_use": "AfterTool",
|
||||
"session_end": "SessionEnd",
|
||||
# Tabnine's Gemini-compatible schema also provides BeforeAgent and
|
||||
# AfterAgent (S7); mapping them so user_prompt_submit and stop
|
||||
# extension handlers fire instead of being skipped.
|
||||
"user_prompt_submit": "BeforeAgent",
|
||||
"stop": "AfterAgent",
|
||||
}
|
||||
events_config_file = ".tabnine/agent/settings.json"
|
||||
events_format = "json-nested"
|
||||
# Tabnine mirrors Gemini's hook schema (BeforeTool/AfterTool) and, like
|
||||
# Gemini, measures hook timeouts in milliseconds. Declaring the unit makes
|
||||
# the shared formatter convert the 60s default to 60000ms instead of
|
||||
# emitting timeout: 60 (60 ms), which would terminate the dispatcher
|
||||
# before it starts (R5).
|
||||
events_timeout_unit = "ms"
|
||||
|
||||
@@ -481,7 +481,7 @@ class PresetRegistry:
|
||||
}
|
||||
|
||||
try:
|
||||
with open(self.registry_path, 'r') as f:
|
||||
with open(self.registry_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
# Validate loaded data is a dict (handles corrupted registry files)
|
||||
if not isinstance(data, dict):
|
||||
@@ -502,7 +502,7 @@ class PresetRegistry:
|
||||
def _save(self):
|
||||
"""Save registry to disk."""
|
||||
self.packs_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.registry_path, 'w') as f:
|
||||
with open(self.registry_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.data, f, indent=2)
|
||||
|
||||
def add(self, pack_id: str, metadata: dict):
|
||||
|
||||
@@ -59,8 +59,11 @@ def preset_list():
|
||||
for pack in installed:
|
||||
status = "[green]enabled[/green]" if pack.get("enabled", True) else "[red]disabled[/red]"
|
||||
pri = pack.get('priority', 10)
|
||||
console.print(f" [bold]{pack['name']}[/bold] ({pack['id']}) v{pack['version']} — {status} — priority {pri}")
|
||||
console.print(f" {pack['description']}")
|
||||
name = _escape_markup(str(pack['name']))
|
||||
pack_id = _escape_markup(str(pack['id']))
|
||||
version = _escape_markup(str(pack['version']))
|
||||
console.print(f" [bold]{name}[/bold] ({pack_id}) v{version} — {status} — priority {pri}")
|
||||
console.print(f" {_escape_markup(str(pack['description']))}")
|
||||
tags = pack.get("tags", [])
|
||||
if isinstance(tags, list) and tags:
|
||||
tags_str = _escape_markup(", ".join(str(t) for t in tags))
|
||||
@@ -317,13 +320,20 @@ def preset_resolve(
|
||||
project_root = _require_specify_project()
|
||||
resolver = PresetResolver(project_root)
|
||||
layers = resolver.collect_all_layers(template_name)
|
||||
safe_template_name = _escape_markup(str(template_name))
|
||||
|
||||
if layers:
|
||||
# Use the highest-priority layer for display because the final output
|
||||
# may be composed and may not map to resolve_with_source()'s single path.
|
||||
display_layer = layers[0]
|
||||
console.print(f" [bold]{template_name}[/bold]: {display_layer['path']}")
|
||||
console.print(f" [dim](top layer from: {display_layer['source']})[/dim]")
|
||||
console.print(
|
||||
f" [bold]{safe_template_name}[/bold]: "
|
||||
f"{_escape_markup(str(display_layer['path']))}"
|
||||
)
|
||||
console.print(
|
||||
f" [dim](top layer from: "
|
||||
f"{_escape_markup(str(display_layer['source']))})[/dim]"
|
||||
)
|
||||
|
||||
has_composition = (
|
||||
layers[0]["strategy"] != "replace"
|
||||
@@ -335,7 +345,10 @@ def preset_resolve(
|
||||
composed = resolver.resolve_content(template_name)
|
||||
except Exception as exc:
|
||||
composed = None
|
||||
console.print(f" [yellow]Warning: composition error: {exc}[/yellow]")
|
||||
console.print(
|
||||
f" [yellow]Warning: composition error: "
|
||||
f"{_escape_markup(str(exc))}[/yellow]"
|
||||
)
|
||||
if composed is None:
|
||||
console.print(" [yellow]Warning: composition cannot produce output (no base layer with 'replace' strategy)[/yellow]")
|
||||
else:
|
||||
@@ -358,15 +371,27 @@ def preset_resolve(
|
||||
strategy_label = layer["strategy"]
|
||||
if strategy_label == "replace" and i == 0:
|
||||
strategy_label = "base"
|
||||
console.print(f" {i + 1}. [{strategy_label}] {layer['source']} → {layer['path']}")
|
||||
# Escape the literal bracket (\[) so Rich renders `[<strategy>]`
|
||||
# instead of parsing it as a style tag and swallowing the label,
|
||||
# mirroring `workflow info`'s step-graph line.
|
||||
console.print(
|
||||
f" {i + 1}. \\[{_escape_markup(str(strategy_label))}] "
|
||||
f"{_escape_markup(str(layer['source']))} → "
|
||||
f"{_escape_markup(str(layer['path']))}"
|
||||
)
|
||||
else:
|
||||
# No layers found — fall back to resolve_with_source for non-composition cases
|
||||
result = resolver.resolve_with_source(template_name)
|
||||
if result:
|
||||
console.print(f" [bold]{template_name}[/bold]: {result['path']}")
|
||||
console.print(f" [dim](from: {result['source']})[/dim]")
|
||||
console.print(
|
||||
f" [bold]{safe_template_name}[/bold]: "
|
||||
f"{_escape_markup(str(result['path']))}"
|
||||
)
|
||||
console.print(
|
||||
f" [dim](from: {_escape_markup(str(result['source']))})[/dim]"
|
||||
)
|
||||
else:
|
||||
console.print(f" [yellow]{template_name}[/yellow]: not found")
|
||||
console.print(f" [yellow]{safe_template_name}[/yellow]: not found")
|
||||
console.print(" [dim]No template with this name exists in the resolution stack[/dim]")
|
||||
|
||||
|
||||
@@ -386,24 +411,32 @@ def preset_info(
|
||||
local_pack = manager.get_pack(preset_id)
|
||||
|
||||
if local_pack:
|
||||
console.print(f"\n[bold cyan]Preset: {local_pack.name}[/bold cyan]\n")
|
||||
console.print(f" ID: {local_pack.id}")
|
||||
console.print(f" Version: {local_pack.version}")
|
||||
console.print(f" Description: {local_pack.description}")
|
||||
console.print(
|
||||
f"\n[bold cyan]Preset: {_escape_markup(str(local_pack.name))}[/bold cyan]\n"
|
||||
)
|
||||
console.print(f" ID: {_escape_markup(str(local_pack.id))}")
|
||||
console.print(f" Version: {_escape_markup(str(local_pack.version))}")
|
||||
console.print(
|
||||
f" Description: {_escape_markup(str(local_pack.description))}"
|
||||
)
|
||||
if local_pack.author:
|
||||
console.print(f" Author: {local_pack.author}")
|
||||
console.print(f" Author: {_escape_markup(str(local_pack.author))}")
|
||||
local_tags = local_pack.tags
|
||||
if isinstance(local_tags, list) and local_tags:
|
||||
console.print(f" Tags: {', '.join(str(t) for t in local_tags)}")
|
||||
tags_str = _escape_markup(", ".join(str(t) for t in local_tags))
|
||||
console.print(f" Tags: {tags_str}")
|
||||
console.print(f" Templates: {len(local_pack.templates)}")
|
||||
for tmpl in local_pack.templates:
|
||||
console.print(f" - {tmpl['name']} ({tmpl['type']}): {tmpl.get('description', '')}")
|
||||
tmpl_name = _escape_markup(str(tmpl['name']))
|
||||
tmpl_type = _escape_markup(str(tmpl['type']))
|
||||
tmpl_desc = _escape_markup(str(tmpl.get('description', '')))
|
||||
console.print(f" - {tmpl_name} ({tmpl_type}): {tmpl_desc}")
|
||||
repo = local_pack.data.get("preset", {}).get("repository")
|
||||
if repo:
|
||||
console.print(f" Repository: {repo}")
|
||||
console.print(f" Repository: {_escape_markup(str(repo))}")
|
||||
license_val = local_pack.data.get("preset", {}).get("license")
|
||||
if license_val:
|
||||
console.print(f" License: {license_val}")
|
||||
console.print(f" License: {_escape_markup(str(license_val))}")
|
||||
console.print("\n [green]Status: installed[/green]")
|
||||
# Get priority from registry
|
||||
pack_metadata = manager.registry.get(preset_id)
|
||||
@@ -438,7 +471,8 @@ def preset_info(
|
||||
)
|
||||
catalog_tags = pack_info.get("tags", [])
|
||||
if isinstance(catalog_tags, list) and catalog_tags:
|
||||
console.print(f" Tags: {', '.join(str(t) for t in catalog_tags)}")
|
||||
catalog_tags_str = _escape_markup(", ".join(str(t) for t in catalog_tags))
|
||||
console.print(f" Tags: {catalog_tags_str}")
|
||||
if pack_info.get("repository"):
|
||||
console.print(
|
||||
f" Repository: {_escape_markup(str(pack_info['repository']))}"
|
||||
@@ -683,10 +717,15 @@ def preset_catalog_add(
|
||||
console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Only rendering is escaped — the raw values are what get persisted and
|
||||
# compared below, so a name containing markup still round-trips exactly.
|
||||
safe_name = _escape_markup(str(name))
|
||||
safe_url = _escape_markup(str(url))
|
||||
|
||||
# Check for duplicate name
|
||||
for existing in catalogs:
|
||||
if isinstance(existing, dict) and existing.get("name") == name:
|
||||
console.print(f"[yellow]Warning:[/yellow] A catalog named '{name}' already exists.")
|
||||
console.print(f"[yellow]Warning:[/yellow] A catalog named '{safe_name}' already exists.")
|
||||
console.print("Use 'specify preset catalog remove' first, or choose a different name.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
@@ -702,10 +741,11 @@ def preset_catalog_add(
|
||||
config_path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
||||
|
||||
install_label = "install allowed" if install_allowed else "discovery only"
|
||||
console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})")
|
||||
console.print(f" URL: {url}")
|
||||
console.print(f"\n[green]✓[/green] Added catalog '[bold]{safe_name}[/bold]' ({install_label})")
|
||||
console.print(f" URL: {safe_url}")
|
||||
console.print(f" Priority: {priority}")
|
||||
console.print(f"\nConfig saved to {_display_project_path(project_root, config_path)}")
|
||||
config_label = _escape_markup(str(_display_project_path(project_root, config_path)))
|
||||
console.print(f"\nConfig saved to {config_label}")
|
||||
|
||||
|
||||
@preset_catalog_app.command("remove")
|
||||
@@ -733,17 +773,20 @@ def preset_catalog_remove(
|
||||
if not isinstance(catalogs, list):
|
||||
console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
|
||||
raise typer.Exit(1)
|
||||
# Rendering only — the raw name drives the comparison below.
|
||||
safe_name = _escape_markup(str(name))
|
||||
|
||||
original_count = len(catalogs)
|
||||
catalogs = [c for c in catalogs if isinstance(c, dict) and c.get("name") != name]
|
||||
|
||||
if len(catalogs) == original_count:
|
||||
console.print(f"[red]Error:[/red] Catalog '{name}' not found.")
|
||||
console.print(f"[red]Error:[/red] Catalog '{safe_name}' not found.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
config["catalogs"] = catalogs
|
||||
config_path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
||||
|
||||
console.print(f"[green]✓[/green] Removed catalog '{name}'")
|
||||
console.print(f"[green]✓[/green] Removed catalog '{safe_name}'")
|
||||
if not catalogs:
|
||||
console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]")
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -88,6 +89,15 @@ class PromptStep(StepBase):
|
||||
),
|
||||
)
|
||||
|
||||
# An invalid timeout reaches subprocess.run() and raises a raw
|
||||
# TypeError ("unsupported operand type(s) for +: 'float' and 'str'")
|
||||
# or ValueError, which the engine re-raises — taking down the whole
|
||||
# run with a message that names neither the step nor 'timeout'. Fail
|
||||
# this step cleanly instead, mirroring the shell step.
|
||||
timeout_error = self._timeout_error(config)
|
||||
if timeout_error is not None:
|
||||
return StepResult(status=StepStatus.FAILED, error=timeout_error)
|
||||
|
||||
# Attempt CLI dispatch
|
||||
timeout = config.get("timeout", 300)
|
||||
dispatch_result = self._try_dispatch(
|
||||
@@ -131,6 +141,41 @@ class PromptStep(StepBase):
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _timeout_error(config: dict[str, Any]) -> str | None:
|
||||
"""Return an error message if ``config['timeout']`` is invalid, else None.
|
||||
|
||||
Shared by execute() and validate() so both paths reject the same
|
||||
values with the same message, mirroring the shell step. An absent
|
||||
``timeout`` is valid (the default is used). bool is a subclass of int,
|
||||
but ``timeout: true`` is a config error rather than a duration, so it
|
||||
is rejected explicitly. Non-finite floats (YAML ``.inf``/``.nan``) pass
|
||||
a plain ``> 0`` check but would raise in subprocess.run(), and a
|
||||
non-positive timeout makes subprocess.run() report an immediate
|
||||
TimeoutExpired, so both are rejected too.
|
||||
"""
|
||||
if "timeout" not in config:
|
||||
return None
|
||||
timeout = config["timeout"]
|
||||
try:
|
||||
valid_timeout = (
|
||||
not isinstance(timeout, bool)
|
||||
and isinstance(timeout, (int, float))
|
||||
and timeout > 0
|
||||
and math.isfinite(timeout)
|
||||
)
|
||||
except OverflowError:
|
||||
# An int too large to convert to float (e.g. a 400-digit YAML
|
||||
# scalar) clears every clause above and raises here — and would
|
||||
# raise the same from subprocess.run(timeout=...).
|
||||
valid_timeout = False
|
||||
if not valid_timeout:
|
||||
return (
|
||||
f"Prompt step {config.get('id', '?')!r}: 'timeout' must be a "
|
||||
f"positive number of seconds, got {timeout!r}."
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _try_dispatch(
|
||||
prompt: str,
|
||||
@@ -169,6 +214,17 @@ class PromptStep(StepBase):
|
||||
if not exec_args:
|
||||
return None
|
||||
|
||||
# Windows: ``subprocess.run`` calls ``CreateProcess``, which does not
|
||||
# consult ``PATHEXT``, so a bare command name like ``claude`` installed
|
||||
# as ``claude.cmd`` (the usual npm shim layout) fails with
|
||||
# ``WinError 2``. That OSError is swallowed below and reported as "CLI
|
||||
# not found or not installed" -- even though the preflight above just
|
||||
# found it. Reuse the already-resolved path so the shim is executed,
|
||||
# mirroring ``IntegrationBase.dispatch_command``, which the ``command``
|
||||
# step already goes through. On POSIX this is the same executable.
|
||||
if fallback_cli_path:
|
||||
exec_args = [fallback_cli_path, *exec_args[1:]]
|
||||
|
||||
import subprocess
|
||||
|
||||
project_root = (
|
||||
@@ -239,4 +295,7 @@ class PromptStep(StepBase):
|
||||
f"Prompt step {config.get('id', '?')!r}: 'model' must be a "
|
||||
f"string, got {type(model).__name__}."
|
||||
)
|
||||
timeout_error = self._timeout_error(config)
|
||||
if timeout_error is not None:
|
||||
errors.append(timeout_error)
|
||||
return errors
|
||||
|
||||
@@ -121,12 +121,20 @@ class ShellStep(StepBase):
|
||||
if "timeout" not in config:
|
||||
return None
|
||||
timeout = config["timeout"]
|
||||
if (
|
||||
isinstance(timeout, bool)
|
||||
or not isinstance(timeout, (int, float))
|
||||
or not math.isfinite(timeout)
|
||||
or timeout <= 0
|
||||
):
|
||||
try:
|
||||
invalid_timeout = (
|
||||
isinstance(timeout, bool)
|
||||
or not isinstance(timeout, (int, float))
|
||||
or not math.isfinite(timeout)
|
||||
or timeout <= 0
|
||||
)
|
||||
except OverflowError:
|
||||
# An int too large to convert to float (e.g. a 400-digit YAML
|
||||
# scalar) is not a bool and *is* an int, so it clears every clause
|
||||
# before ``isfinite()`` and raises there — and would raise the same
|
||||
# from subprocess.run(timeout=...). Mirrors the prompt step.
|
||||
invalid_timeout = True
|
||||
if invalid_timeout:
|
||||
return (
|
||||
f"Shell step {config.get('id', '?')!r}: 'timeout' must be a "
|
||||
f"positive number of seconds, got {timeout!r}."
|
||||
|
||||
@@ -26,6 +26,45 @@ def test_missing_required_field_is_reported_by_name():
|
||||
assert any("bundle.license" in e for e in errors)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field", ["name", "role", "description", "author", "license"]
|
||||
)
|
||||
def test_explicit_null_bundle_field_is_reported_as_missing(field):
|
||||
"""A field present but null is how YAML spells an empty value (`author:`).
|
||||
|
||||
`str(None)` is the literal text "None", which is non-empty, so it passed the
|
||||
required-field checks: the bundle validated clean and shipped "None" as its
|
||||
author/license/description.
|
||||
"""
|
||||
data = valid_manifest_dict()
|
||||
data["bundle"][field] = None
|
||||
manifest = BundleManifest.from_dict(data)
|
||||
assert getattr(manifest.bundle, field) == ""
|
||||
assert any(f"bundle.{field}" in e for e in manifest.structural_errors())
|
||||
|
||||
|
||||
def test_explicit_null_speckit_version_is_reported_as_missing():
|
||||
data = valid_manifest_dict()
|
||||
data["requires"]["speckit_version"] = None
|
||||
manifest = BundleManifest.from_dict(data)
|
||||
assert manifest.requires.speckit_version == ""
|
||||
assert any("speckit_version" in e for e in manifest.structural_errors())
|
||||
|
||||
|
||||
def test_explicit_null_component_id_is_not_named_none():
|
||||
"""A null component id must not become a component literally named "None"."""
|
||||
data = valid_manifest_dict()
|
||||
for kind, items in (data.get("provides") or {}).items():
|
||||
if isinstance(items, list) and items and isinstance(items[0], dict):
|
||||
items[0]["id"] = None
|
||||
break
|
||||
else: # pragma: no cover - fixture is expected to provide components
|
||||
pytest.skip("fixture has no component list to null out")
|
||||
manifest = BundleManifest.from_dict(data)
|
||||
assert manifest.components, "fixture is expected to declare components"
|
||||
assert all(ref.id != "None" for ref in manifest.components)
|
||||
|
||||
|
||||
def test_unsupported_schema_version_is_rejected():
|
||||
data = valid_manifest_dict(schema_version="9.9")
|
||||
errors = BundleManifest.from_dict(data).structural_errors()
|
||||
|
||||
2226
tests/integrations/test_events.py
Normal file
2226
tests/integrations/test_events.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -109,6 +109,21 @@ class TestCopilotIntegration:
|
||||
assert settings not in created
|
||||
assert not any("settings.json" in k for k in m.files)
|
||||
|
||||
def test_setup_preserves_non_utf8_vscode_settings(self, tmp_path, caplog):
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
vscode_dir = tmp_path / ".vscode"
|
||||
vscode_dir.mkdir(parents=True)
|
||||
settings = vscode_dir / "settings.json"
|
||||
original = b'{"editor.fontSize": 14}\xff'
|
||||
settings.write_bytes(original)
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
|
||||
copilot.setup(tmp_path, m)
|
||||
|
||||
assert settings.read_bytes() == original
|
||||
assert "Could not parse" in caplog.text
|
||||
|
||||
def test_all_created_files_tracked_in_manifest(self, tmp_path):
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
|
||||
@@ -3831,6 +3831,68 @@ class TestIntegrationUpgrade:
|
||||
"upgrade of the active integration re-registers extension commands"
|
||||
)
|
||||
|
||||
def test_upgrade_copilot_skills_restores_extension_skill_over_regenerated_dir(
|
||||
self, tmp_path
|
||||
):
|
||||
"""End-to-end regression for #3849 (upgrade-overwrites-copilot-skills).
|
||||
|
||||
In Copilot skills mode, ``integration upgrade`` runs ``setup()`` — which
|
||||
regenerates the core-template skill directories — *before* re-registering
|
||||
installed extensions. The extension re-registration then hits the
|
||||
``skill_dir_preexists`` guard in ``_register_extension_skills`` (the skill
|
||||
sub-directory exists, courtesy of ``setup()``, but its ``SKILL.md`` has
|
||||
not been rewritten with extension content), so pre-fix the extension
|
||||
skill was silently left missing — its command content lost even though the
|
||||
extension remained installed and registered.
|
||||
|
||||
The fix threads ``force=True`` from ``integration_upgrade()`` down to
|
||||
``_register_extension_skills`` so the guard is bypassed and the extension
|
||||
content is re-composed on top of the just-regenerated directory. This test
|
||||
exercises the full ``specify integration upgrade`` command path and fails
|
||||
without the fix (the skill is never recreated).
|
||||
"""
|
||||
project = _init_project(
|
||||
tmp_path, "copilot", integration_options="--skills"
|
||||
)
|
||||
|
||||
result = _run_in_project(project, ["extension", "add", "git"])
|
||||
assert result.exit_code == 0, f"extension add failed: {result.output}"
|
||||
|
||||
skill_dir = project / ".github" / "skills" / "speckit-git-feature"
|
||||
skill_file = skill_dir / "SKILL.md"
|
||||
assert skill_file.exists(), (
|
||||
"precondition: git extension renders as a Copilot skill"
|
||||
)
|
||||
original = skill_file.read_text(encoding="utf-8")
|
||||
assert "source: extension:git" in original, (
|
||||
"precondition: skill carries the git extension ownership marker"
|
||||
)
|
||||
|
||||
# Simulate the exact pre-condition the bug depends on: the skill file is
|
||||
# gone but its directory survives (as it does once setup() regenerates the
|
||||
# core-template layout during upgrade), triggering the skill_dir_preexists
|
||||
# skip guard on re-registration.
|
||||
skill_file.unlink()
|
||||
assert skill_dir.exists() and not skill_file.exists()
|
||||
|
||||
result = _run_in_project(project, [
|
||||
"integration", "upgrade", "copilot",
|
||||
"--integration-options", "--skills",
|
||||
"--script", "sh", "--force",
|
||||
])
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
assert skill_file.exists(), (
|
||||
"upgrade must restore the extension skill even when its directory "
|
||||
"already exists (regression #3849)"
|
||||
)
|
||||
restored = skill_file.read_text(encoding="utf-8")
|
||||
assert "source: extension:git" in restored, (
|
||||
"restored skill must contain the git extension content, not a bare "
|
||||
"core-template stub"
|
||||
)
|
||||
assert "# Git Feature Skill" in restored
|
||||
|
||||
def test_upgrade_active_integration_reregisters_presets(self, tmp_path):
|
||||
"""Upgrading the active integration restores missing preset artifacts."""
|
||||
import yaml
|
||||
|
||||
@@ -242,6 +242,34 @@ class TestManifestUninstall:
|
||||
"remove_manifest=False must keep the manifest file on disk"
|
||||
)
|
||||
|
||||
def test_undeletable_manifest_is_skipped_not_raised(self, tmp_path):
|
||||
"""An undeletable manifest must not abort the whole uninstall.
|
||||
|
||||
The tracked files are removed *before* the manifest, so raising here
|
||||
loses the ``(removed, skipped)`` result the caller needs: the CLI's
|
||||
post-uninstall bookkeeping (reassigning the default integration,
|
||||
rewriting/removing ``integration.json``, clearing init options) never
|
||||
runs, leaving a removed integration still recorded as installed.
|
||||
|
||||
Leaving a directory at the manifest path is a portable way to make
|
||||
``unlink()`` fail with no chmod and no monkeypatch: it raises
|
||||
``IsADirectoryError`` on Linux and ``PermissionError`` on
|
||||
Windows/macOS, both ``OSError`` subclasses.
|
||||
"""
|
||||
m = IntegrationManifest("test", tmp_path, version="1.0")
|
||||
m.record_file("f.txt", "content")
|
||||
m.save()
|
||||
m.manifest_path.unlink()
|
||||
m.manifest_path.mkdir()
|
||||
|
||||
removed, skipped = m.uninstall()
|
||||
|
||||
assert removed == [tmp_path / "f.txt"]
|
||||
assert not (tmp_path / "f.txt").exists()
|
||||
assert m.manifest_path in skipped, (
|
||||
"an undeletable manifest must be reported in skipped"
|
||||
)
|
||||
|
||||
def test_cleans_empty_parent_dirs(self, tmp_path):
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_file("a/b/c/f.txt", "content")
|
||||
|
||||
@@ -2943,6 +2943,135 @@ class TestExtensionSkillRegistration:
|
||||
assert "speckit-early-ext-world" in metadata["registered_skills"]
|
||||
|
||||
|
||||
|
||||
# ===== Regression test: upgrade-overwrites-copilot-skills (#3849) =====
|
||||
|
||||
class TestRegisterExtensionSkillsForceFlag:
|
||||
"""Regression tests for the ``force`` flag on ``_register_extension_skills``.
|
||||
|
||||
Issue #3849: ``integration upgrade --force`` called ``setup()`` which
|
||||
regenerated all core-template SKILL.md files, then called
|
||||
``register_enabled_extensions_for_agent()``. The skip-guard in
|
||||
``_register_extension_skills`` treated the freshly-written core files as
|
||||
existing user content and skipped every extension skill, leaving only core
|
||||
template content on disk.
|
||||
|
||||
The fix introduces ``force=True`` in the upgrade path so the guard does not
|
||||
fire for core-template files that setup() just wrote.
|
||||
"""
|
||||
|
||||
def test_force_false_skips_existing_skill(self, project_dir, temp_dir):
|
||||
"""Without force=True the skip guard must still protect existing files."""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
||||
skills_dir = _create_skills_dir(project_dir, ai="claude")
|
||||
ext_dir = _create_extension_dir(temp_dir)
|
||||
|
||||
# Manually pre-create a SKILL.md as if setup() had already written it
|
||||
skill_subdir = skills_dir / "speckit-test-ext-hello"
|
||||
skill_subdir.mkdir(parents=True, exist_ok=True)
|
||||
skill_file = skill_subdir / "SKILL.md"
|
||||
skill_file.write_text("core-template content only", encoding="utf-8")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
|
||||
# Default (force=False): existing file must not be overwritten
|
||||
written = manager._register_extension_skills(manifest, ext_dir, force=False)
|
||||
assert "speckit-test-ext-hello" not in written
|
||||
assert skill_file.read_text(encoding="utf-8") == "core-template content only"
|
||||
|
||||
def test_force_true_overwrites_existing_skill(self, project_dir, temp_dir):
|
||||
"""With force=True the function must overwrite the existing SKILL.md.
|
||||
|
||||
This is the core regression test for #3849: calling
|
||||
``_register_extension_skills(force=True)`` after ``setup()`` has
|
||||
written a fresh core-template SKILL.md must replace it with the
|
||||
composed extension content.
|
||||
"""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
||||
skills_dir = _create_skills_dir(project_dir, ai="claude")
|
||||
ext_dir = _create_extension_dir(temp_dir)
|
||||
|
||||
# Simulate what setup() writes: a bare core-template SKILL.md
|
||||
skill_subdir = skills_dir / "speckit-test-ext-hello"
|
||||
skill_subdir.mkdir(parents=True, exist_ok=True)
|
||||
skill_file = skill_subdir / "SKILL.md"
|
||||
skill_file.write_text("core-template content only", encoding="utf-8")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
|
||||
# Upgrade path (force=True): extension content should replace the core file
|
||||
written = manager._register_extension_skills(manifest, ext_dir, force=True)
|
||||
assert "speckit-test-ext-hello" in written, (
|
||||
"force=True should overwrite the core-template file and return the skill name"
|
||||
)
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
assert "Run this to say hello." in content, (
|
||||
"Extension command body must appear in the overwritten SKILL.md"
|
||||
)
|
||||
assert "core-template content only" not in content, (
|
||||
"Core-template placeholder must have been replaced by extension content"
|
||||
)
|
||||
|
||||
def test_register_enabled_extensions_for_agent_force_flag_threads_through(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""force=True on register_enabled_extensions_for_agent must reach _register_extension_skills.
|
||||
|
||||
End-to-end check: after an upgrade writes a fresh core-template SKILL.md,
|
||||
``register_enabled_extensions_for_agent(force=True)`` must produce a
|
||||
SKILL.md that contains the extension content.
|
||||
"""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
||||
skills_dir = _create_skills_dir(project_dir, ai="claude")
|
||||
ext_dir = _create_extension_dir(temp_dir)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
# Install extension so it is in the registry
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
# Simulate a freshly-regenerated core-template SKILL.md (as setup() would write)
|
||||
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
|
||||
skill_file.write_text("core-template content only", encoding="utf-8")
|
||||
|
||||
# Re-register with force=True (upgrade path)
|
||||
manager.register_enabled_extensions_for_agent("claude", force=True)
|
||||
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
assert "Run this to say hello." in content, (
|
||||
"After register_enabled_extensions_for_agent(force=True), the SKILL.md "
|
||||
"must contain the extension body, not just the core-template stub."
|
||||
)
|
||||
|
||||
def test_force_true_with_preexisting_dir_but_no_skill_file(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""force=True must write into a pre-existing directory with no SKILL.md.
|
||||
|
||||
The second skip guard (``elif skill_dir_preexists``) should also be
|
||||
bypassed by force=True so an upgrade can create a missing SKILL.md
|
||||
even when the skill sub-directory already exists.
|
||||
"""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
||||
skills_dir = _create_skills_dir(project_dir, ai="claude")
|
||||
ext_dir = _create_extension_dir(temp_dir)
|
||||
|
||||
# Create the skill directory without the SKILL.md file
|
||||
skill_subdir = skills_dir / "speckit-test-ext-hello"
|
||||
skill_subdir.mkdir(parents=True, exist_ok=True)
|
||||
skill_file = skill_subdir / "SKILL.md"
|
||||
assert not skill_file.exists()
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
|
||||
written = manager._register_extension_skills(manifest, ext_dir, force=True)
|
||||
assert "speckit-test-ext-hello" in written
|
||||
assert skill_file.exists()
|
||||
assert "Run this to say hello." in skill_file.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ===== Extension Skill Unregistration Tests =====
|
||||
|
||||
class TestExtensionSkillUnregistration:
|
||||
|
||||
@@ -576,7 +576,7 @@ class TestExtensionManifest:
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
|
||||
with pytest.raises(ValidationError, match="must provide at least one command or hook"):
|
||||
with pytest.raises(ValidationError, match="must provide at least one command, hook, or event"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_hooks_only_extension(self, temp_dir, valid_manifest_data):
|
||||
@@ -614,6 +614,67 @@ class TestExtensionManifest:
|
||||
with pytest.raises(ValidationError, match="Invalid provides.commands"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
@pytest.mark.parametrize("section", ["extension", "requires", "provides"])
|
||||
@pytest.mark.parametrize("bad", [None, [], "text"])
|
||||
def test_required_section_not_mapping_rejected(
|
||||
self, temp_dir, valid_manifest_data, section, bad
|
||||
):
|
||||
"""A required section that is written but empty or wrongly shaped must
|
||||
raise ValidationError, not a raw TypeError/AttributeError.
|
||||
|
||||
REQUIRED_FIELDS only checks key presence, so `provides:` with no value
|
||||
passed it and then hit `None.get(...)`. That AttributeError escaped
|
||||
list_installed()'s ValidationError-only "Corrupted extension" fallback,
|
||||
so one bad extension made `specify extension list` exit 1 instead of
|
||||
listing the others.
|
||||
"""
|
||||
import yaml
|
||||
|
||||
valid_manifest_data[section] = 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 {section}"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_empty_provides_mapping_is_still_accepted_with_hooks(
|
||||
self, temp_dir, valid_manifest_data
|
||||
):
|
||||
"""Regression guard: `provides: {}` is a well-SHAPED mapping, so the new
|
||||
shape check must not reject it — an extension may provide only hooks."""
|
||||
import yaml
|
||||
|
||||
valid_manifest_data["provides"] = {}
|
||||
assert valid_manifest_data.get("hooks"), "fixture is expected to define hooks"
|
||||
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
|
||||
ExtensionManifest(manifest_path) # must not raise
|
||||
|
||||
def test_empty_provides_and_no_hooks_keeps_its_own_message(
|
||||
self, temp_dir, valid_manifest_data
|
||||
):
|
||||
"""...and with no hooks (or events) either, it reports the "nothing
|
||||
provided" message rather than the new shape error."""
|
||||
import yaml
|
||||
|
||||
valid_manifest_data["provides"] = {}
|
||||
valid_manifest_data.pop("hooks", None)
|
||||
valid_manifest_data.pop("events", None)
|
||||
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
|
||||
with pytest.raises(
|
||||
ValidationError, match="at least one command, hook, or event"
|
||||
):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_hooks_not_dict_rejected(self, temp_dir, valid_manifest_data):
|
||||
"""Test manifest with hooks as a list is rejected."""
|
||||
import yaml
|
||||
@@ -2662,6 +2723,36 @@ Real body starts here.
|
||||
|
||||
assert parsed["description"] == "first line\nsecond line\n"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("description", "expected"),
|
||||
[
|
||||
(None, ""), # "description:" with no value
|
||||
(42, "42"), # unquoted number
|
||||
(True, "True"), # unquoted boolean
|
||||
(["a", "b"], "['a', 'b']"), # was silently concatenated to "ab"
|
||||
],
|
||||
)
|
||||
def test_render_toml_command_coerces_non_string_description(
|
||||
self, description, expected
|
||||
):
|
||||
"""Frontmatter comes from yaml.safe_load, so description can be any type.
|
||||
|
||||
_render_basic_toml_string iterates the value and calls ord() per
|
||||
character, so a non-string raised a raw TypeError and a list of
|
||||
single-character items was silently concatenated into a wrong value.
|
||||
render_yaml_command (same class) already coerces; this brings the TOML
|
||||
branch to parity.
|
||||
"""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
|
||||
registrar = AgentCommandRegistrar()
|
||||
output = registrar.render_toml_command(
|
||||
{"description": description}, "body", "extension:test-ext"
|
||||
)
|
||||
|
||||
parsed = tomllib.loads(output)
|
||||
assert parsed["description"] == expected
|
||||
|
||||
def test_render_toml_command_escapes_control_characters(self):
|
||||
"""Control characters and a lone CR must be escaped so the TOML parses.
|
||||
|
||||
|
||||
@@ -2826,6 +2826,80 @@ class TestPresetCatalogMultiCatalog:
|
||||
assert "https://example.com/[cat].json" in result.output
|
||||
assert "desc [with] brackets" in result.output
|
||||
|
||||
def test_catalog_add_escapes_rich_markup(self, project_dir):
|
||||
"""`preset catalog add` must not parse the name/url as Rich markup.
|
||||
|
||||
An unbalanced closing tag raised MarkupError *after* the entry was
|
||||
already written to preset-catalogs.yml, so the user saw a traceback
|
||||
and no confirmation for a catalog that had in fact been added.
|
||||
"""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
name = "[/red]my-catalog"
|
||||
url = "https://example.com/[bold]c.json"
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(
|
||||
app, ["preset", "catalog", "add", url, "--name", name]
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
# Rendered verbatim, not swallowed as markup.
|
||||
assert name in result.output
|
||||
assert url in result.output
|
||||
# Only rendering is escaped: the raw values still round-trip to disk.
|
||||
config = yaml.safe_load(
|
||||
(project_dir / ".specify" / "preset-catalogs.yml").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
assert config["catalogs"][0]["name"] == name
|
||||
assert config["catalogs"][0]["url"] == url
|
||||
|
||||
def test_catalog_remove_escapes_rich_markup(self, project_dir):
|
||||
"""`preset catalog remove` must not parse the name as Rich markup."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
name = "[/red]my-catalog"
|
||||
(project_dir / ".specify" / "preset-catalogs.yml").write_text(
|
||||
yaml.dump({
|
||||
"catalogs": [
|
||||
{
|
||||
"name": name,
|
||||
"url": "https://example.com/c.json",
|
||||
"priority": 1,
|
||||
"install_allowed": False,
|
||||
}
|
||||
]
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, ["preset", "catalog", "remove", name])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert name in result.output
|
||||
|
||||
def test_catalog_remove_escapes_markup_in_not_found_error(self, project_dir):
|
||||
"""The not-found error path renders the name too."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
(project_dir / ".specify" / "preset-catalogs.yml").write_text(
|
||||
yaml.dump({"catalogs": []}), encoding="utf-8"
|
||||
)
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(
|
||||
app, ["preset", "catalog", "remove", "[/red]absent"]
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "[/red]absent" in result.output
|
||||
|
||||
def test_env_var_overrides_catalogs(self, project_dir, monkeypatch):
|
||||
"""Test that SPECKIT_PRESET_CATALOG_URL env var overrides defaults."""
|
||||
monkeypatch.setenv(
|
||||
@@ -12209,3 +12283,213 @@ class TestPresetCatalogRichMarkup:
|
||||
):
|
||||
value = self.MARKUP_PRESET[field]
|
||||
assert value in output
|
||||
# Tags are joined into a single line, so assert on the rendered join.
|
||||
assert ", ".join(self.MARKUP_PRESET["tags"]) in output
|
||||
|
||||
|
||||
class TestInstalledPresetRichMarkup:
|
||||
"""Locally installed preset metadata must render as literal text.
|
||||
|
||||
``preset.yml`` is user-editable, so its fields can contain ``[...]``.
|
||||
``TestPresetCatalogRichMarkup`` covers the catalog branch of these
|
||||
commands; the installed-preset branch of ``preset list``/``preset info``
|
||||
and all of ``preset resolve`` were left unescaped, so a field like
|
||||
``Does [stuff] nicely`` silently rendered as ``Does nicely`` and an
|
||||
unbalanced tag such as ``[/red]`` raised ``rich.errors.MarkupError``,
|
||||
aborting the command with a traceback.
|
||||
"""
|
||||
|
||||
MARKUP_FIELDS = {
|
||||
"name": "[green]Markup Name[/green]",
|
||||
"version": "1.0.0",
|
||||
"description": "[yellow]Markup Description[/yellow]",
|
||||
"author": "[magenta]Markup Author[/magenta]",
|
||||
"repository": "[bold]Markup Repository[/bold]",
|
||||
"license": "[cyan]Markup License[/cyan]",
|
||||
}
|
||||
|
||||
def _install(self, temp_dir, project_dir, preset_overrides=None, strategy=None,
|
||||
pack_id="markup-pack", priority=10, tmpl_description=None):
|
||||
"""Install a preset from a directory built with the given manifest fields."""
|
||||
from specify_cli.presets import PresetManager
|
||||
|
||||
src = temp_dir / f"src-{pack_id}"
|
||||
(src / "templates").mkdir(parents=True)
|
||||
(src / "templates" / "spec-template.md").write_text("# tmpl\n")
|
||||
|
||||
preset_section = {
|
||||
"id": pack_id,
|
||||
"name": pack_id,
|
||||
"version": "1.0.0",
|
||||
"description": "plain description",
|
||||
}
|
||||
preset_section.update(preset_overrides or {})
|
||||
tmpl = {
|
||||
"type": "template",
|
||||
"name": "spec-template",
|
||||
"file": "templates/spec-template.md",
|
||||
}
|
||||
if tmpl_description is not None:
|
||||
tmpl["description"] = tmpl_description
|
||||
if strategy:
|
||||
tmpl["strategy"] = strategy
|
||||
(src / "preset.yml").write_text(yaml.dump({
|
||||
"schema_version": "1.0",
|
||||
"preset": preset_section,
|
||||
"requires": {"speckit_version": ">=0.0.1"},
|
||||
"provides": {"templates": [tmpl]},
|
||||
"tags": ["[italic]markup-tag[/italic]"],
|
||||
}))
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(src, "9.9.9", priority)
|
||||
return manager
|
||||
|
||||
def _invoke(self, project_dir, args):
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
return CliRunner().invoke(app, args)
|
||||
|
||||
def test_list_and_info_escape_installed_markup(self, temp_dir, project_dir):
|
||||
"""Every ``preset.yml`` field must survive verbatim in list/info output."""
|
||||
self._install(temp_dir, project_dir, preset_overrides=self.MARKUP_FIELDS)
|
||||
|
||||
for args in (["preset", "list"], ["preset", "info", "markup-pack"]):
|
||||
result = self._invoke(project_dir, args)
|
||||
assert result.exit_code == 0, result.output
|
||||
output = " ".join(strip_ansi(result.output).split())
|
||||
# `preset list` does not render repository/license.
|
||||
fields = ("name", "description") if args[1] == "list" else self.MARKUP_FIELDS
|
||||
for field in fields:
|
||||
assert self.MARKUP_FIELDS[field] in output, (field, args, output)
|
||||
assert "[italic]markup-tag[/italic]" in output, (args, output)
|
||||
|
||||
def test_info_does_not_swallow_template_description(self, temp_dir, project_dir):
|
||||
"""The per-template line in ``preset info`` must escape the template description.
|
||||
|
||||
``name``/``type`` are format-restricted by manifest validation, but
|
||||
``description`` is free-form, so it is the field that can carry markup.
|
||||
"""
|
||||
self._install(
|
||||
temp_dir,
|
||||
project_dir,
|
||||
tmpl_description="Template [desc] here",
|
||||
)
|
||||
result = self._invoke(project_dir, ["preset", "info", "markup-pack"])
|
||||
assert result.exit_code == 0, result.output
|
||||
output = " ".join(strip_ansi(result.output).split())
|
||||
assert "spec-template (template): Template [desc] here" in output, output
|
||||
|
||||
def test_unbalanced_markup_does_not_crash_list_or_info(self, temp_dir, project_dir):
|
||||
"""An unbalanced tag must not raise MarkupError and abort the command."""
|
||||
self._install(
|
||||
temp_dir,
|
||||
project_dir,
|
||||
preset_overrides={"description": "Broken [/red] tag"},
|
||||
)
|
||||
|
||||
for args in (["preset", "list"], ["preset", "info", "markup-pack"]):
|
||||
result = self._invoke(project_dir, args)
|
||||
assert result.exit_code == 0, (args, result.output, result.exception)
|
||||
assert "Broken [/red] tag" in strip_ansi(result.output)
|
||||
|
||||
def test_resolve_escapes_template_name(self, project_dir):
|
||||
"""``preset resolve`` echoes its argument; an unbalanced tag must not crash."""
|
||||
result = self._invoke(project_dir, ["preset", "resolve", "no[/red]such"])
|
||||
assert result.exit_code == 0, (result.output, result.exception)
|
||||
assert "no[/red]such" in strip_ansi(result.output)
|
||||
|
||||
def test_resolve_escapes_layer_path_and_source(self, project_dir):
|
||||
"""The top-layer path/source lines must render markup literally.
|
||||
|
||||
A preset can be installed from any directory, so the resolved path can
|
||||
contain ``[...]``; the layer source carries the pack id and version.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
from specify_cli.presets import PresetResolver
|
||||
|
||||
# A closing tag cannot live inside a path segment: `Path` treats its
|
||||
# `/` as a separator on POSIX and rewrites it to `\` on Windows. The
|
||||
# opening tag covers the swallowing case for the path; the unbalanced
|
||||
# closing tag rides on `source`, which is a plain string.
|
||||
layer = {
|
||||
"path": Path("/tmp/[red]dir/spec-template.md"),
|
||||
"source": "pack [/red] v1.0.0",
|
||||
"strategy": "replace",
|
||||
}
|
||||
with patch.object(PresetResolver, "collect_all_layers", return_value=[layer]):
|
||||
result = self._invoke(project_dir, ["preset", "resolve", "spec-template"])
|
||||
|
||||
assert result.exit_code == 0, (result.output, result.exception)
|
||||
output = " ".join(strip_ansi(result.output).split())
|
||||
assert "[red]dir" in output, output
|
||||
assert "pack [/red] v1.0.0" in output, output
|
||||
|
||||
def test_resolve_escapes_fallback_path_and_source(self, project_dir):
|
||||
"""The no-layer fallback branch must escape ``resolve_with_source`` output."""
|
||||
from unittest.mock import patch
|
||||
from specify_cli.presets import PresetResolver
|
||||
|
||||
with patch.object(
|
||||
PresetResolver, "collect_all_layers", return_value=[]
|
||||
), patch.object(
|
||||
PresetResolver,
|
||||
"resolve_with_source",
|
||||
return_value={
|
||||
"path": "/tmp/[blue]fallback[/blue]/spec-template.md",
|
||||
"source": "fallback [/red] source",
|
||||
},
|
||||
):
|
||||
result = self._invoke(project_dir, ["preset", "resolve", "spec-template"])
|
||||
|
||||
assert result.exit_code == 0, (result.output, result.exception)
|
||||
output = " ".join(strip_ansi(result.output).split())
|
||||
assert "[blue]fallback[/blue]" in output, output
|
||||
assert "fallback [/red] source" in output, output
|
||||
|
||||
def test_resolve_escapes_composition_error(self, project_dir):
|
||||
"""A composition exception message must not be parsed as markup."""
|
||||
from unittest.mock import patch
|
||||
from specify_cli.presets import PresetResolver
|
||||
|
||||
layers = [
|
||||
{
|
||||
"path": Path("/tmp/top/spec-template.md"),
|
||||
"source": "top-pack v1.0.0",
|
||||
"strategy": "append",
|
||||
},
|
||||
{
|
||||
"path": Path("/tmp/base/spec-template.md"),
|
||||
"source": "base-pack v1.0.0",
|
||||
"strategy": "append",
|
||||
},
|
||||
]
|
||||
with patch.object(
|
||||
PresetResolver, "collect_all_layers", return_value=layers
|
||||
), patch.object(
|
||||
PresetResolver,
|
||||
"resolve_content",
|
||||
side_effect=RuntimeError("compose failed: [/red] bad layer"),
|
||||
):
|
||||
result = self._invoke(project_dir, ["preset", "resolve", "spec-template"])
|
||||
|
||||
assert result.exit_code == 0, (result.output, result.exception)
|
||||
output = " ".join(strip_ansi(result.output).split())
|
||||
assert "compose failed: [/red] bad layer" in output, output
|
||||
|
||||
def test_resolve_renders_composition_strategy_labels(self, temp_dir, project_dir):
|
||||
"""The composition chain's ``[<strategy>]`` label must not be eaten as a tag."""
|
||||
self._install(temp_dir, project_dir, strategy="replace",
|
||||
pack_id="base-pack", priority=20)
|
||||
self._install(temp_dir, project_dir, strategy="append",
|
||||
pack_id="app-pack", priority=5)
|
||||
|
||||
result = self._invoke(project_dir, ["preset", "resolve", "spec-template"])
|
||||
assert result.exit_code == 0, (result.output, result.exception)
|
||||
output = strip_ansi(result.output)
|
||||
assert "Composition chain" in output, output
|
||||
assert "[base]" in output, output
|
||||
assert "[append]" in output, output
|
||||
|
||||
@@ -30,7 +30,7 @@ LOCAL_REFRESH_TEST_EXTRA_DEPS = (
|
||||
f"--quiet --no-header --output-file {COMMITTED_AUDIT_REQUIREMENTS}"
|
||||
)
|
||||
WORKFLOW_SYNC_COMPILE_TEST_EXTRA_DEPS = (
|
||||
"uv pip compile pyproject.toml --extra test --universal --upgrade --generate-hashes "
|
||||
"uv pip compile pyproject.toml --extra test --universal --generate-hashes "
|
||||
"--quiet --no-header --output-file"
|
||||
)
|
||||
WORKFLOW_SYNC_SCRIPT = "python .github/scripts/check_security_requirements.py"
|
||||
@@ -99,7 +99,9 @@ class TestDependencyAuditWorkflow:
|
||||
assert sync_check["env"]["DEPENDENCY_DIFF_BASE"] == (
|
||||
"${{ github.event.pull_request.base.sha || github.event.before || '' }}"
|
||||
)
|
||||
assert sync_check["env"]["DEPENDENCY_DIFF_HEAD"] == "${{ github.sha }}"
|
||||
assert sync_check["env"]["DEPENDENCY_DIFF_HEAD"] == (
|
||||
"${{ github.event.pull_request.head.sha || github.sha }}"
|
||||
)
|
||||
assert sync_check["run"] == WORKFLOW_SYNC_SCRIPT
|
||||
assert committed_audit["run"] == LOCAL_PIP_AUDIT
|
||||
|
||||
@@ -239,10 +241,14 @@ class TestDependencyAuditWorkflow:
|
||||
|
||||
def test_sync_script_skips_when_dependency_inputs_are_unchanged(self, monkeypatch, capsys):
|
||||
sync_script = _load_sync_script()
|
||||
commands = []
|
||||
|
||||
def fake_run(command, **kwargs):
|
||||
commands.append(command)
|
||||
if command[:2] == ["git", "merge-base"]:
|
||||
return subprocess.CompletedProcess(command, 0, stdout="base123\n", stderr="")
|
||||
assert command == [
|
||||
"git", "diff", "--name-only", "HEAD^", "HEAD", "--",
|
||||
"git", "diff", "--name-only", "base123", "HEAD", "--",
|
||||
"pyproject.toml", ".github/security-audit-requirements.txt",
|
||||
]
|
||||
assert kwargs["check"] is True
|
||||
@@ -251,16 +257,21 @@ class TestDependencyAuditWorkflow:
|
||||
monkeypatch.setattr(sync_script.subprocess, "run", fake_run)
|
||||
|
||||
assert sync_script.main() == 0
|
||||
assert commands[0] == ["git", "merge-base", "HEAD^", "HEAD"]
|
||||
assert "sync check skipped" in capsys.readouterr().out
|
||||
|
||||
def test_sync_script_uses_github_diff_refs_when_available(self, monkeypatch):
|
||||
sync_script = _load_sync_script()
|
||||
monkeypatch.setenv("DEPENDENCY_DIFF_BASE", "abc123")
|
||||
monkeypatch.setenv("DEPENDENCY_DIFF_HEAD", "def456")
|
||||
commands = []
|
||||
|
||||
def fake_run(command, **_kwargs):
|
||||
commands.append(command)
|
||||
if command[:2] == ["git", "merge-base"]:
|
||||
return subprocess.CompletedProcess(command, 0, stdout="merge123\n", stderr="")
|
||||
assert command == [
|
||||
"git", "diff", "--name-only", "abc123", "def456", "--",
|
||||
"git", "diff", "--name-only", "merge123", "def456", "--",
|
||||
"pyproject.toml", ".github/security-audit-requirements.txt",
|
||||
]
|
||||
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
|
||||
@@ -268,6 +279,7 @@ class TestDependencyAuditWorkflow:
|
||||
monkeypatch.setattr(sync_script.subprocess, "run", fake_run)
|
||||
|
||||
assert sync_script._dependency_inputs_changed() is False
|
||||
assert commands[0] == ["git", "merge-base", "abc123", "def456"]
|
||||
|
||||
def test_sync_script_compiles_and_compares_when_dependency_inputs_changed(
|
||||
self, monkeypatch, tmp_path
|
||||
@@ -284,10 +296,13 @@ class TestDependencyAuditWorkflow:
|
||||
monkeypatch.setenv("GENERATED_REQUIREMENTS", str(generated_requirements))
|
||||
|
||||
def fake_run(command, **kwargs):
|
||||
if command[0] == "git":
|
||||
if command[:2] == ["git", "merge-base"]:
|
||||
return subprocess.CompletedProcess(command, 0, stdout="base123\n", stderr="")
|
||||
if command[:2] == ["git", "diff"]:
|
||||
return subprocess.CompletedProcess(command, 0, stdout="pyproject.toml\n", stderr="")
|
||||
compile_commands.append(command)
|
||||
assert kwargs["check"] is True
|
||||
assert generated_requirements.read_text(encoding="utf-8") == "pytest==1\n"
|
||||
generated_requirements.write_text("pytest==1\n", encoding="utf-8")
|
||||
return subprocess.CompletedProcess(command, 0)
|
||||
|
||||
@@ -297,6 +312,7 @@ class TestDependencyAuditWorkflow:
|
||||
assert len(compile_commands) == 1
|
||||
compile_command = " ".join(compile_commands[0])
|
||||
assert WORKFLOW_SYNC_COMPILE_TEST_EXTRA_DEPS in compile_command
|
||||
assert "--upgrade" not in compile_commands[0]
|
||||
assert "--output-file" in compile_commands[0]
|
||||
assert str(generated_requirements) in compile_commands[0]
|
||||
|
||||
|
||||
@@ -472,6 +472,26 @@ class TestTagValidation:
|
||||
output = strip_ansi(result.output)
|
||||
assert "Invalid --tag" in output or "expected vMAJOR.MINOR.PATCH" in output
|
||||
|
||||
def test_rejection_message_keeps_the_suffix_token(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
"""Rich must not swallow the literal `[suffix]`.
|
||||
|
||||
Unescaped it is parsed as a style tag and dropped, so the user is told
|
||||
only "expected vMAJOR.MINOR.PATCH" -- implying a bare vX.Y.Z is the only
|
||||
accepted form, when -rc1 / .dev0 / +build.42 are all valid and are
|
||||
documented as such in docs/upgrade.md and README.md.
|
||||
"""
|
||||
result = runner.invoke(app, ["self", "upgrade", "--tag", "latest"])
|
||||
assert result.exit_code == 1
|
||||
assert "expected vMAJOR.MINOR.PATCH[suffix]" in strip_ansi(result.output)
|
||||
|
||||
def test_tag_option_help_keeps_the_suffix_token(self):
|
||||
"""Typer renders option help through Rich, so `--help` dropped it too."""
|
||||
result = runner.invoke(app, ["self", "upgrade", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "[suffix]" in strip_ansi(result.output)
|
||||
|
||||
|
||||
class TestUnknownCurrent:
|
||||
"""'unknown' current version renders literally in notice and success message."""
|
||||
|
||||
@@ -1486,6 +1486,41 @@ class TestPromptStep:
|
||||
assert result.output["dispatched"] is True
|
||||
assert result.output["exit_code"] == 0
|
||||
|
||||
def test_try_dispatch_executes_the_resolved_executable(self, tmp_path):
|
||||
"""argv[0] must be the shutil.which-resolved path, not the bare name.
|
||||
|
||||
On Windows subprocess.run calls CreateProcess, which ignores PATHEXT, so
|
||||
a bare `claude` installed as `claude.cmd` (the usual npm shim) raises
|
||||
WinError 2. That OSError is swallowed and reported as "CLI not found or
|
||||
not installed" even though the preflight which() just found it, while
|
||||
the `command` step -- which goes through
|
||||
IntegrationBase.dispatch_command -- resolves argv[0] and works.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
from specify_cli.workflows.steps.prompt import PromptStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
step = PromptStep()
|
||||
ctx = StepContext(default_integration="claude", project_root=str(tmp_path))
|
||||
config = {"id": "test", "type": "prompt", "prompt": "hello"}
|
||||
|
||||
resolved = r"C:\tools\claude.CMD"
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = ""
|
||||
mock_result.stderr = ""
|
||||
|
||||
with patch(
|
||||
"specify_cli.workflows.steps.prompt.shutil.which",
|
||||
lambda name: resolved,
|
||||
), patch("subprocess.run", return_value=mock_result) as run:
|
||||
result = step.execute(config, ctx)
|
||||
|
||||
assert result.status == StepStatus.COMPLETED
|
||||
assert result.output["dispatched"] is True
|
||||
argv = run.call_args.args[0]
|
||||
assert argv[0] == resolved, argv
|
||||
|
||||
def test_dispatch_with_mock_cli(self, tmp_path):
|
||||
from unittest.mock import patch, MagicMock
|
||||
from specify_cli.workflows.steps.prompt import PromptStep
|
||||
@@ -1670,6 +1705,90 @@ class TestPromptStep:
|
||||
assert res.status is StepStatus.FAILED, falsey
|
||||
assert "'model' must be a string" in (res.error or ""), falsey
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad", ["30", True, float("inf"), float("nan"), 0, -5, ["30"], None, 10**400]
|
||||
)
|
||||
def test_validate_rejects_invalid_timeout(self, bad):
|
||||
"""'timeout' reaches subprocess.run(), so validate() must reject junk.
|
||||
|
||||
The sibling shell step already rejects exactly these values; the
|
||||
prompt step gained a ``timeout`` without the matching guard, so a
|
||||
workflow that fails validation as a shell step passed as a prompt one.
|
||||
|
||||
``10**400`` is an int too large to convert to float: it passes
|
||||
``isinstance``/``> 0`` but makes ``math.isfinite()`` — and later
|
||||
``subprocess.run()`` — raise ``OverflowError``, so the guard has to
|
||||
catch that rather than let it escape as the crash it exists to stop.
|
||||
"""
|
||||
from specify_cli.workflows.steps.prompt import PromptStep
|
||||
|
||||
step = PromptStep()
|
||||
errors = step.validate(
|
||||
{"id": "p", "type": "prompt", "prompt": "hi", "timeout": bad}
|
||||
)
|
||||
assert any("'timeout' must be a positive number" in e for e in errors), (
|
||||
bad,
|
||||
errors,
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("good", [300, 5, 0.5])
|
||||
def test_validate_accepts_valid_timeout(self, good):
|
||||
"""A positive int/float timeout — and an absent one — stay valid."""
|
||||
from specify_cli.workflows.steps.prompt import PromptStep
|
||||
|
||||
step = PromptStep()
|
||||
for config in (
|
||||
{"id": "p", "type": "prompt", "prompt": "hi", "timeout": good},
|
||||
{"id": "p", "type": "prompt", "prompt": "hi"},
|
||||
):
|
||||
errors = step.validate(config)
|
||||
assert not any("'timeout'" in e for e in errors), (config, errors)
|
||||
|
||||
def test_execute_fails_cleanly_on_invalid_timeout(self, monkeypatch):
|
||||
"""execute() must fail the step, not raise, on an invalid timeout.
|
||||
|
||||
The engine does not auto-validate step config and re-raises anything a
|
||||
step throws, so an unvalidated ``timeout`` reaching subprocess.run()
|
||||
raised a raw ``TypeError: unsupported operand type(s) for +: 'float'
|
||||
and 'str'`` (or ``ValueError`` for NaN) that aborted the entire run —
|
||||
naming neither the step nor the field — after earlier steps had
|
||||
already run their side effects.
|
||||
"""
|
||||
import subprocess
|
||||
from unittest.mock import patch
|
||||
|
||||
from specify_cli.workflows.steps.prompt import PromptStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
def fail_if_called(*args, **kwargs):
|
||||
raise AssertionError("subprocess.run should not run on invalid timeout")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fail_if_called)
|
||||
step = PromptStep()
|
||||
ctx = StepContext(inputs={}, default_integration="claude")
|
||||
# A string/list raises TypeError and NaN raises ValueError inside
|
||||
# subprocess.run(); ``True`` would silently become a 1s timeout (bool
|
||||
# is an int subclass); a non-positive value reports an immediate
|
||||
# TimeoutExpired for a command that never got the time to run; an int
|
||||
# too large to convert to float raises OverflowError.
|
||||
for bad in ("30", True, float("nan"), 0, -5, ["30"], 10**400):
|
||||
with patch(
|
||||
"specify_cli.workflows.steps.prompt.shutil.which",
|
||||
return_value="/opt/claude",
|
||||
):
|
||||
result = step.execute(
|
||||
{
|
||||
"id": "p",
|
||||
"type": "prompt",
|
||||
"prompt": "hi",
|
||||
"integration": "claude",
|
||||
"timeout": bad,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
assert result.status is StepStatus.FAILED, bad
|
||||
assert "'timeout' must be a positive number" in (result.error or ""), bad
|
||||
|
||||
|
||||
class TestShellStep:
|
||||
"""Test the shell step type."""
|
||||
@@ -1908,6 +2027,75 @@ class TestShellStep:
|
||||
errors = step.validate({"id": "qa", "run": "echo hi", "timeout": bad})
|
||||
assert any("'timeout' must be a positive number" in e for e in errors)
|
||||
|
||||
def test_validate_rejects_huge_int_timeout(self):
|
||||
"""A too-large-to-convert int must be reported, not raise OverflowError.
|
||||
|
||||
``math.isfinite(10**400)`` raises ``OverflowError: int too large to
|
||||
convert to float``. Such a value is an ``int`` and is not a ``bool``,
|
||||
so it clears every clause before ``isfinite()`` and raises there —
|
||||
escaping ``validate()`` as the uncaught crash this guard exists to
|
||||
prevent. ``specify workflow run`` then aborts with a bare traceback
|
||||
instead of "Workflow validation failed". Both signs reach
|
||||
``isfinite()`` because it is checked before ``timeout <= 0``.
|
||||
``subprocess.run(timeout=...)`` raises the same OverflowError, so the
|
||||
value is genuinely invalid rather than merely unrepresentable here.
|
||||
The prompt step already catches this (PR #3847).
|
||||
"""
|
||||
from specify_cli.workflows.steps.shell import ShellStep
|
||||
|
||||
step = ShellStep()
|
||||
for bad in (10**400, -(10**400)):
|
||||
errors = step.validate({"id": "qa", "run": "echo hi", "timeout": bad})
|
||||
assert any(
|
||||
"'timeout' must be a positive number" in e for e in errors
|
||||
), (bad, errors)
|
||||
|
||||
def test_validate_workflow_reports_huge_int_timeout(self):
|
||||
"""The huge-int timeout surfaces as a validation error end to end.
|
||||
|
||||
``specify workflow run`` calls ``engine.validate()`` before executing
|
||||
any step; an OverflowError escaping the shell step's ``validate()``
|
||||
propagates out of ``validate_workflow`` and kills the command with a
|
||||
traceback, so pin the whole path, not just the helper.
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
|
||||
|
||||
definition = WorkflowDefinition(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "demo", "name": "Demo", "version": "1.0.0"},
|
||||
"steps": [
|
||||
{"id": "qa", "type": "shell", "run": "echo hi", "timeout": 10**400}
|
||||
],
|
||||
}
|
||||
)
|
||||
errors = validate_workflow(definition)
|
||||
assert any("'timeout' must be a positive number" in e for e in errors), errors
|
||||
|
||||
def test_execute_fails_cleanly_on_huge_int_timeout(self, monkeypatch):
|
||||
"""execute() must fail just this step on a huge-int timeout.
|
||||
|
||||
The engine does not auto-validate step config and re-raises anything a
|
||||
step throws, so on an unvalidated run the OverflowError would abort the
|
||||
whole workflow after earlier steps had already run their side effects.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
from specify_cli.workflows.steps.shell import ShellStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
def fail_if_called(*args, **kwargs):
|
||||
raise AssertionError("subprocess.run should not run on invalid timeout")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fail_if_called)
|
||||
step = ShellStep()
|
||||
for bad in (10**400, -(10**400)):
|
||||
result = step.execute(
|
||||
{"id": "qa", "run": "echo hi", "timeout": bad}, StepContext()
|
||||
)
|
||||
assert result.status == StepStatus.FAILED, bad
|
||||
assert "'timeout' must be a positive number" in (result.error or ""), bad
|
||||
|
||||
def test_validate_accepts_positive_numeric_timeout(self):
|
||||
from specify_cli.workflows.steps.shell import ShellStep
|
||||
|
||||
@@ -2491,7 +2679,7 @@ class TestIfThenStep:
|
||||
assert any("missing 'condition'" in e for e in errors)
|
||||
|
||||
@pytest.mark.parametrize("bad", [["a", "b"], {"k": "v"}, 5, 1.5])
|
||||
def test_validate_rejects_non_string_condition(self, bad):
|
||||
def test_validate_rejects_non_string_non_bool_condition(self, bad):
|
||||
# A list/dict/number condition is returned unchanged by
|
||||
# evaluate_expression, and evaluate_condition then bool()-coerces it, so
|
||||
# it silently resolves to its truthiness (e.g. [1, 2] is always True)
|
||||
@@ -2908,7 +3096,7 @@ class TestWhileStep:
|
||||
# max_iterations is optional (defaults to 10)
|
||||
|
||||
@pytest.mark.parametrize("bad", [["a", "b"], {"k": "v"}, 5, 1.5])
|
||||
def test_validate_rejects_non_string_condition(self, bad):
|
||||
def test_validate_rejects_non_string_non_bool_condition(self, bad):
|
||||
from specify_cli.workflows.steps.while_loop import WhileStep
|
||||
|
||||
step = WhileStep()
|
||||
@@ -3040,7 +3228,7 @@ class TestDoWhileStep:
|
||||
# max_iterations is optional (defaults to 10)
|
||||
|
||||
@pytest.mark.parametrize("bad", [["a", "b"], {"k": "v"}, 5, 1.5])
|
||||
def test_validate_rejects_non_string_condition(self, bad):
|
||||
def test_validate_rejects_non_string_non_bool_condition(self, bad):
|
||||
from specify_cli.workflows.steps.do_while import DoWhileStep
|
||||
|
||||
step = DoWhileStep()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-22T00:00:00Z",
|
||||
"updated_at": "2026-07-29T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/catalog.community.json",
|
||||
"workflows": {
|
||||
"pipeline": {
|
||||
@@ -22,6 +22,29 @@
|
||||
],
|
||||
"created_at": "2026-07-10T00:00:00Z",
|
||||
"updated_at": "2026-07-21T00:00:00Z"
|
||||
},
|
||||
"yolo": {
|
||||
"id": "yolo",
|
||||
"name": "Full SDD Cycle - no gates",
|
||||
"description": "Runs specify → plan → tasks → implement without review gates",
|
||||
"author": "clintcparker",
|
||||
"version": "0.1.0",
|
||||
"url": "https://raw.githubusercontent.com/clintcparker/speckit-addons/yolo-v0.1.0/workflows/yolo/workflow.yml",
|
||||
"repository": "https://github.com/clintcparker/speckit-addons",
|
||||
"documentation": "https://github.com/clintcparker/speckit-addons/blob/yolo-v0.1.0/workflows/yolo/README.md",
|
||||
"changelog": "https://github.com/clintcparker/speckit-addons/blob/yolo-v0.1.0/workflows/yolo/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.12"
|
||||
},
|
||||
"tags": [
|
||||
"sdd",
|
||||
"full-cycle",
|
||||
"no-gates",
|
||||
"automation"
|
||||
],
|
||||
"created_at": "2026-07-29T00:00:00Z",
|
||||
"updated_at": "2026-07-29T00:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user