Compare commits

..
1 Commits
Author SHA1 Message Date
github-actions[bot] 5dce710ce0 chore: bump version to 0.16.0 2026-08-05 13:39:31 +00:00
42 changed files with 231 additions and 1685 deletions
+2 -2
View File
@@ -22,11 +22,11 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Initialize CodeQL
uses: github/codeql-action/init@d1ba80a13dd99fba24a470575428917156a28b43 # v4
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
with:
languages: ${{ matrix.language }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@d1ba80a13dd99fba24a470575428917156a28b43 # v4
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
with:
category: "/language:${{ matrix.language }}"
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
fi
- name: Run markdownlint-cli2
uses: DavidAnson/markdownlint-cli2-action@21c1be1b93ad9ed58fa840aacc3f279cde2a72ff # v24.2.0
uses: DavidAnson/markdownlint-cli2-action@6bf21b07787794f89a243495939cd651942aeabe # v24.1.0
with:
globs: |
'**/*.md'
-26
View File
@@ -2,32 +2,6 @@
<!-- insert new changelog below this comment -->
## [0.16.1] - 2026-08-07
### Changed
- fix(integrations): wrap a non-UTF-8 catalog response (#4011)
- fix(events): skip an unreadable command template (#3956)
- fix(bundle): wrap malformed YAML in a local .zip bundle manifest (#4013)
- chore(deps): bump github/codeql-action/analyze from 4.37.3 to 4.37.5 (#4005)
- chore(deps): bump DavidAnson/markdownlint-cli2-action (#4006)
- fix(agent-context): recurse for nested plans in Python mtime fallback (#3757)
- fix: add utf-8 encoding to extension and preset registry file I/O (#3834)
- fix(init): escape user-supplied values in `specify init` output (#3787)
- fix: bound response read in integration catalog fetch (#3812)
- fix: use missing_ok for temp file cleanup to avoid masking errors (#3803)
- fix(workflows): handle an unreadable run state in `workflow status` (#3999)
- feat(init): scaffold managed .specify/.gitignore (#4000)
- fix(scripts): stop check-prerequisites text mode crashing on a legacy stdout code page (#3890)
- fix(presets): return None for an unreadable layer in resolve_content (#3959)
- fix(extensions): start fresh on a non-UTF-8 extension registry (#3998)
- Fix init-force-preset-desync: reapply presets/extensions on init --here --force (#3995)
- fix(skills): apply the line-anchored delimiter scan to hermes and kimi (#3739)
- fix(archives): wrap the bare EOFError a truncated tar.gz raises (#3938)
- test(integrations): guard multiline/control-char SKILL.md frontmatter escaping (#3392)
- fix(scripts): stop setup-tasks text mode crashing on a legacy code page (#3892)
- chore: release 0.16.0, begin 0.16.1.dev0 development (#3992)
## [0.16.0] - 2026-08-05
### Changed
-2
View File
@@ -57,8 +57,6 @@ specify init my-project --integration copilot --preset compliance
> **Two resolution axes.** `SPECIFY_INIT_DIR` selects the **project** (which directory contains `.specify/`); `SPECIFY_FEATURE_DIRECTORY` / `.specify/feature.json` select the **feature** within that project. They are independent — project first, then feature.
> **Version control.** `specify init` scaffolds a managed `.specify/.gitignore` that excludes machine-local state — `feature.json` (the current-feature pointer, rewritten on every feature switch) and per-machine extension `extensions/*/local-config.yml` overrides — while leaving everything else under `.specify/` (constitution, templates, scripts, extension config) shareable so teams stay aligned. Like the rest of `.specify/`'s shared scripts and templates, the file is tracked in the shared-infrastructure manifest: your edits are preserved on re-init and `specify init --here --force` restores the managed content. It is intentionally left in place by `specify integration uninstall`, which only removes the uninstalled agent's own files.
> **Symlinked project roots.** `SPECIFY_INIT_DIR` relocates *where* the project is, not *how* a command treats symlinks: each command keeps its existing cwd-path stance. Commands that traverse and write project files through broad input paths (`bundle`, `workflow run <file>`) refuse a symlinked `.specify/` to preserve write confinement. Other project-scoped commands keep their existing behavior when `SPECIFY_INIT_DIR` points at a project root, which may include following a symlinked `.specify/`.
## Check Installed Tools
@@ -11,8 +11,8 @@ Usage: update_agent_context.py [plan_path]
When ``plan_path`` is omitted, the script derives it from
``.specify/feature.json`` (written by /speckit-specify). Falls back to the most
recently modified ``plan.md`` found anywhere under ``specs/`` — scoped layouts
nest it as ``specs/<scope>/<feature>/plan.md`` — only when feature.json is
recently modified ``plan.md`` anywhere under ``specs/`` (including nested scoped
layouts such as ``specs/<scope>/<feature>/plan.md``) only when feature.json is
absent or its plan does not exist yet.
"""
@@ -173,31 +173,16 @@ def _resolve_plan_path(project_root: str) -> str:
if not plan_path:
root = Path(project_root).resolve()
specs = root / "specs"
def _resolved_rel(p: Path) -> Path | None:
# Resolve symlinks before checking containment: relative_to() is
# lexical and would otherwise accept a plan reached through a specs/
# symlink that points outside the project, emitting an
# in-project-looking path for an out-of-project file (or picking it
# as "most recent").
plans = sorted(
(root / "specs").rglob("plan.md"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
if plans:
try:
return p.resolve().relative_to(root)
except (OSError, ValueError):
return None
# Recurse (rather than the old one-level specs/*/plan.md glob) so scoped
# layouts created via SPECIFY_FEATURE_DIRECTORY, e.g.
# specs/<scope>/<feature>/plan.md, are still discovered when
# feature.json is absent (#3024). Mirrors the bash and PowerShell twins.
candidates = []
for p in specs.rglob("plan.md"):
rel = _resolved_rel(p)
if rel is not None:
candidates.append((p, rel))
candidates.sort(key=lambda pr: pr[0].stat().st_mtime, reverse=True)
if candidates:
plan_path = candidates[0][1].as_posix()
plan_path = plans[0].relative_to(root).as_posix()
except ValueError:
plan_path = ""
return plan_path
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.16.1"
version = "0.16.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"
+4 -21
View File
@@ -130,31 +130,14 @@ def _print_paths_only(paths: FeaturePaths, json_mode: bool) -> None:
print(f"TASKS: {paths.tasks}")
def _status_marker(ok: bool) -> str:
"""Return the status glyph, downgraded to ASCII when stdout cannot encode it.
On Windows sys.stdout falls back to the ANSI code page whenever it is not a
console - a pipe or a file redirect, which is how agents and workflow steps
invoke these scripts - and U+2713 is unencodable in cp1252, so printing it
raised UnicodeEncodeError and aborted the report right after
"AVAILABLE_DOCS:". "[OK]"/"[FAIL]" is the ASCII rendering these markers
already have in-tree: see Test-FileExists in scripts/powershell/common.ps1
and normalize_status_text in tests/parity_helpers.py.
"""
glyph = "✓" if ok else "✗"
try:
glyph.encode(getattr(sys.stdout, "encoding", None) or "utf-8")
except (LookupError, UnicodeEncodeError):
return "[OK]" if ok else "[FAIL]"
return glyph
def _check_file(path: Path, description: str) -> None:
print(f" {_status_marker(path.is_file())} {description}")
marker = "✓" if path.is_file() else "✗"
print(f" {marker} {description}")
def _check_dir(path: Path, description: str) -> None:
print(f" {_status_marker(_dir_has_entries(path))} {description}")
marker = "✓" if _dir_has_entries(path) else "✗"
print(f" {marker} {description}")
def _print_text_results(paths: FeaturePaths, include_tasks: bool) -> None:
+4 -21
View File
@@ -55,31 +55,14 @@ def _available_docs(paths: FeaturePaths) -> list[str]:
return docs
def _status_marker(ok: bool) -> str:
"""Return the status glyph, downgraded to ASCII when stdout cannot encode it.
On Windows sys.stdout falls back to the ANSI code page whenever it is not a
console - a pipe or a file redirect, which is how agents and workflow steps
invoke these scripts - and U+2713 is unencodable in cp1252, so printing it
raised UnicodeEncodeError and aborted the report mid-listing.
"[OK]"/"[FAIL]" is the ASCII rendering these markers already have in-tree:
see Test-FileExists in scripts/powershell/common.ps1 and
normalize_status_text in tests/parity_helpers.py.
"""
glyph = "✓" if ok else "✗"
try:
glyph.encode(getattr(sys.stdout, "encoding", None) or "utf-8")
except (LookupError, UnicodeEncodeError):
return "[OK]" if ok else "[FAIL]"
return glyph
def _check_file(path: Path, description: str) -> None:
print(f" {_status_marker(path.is_file())} {description}")
marker = "✓" if path.is_file() else "✗"
print(f" {marker} {description}")
def _check_dir(path: Path, description: str) -> None:
print(f" {_status_marker(_dir_has_entries(path))} {description}")
marker = "✓" if _dir_has_entries(path) else "✗"
print(f" {marker} {description}")
def main(argv: list[str] | None = None) -> int:
+3 -21
View File
@@ -10,7 +10,6 @@ import struct
import tarfile
import unicodedata
import zipfile
import zlib
from collections.abc import Iterator
from contextlib import ExitStack, contextmanager
from ipaddress import IPv4Address, IPv6Address, ip_address
@@ -70,19 +69,6 @@ _ZIP_MAX_COMMENT_BYTES = (1 << 16) - 1
_BOUNDED_ZIP_COMPRESSION_METHODS = frozenset(
(zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED)
)
#: Decompression failures a truncated or corrupt gzip stream raises from
#: ``tarfile``. Most are wrapped in ``TarError``, but two escape raw, and
#: neither derives from ``TarError`` or ``OSError``, so both bypass a
#: ``(TarError, OSError)`` handler:
#:
#: * ``EOFError`` -- from the gzip layer when the stream ends before its
#: end-of-stream marker, i.e. a truncated archive.
#: * ``zlib.error`` -- from a corrupt deflate block. ``tarfile`` converts this
#: to ``ReadError`` while reading a member *header*, but the forward seek it
#: performs to skip member *data* sits outside that conversion, so a corrupt
#: region past the first header escapes raw.
_TAR_DECOMPRESSION_ERRORS = (tarfile.TarError, EOFError, zlib.error)
_ARCHIVE_CONTENT_TYPES: dict[str, ArchiveFormat] = {
"application/gzip": "tar.gz",
"application/x-gzip": "tar.gz",
@@ -180,11 +166,7 @@ def detect_archive_format(
try:
with tarfile.open(fileobj=archive_file, mode="r:gz"):
is_tar_gz = True
except _TAR_DECOMPRESSION_ERRORS:
# A truncated gzip stream raises a bare EOFError here rather
# than a TarError, so catching only TarError let it escape
# this probe as a raw exception instead of leaving
# ``is_tar_gz`` False and reporting the format mismatch.
except tarfile.TarError:
pass
archive_file.seek(0)
except OSError as exc:
@@ -1095,7 +1077,7 @@ def safe_extract_tar(
mode="r:gz",
fileobj=archive_file,
)
except (*_TAR_DECOMPRESSION_ERRORS, OSError) as exc:
except (tarfile.TarError, OSError) as exc:
_raise_from(error_type, f"Invalid tar.gz archive: {archive_path}", exc)
with archive:
@@ -1167,7 +1149,7 @@ def safe_extract_tar(
f"of {max_total_bytes} bytes",
)
validated.append((member, normalized_name, is_dir))
except (*_TAR_DECOMPRESSION_ERRORS, OSError) as exc:
except (tarfile.TarError, OSError) as exc:
_raise_from(
error_type,
f"Invalid tar.gz archive: {archive_path}",
+2 -2
View File
@@ -192,8 +192,8 @@ def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker
os.replace(temp_path, target_file)
except Exception:
if temp_path:
temp_path.unlink(missing_ok=True)
if temp_path and temp_path.exists():
temp_path.unlink()
raise
try:
+1 -11
View File
@@ -771,17 +771,7 @@ def _local_manifest_source(arg: str):
error_type=BundlerError,
label="bundle manifest",
)
try:
data = _yaml.safe_load(io.BytesIO(raw))
except _yaml.YAMLError as exc:
# The sibling directory/bundle.yml branches reach YAML through
# load_yaml(), which turns a parse failure into a BundlerError. This
# branch parses inline, so without this it raises a raw YAMLError --
# neither a ValueError nor an OSError -- which escapes
# bundle_install()'s `except BundlerError` as a traceback.
raise BundlerError(
f"Invalid YAML in bundle.yml inside '{candidate}': {exc}"
) from exc
data = _yaml.safe_load(io.BytesIO(raw))
return BundleManifest.from_dict(data)
if candidate.name == "bundle.yml" or candidate.suffix in (".yml", ".yaml"):
+16 -64
View File
@@ -3,16 +3,13 @@
from __future__ import annotations
import os
import shlex
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any
import typer
from rich.live import Live
from rich.markup import escape as _escape_markup
from rich.panel import Panel
from .._agent_config import (
@@ -172,25 +169,6 @@ def _install_extension_during_init(project_path: Path, ext_spec: str, speckit_ve
return f"{manifest.name} v{manifest.version} installed"
def _shell_quote_arg(value: str) -> str:
"""Quote *value* as one argument for the shells of the host OS.
The Next Steps ``cd`` line is copy-pasted into whichever shell ran
``specify init``, so it is quoted for the host the same way
``_version._render_argv`` renders its copy-pasteable installer command:
``list2cmdline`` on Windows, ``shlex.quote`` elsewhere. The Windows branch
must emit double quotes -- ``cd 'my project'`` is a path-not-found in
cmd.exe, while ``cd "my project"`` is accepted by cmd.exe, PowerShell and
Git Bash alike. A value needing no quoting is returned unchanged.
Whitespace only. PowerShell also glob-expands ``[``/``]`` and expands
``$``/backtick inside double quotes, so such a name still needs
``Set-Location -LiteralPath`` there -- syntax invalid in cmd.exe and sh, so
this shell-neutral line cannot cover it.
"""
return subprocess.list2cmdline([value]) if os.name == "nt" else shlex.quote(value)
def ensure_constitution_from_template(
project_path: Path, tracker: StepTracker | None = None
) -> None:
@@ -373,10 +351,7 @@ def register(app: typer.Typer) -> None:
if integration:
resolved_integration = get_integration(integration)
if not resolved_integration:
console.print(
f"[red]Error:[/red] Unknown integration: "
f"'{_escape_markup(str(integration))}'"
)
console.print(f"[red]Error:[/red] Unknown integration: '{integration}'")
available = ", ".join(sorted(INTEGRATION_REGISTRY))
console.print(f"[yellow]Available integrations:[/yellow] {available}")
raise typer.Exit(1)
@@ -453,27 +428,26 @@ def register(app: typer.Typer) -> None:
project_path = Path(project_name).resolve()
dir_existed_before = project_path.exists()
if project_path.exists():
safe_name = _escape_markup(str(project_name))
if not project_path.is_dir():
console.print(
f"[red]Error:[/red] '{safe_name}' exists but is not a directory."
f"[red]Error:[/red] '{project_name}' exists but is not a directory."
)
raise typer.Exit(1)
existing_items = list(project_path.iterdir())
if force:
if existing_items:
console.print(
f"[yellow]Warning:[/yellow] Directory '{safe_name}' is not empty ({len(existing_items)} items)"
f"[yellow]Warning:[/yellow] Directory '{project_name}' is not empty ({len(existing_items)} items)"
)
console.print(
"[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]"
)
console.print(
f"[cyan]--force supplied: merging into existing directory '[cyan]{safe_name}[/cyan]'[/cyan]"
f"[cyan]--force supplied: merging into existing directory '[cyan]{project_name}[/cyan]'[/cyan]"
)
else:
error_panel = Panel(
f"Directory already exists: '[cyan]{safe_name}[/cyan]'\n"
f"Directory already exists: '[cyan]{project_name}[/cyan]'\n"
"Please choose a different project name or remove the existing directory.\n"
"Use [bold]--force[/bold] to merge into the existing directory.",
title="[red]Directory Conflict[/red]",
@@ -487,7 +461,7 @@ def register(app: typer.Typer) -> None:
if integration:
if integration not in AGENT_CONFIG:
console.print(
f"[red]Error:[/red] Invalid integration '{_escape_markup(str(integration))}'. Choose from: {', '.join(AGENT_CONFIG.keys())}"
f"[red]Error:[/red] Invalid integration '{integration}'. Choose from: {', '.join(AGENT_CONFIG.keys())}"
)
raise typer.Exit(1)
selected_ai = integration
@@ -526,14 +500,12 @@ def register(app: typer.Typer) -> None:
setup_lines = [
"[cyan]Specify Project Setup[/cyan]",
"",
f"{'Project':<15} [green]{_escape_markup(project_path.name)}[/green]",
f"{'Working Path':<15} [dim]{_escape_markup(str(current_dir))}[/dim]",
f"{'Project':<15} [green]{project_path.name}[/green]",
f"{'Working Path':<15} [dim]{current_dir}[/dim]",
]
if not here:
setup_lines.append(
f"{'Target Path':<15} [dim]{_escape_markup(str(project_path))}[/dim]"
)
setup_lines.append(f"{'Target Path':<15} [dim]{project_path}[/dim]")
console.print(
Panel("\n".join(setup_lines), border_style="cyan", padding=(1, 2))
@@ -560,7 +532,7 @@ def register(app: typer.Typer) -> None:
if script_type:
if script_type not in SCRIPT_TYPE_CHOICES:
console.print(
f"[red]Error:[/red] Invalid script type '{_escape_markup(str(script_type))}'. Choose from: {', '.join(SCRIPT_TYPE_CHOICES.keys())}"
f"[red]Error:[/red] Invalid script type '{script_type}'. Choose from: {', '.join(SCRIPT_TYPE_CHOICES.keys())}"
)
raise typer.Exit(1)
selected_script = script_type
@@ -599,6 +571,8 @@ def register(app: typer.Typer) -> None:
tracker.add(key, label)
if extensions:
from rich.markup import escape as _escape_markup
for i, ext_spec in enumerate(extensions):
tracker.add(
f"extension-{i}", f"Install extension: {_escape_markup(ext_spec)}"
@@ -661,30 +635,6 @@ def register(app: typer.Typer) -> None:
)
manifest.save()
if force:
from ..integrations._helpers import (
_register_extensions_for_agent,
_register_presets_for_agent,
)
_register_extensions_for_agent(
project_path,
resolved_integration.key,
force=True,
continuing=(
"The project was re-initialized, but installed extensions"
" may need re-registration."
),
)
_register_presets_for_agent(
project_path,
resolved_integration.key,
continuing=(
"The project was re-initialized, but installed presets"
" may need re-registration."
),
)
integration_settings = _with_integration_setting(
{},
resolved_integration.key,
@@ -853,6 +803,8 @@ def register(app: typer.Typer) -> None:
# Install extensions specified via --extension
if extensions:
from rich.markup import escape as _escape_markup
from ..extensions._commands import _refresh_events_and_warn
speckit_ver = get_speckit_version()
@@ -942,7 +894,7 @@ def register(app: typer.Typer) -> None:
if agent_folder:
security_notice = Panel(
f"Some agents may store credentials, auth tokens, or other identifying and private artifacts in the agent folder within your project.\n"
f"Consider adding [cyan]{_escape_markup(str(agent_folder))}[/cyan] (or parts of it) to [cyan].gitignore[/cyan] to prevent accidental credential leakage.",
f"Consider adding [cyan]{agent_folder}[/cyan] (or parts of it) to [cyan].gitignore[/cyan] to prevent accidental credential leakage.",
title="[yellow]Agent Folder Security[/yellow]",
border_style="yellow",
padding=(1, 2),
@@ -953,7 +905,7 @@ def register(app: typer.Typer) -> None:
steps_lines = []
if not here:
steps_lines.append(
f"1. Go to the project folder: [cyan]cd {_escape_markup(_shell_quote_arg(str(project_name)))}[/cyan]"
f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]"
)
step_num = 2
else:
+1 -9
View File
@@ -548,15 +548,7 @@ def _resolve_event_command_argv(
"""
from .integrations.base import IntegrationBase
try:
content = template_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
# An unreadable or undecodable template cannot declare a runnable
# script. Degrade to "no argv" like every other failure in this
# resolver (missing frontmatter, malformed YAML, absent scripts)
# instead of leaking a raw traceback through
# resolve_and_run_event_command.
return None
content = template_path.read_text(encoding="utf-8")
m = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
if not m:
return None
+7 -11
View File
@@ -660,13 +660,8 @@ class ExtensionRegistry:
if not isinstance(data.get("extensions"), dict):
data["extensions"] = {}
return data
except (json.JSONDecodeError, UnicodeDecodeError, FileNotFoundError):
# Corrupted or missing registry, start fresh. A registry whose
# bytes cannot be decoded as UTF-8 is the same corruption class as
# malformed JSON — only the exception type differs, and it is
# raised by the text-mode read before JSON parsing begins. OSError
# is deliberately not caught: the data may be intact on disk, and
# starting fresh would let a later _save() wipe it.
except (json.JSONDecodeError, FileNotFoundError):
# Corrupted or missing registry, start fresh
return {"schema_version": self.SCHEMA_VERSION, "extensions": {}}
def _save(self):
@@ -4315,10 +4310,11 @@ class ConfigManager:
Returns an empty list if the registry is missing or corrupted
(fresh project, ad-hoc test harness) so ``_get_env_config`` degrades
to its pre-fix behaviour rather than crashing. ``UnicodeError`` is
kept alongside ``OSError`` as belt-and-braces: ``_load()`` now starts
fresh on non-UTF-8 registry bytes itself, but catching it here too
keeps this call site degrading gracefully rather than breaking *every*
config read if that handling ever regresses.
caught alongside ``OSError`` because ``ExtensionRegistry._load()``
opens the file in text mode and only handles ``JSONDecodeError`` /
``FileNotFoundError``, so a registry file with non-UTF-8 bytes would
otherwise surface a ``UnicodeDecodeError`` here and break *every*
config read instead of degrading gracefully.
Used by ``_get_env_config`` to detect env vars whose remainder claims
a longer, sibling-owned prefix (e.g. ``SPECKIT_GIT_HOOKS_URL`` is
+1 -10
View File
@@ -207,7 +207,7 @@ class IntegrationCatalog(CatalogStackBase):
max_bytes=MAX_JSON_METADATA_BYTES,
error_type=IntegrationCatalogError,
label=f"catalog from {entry.url}",
).decode("utf-8")
)
)
shape_error = _catalog_shape_error(catalog_data)
@@ -237,15 +237,6 @@ class IntegrationCatalog(CatalogStackBase):
raise IntegrationCatalogError(
f"Failed to fetch catalog from {entry.url}: {exc}"
)
except UnicodeDecodeError as exc:
# A non-UTF-8 response body fails at .decode() before json.loads()
# ever runs, so JSONDecodeError below does not cover it (the two are
# sibling ValueError subclasses, not parent/child). Without this the
# raw UnicodeDecodeError escapes _get_merged_integrations()'s
# "warn and skip this catalog" handler and kills the whole command.
raise IntegrationCatalogError(
f"Catalog from {entry.url} is not valid UTF-8: {exc}"
)
except json.JSONDecodeError as exc:
raise IntegrationCatalogError(
f"Invalid JSON in catalog from {entry.url}: {exc}"
@@ -121,27 +121,13 @@ class HermesIntegration(SkillsIntegration):
command_name = src_file.stem # e.g. "plan"
skill_name = f"speckit-{command_name.replace('.', '-')}"
# Parse frontmatter for description. Locate the closing ``---`` on
# its own line rather than with ``raw.split("---", 2)`` — a bare
# substring split stops at the first ``---`` *anywhere*, including
# one inside a value such as ``description: Separate sections
# with ---``, which truncates the frontmatter and drops later keys.
# The block between the delimiters is parsed unstripped so trailing
# newlines in literal (``|``) block scalars survive.
# Parse frontmatter for description
frontmatter: dict[str, Any] = {}
if raw.startswith("---"):
fm_lines = raw.splitlines(keepends=True)
fm_close = next(
(
i
for i in range(1, len(fm_lines))
if fm_lines[i].rstrip() == "---"
),
None,
)
if fm_close is not None:
parts = raw.split("---", 2)
if len(parts) >= 3:
try:
fm = yaml.safe_load("".join(fm_lines[1:fm_close]))
fm = yaml.safe_load(parts[1])
if isinstance(fm, dict):
frontmatter = fm
except yaml.YAMLError:
@@ -157,26 +143,10 @@ class HermesIntegration(SkillsIntegration):
project_root=project_root,
)
# Strip the processed frontmatter — we rebuild it for skills.
# Scan for the closing ``---`` on its own line rather than
# ``split("---", 2)`` so a ``---`` embedded in a value does not
# truncate the frontmatter and spill it into the body.
if processed_body.startswith("---"):
body_lines = processed_body.splitlines(keepends=True)
close_idx = next(
(
i
for i in range(1, len(body_lines))
if body_lines[i].rstrip() == "---"
),
None,
)
if close_idx is not None:
# Keep whatever trails the ``---`` marker on the closing
# line so the body stays byte-for-byte identical to
# ``split("---", 2)[2]`` for well-formed templates.
processed_body = body_lines[close_idx][3:] + "".join(
body_lines[close_idx + 1 :]
)
parts = processed_body.split("---", 2)
if len(parts) >= 3:
processed_body = parts[2]
# Select description
description = frontmatter.get("description", "")
+3 -13
View File
@@ -323,24 +323,14 @@ def _is_speckit_generated_skill(skill_dir: Path) -> bool:
if not content.startswith("---"):
return False
# Locate the closing ``---`` on its own line rather than with
# ``content.split("---", 2)`` — a bare substring split stops at the first
# ``---`` *anywhere*, including one inside a value such as
# ``description: Separate sections with ---``, which truncates the parsed
# frontmatter and can drop the metadata block this check relies on (so a
# Speckit-generated skill would not be recognized on teardown).
lines = content.splitlines(keepends=True)
close_idx = next(
(i for i in range(1, len(lines)) if lines[i].rstrip() == "---"),
None,
)
if close_idx is None:
parts = content.split("---", 2)
if len(parts) < 3:
return False
try:
import yaml
frontmatter = yaml.safe_load("".join(lines[1:close_idx]))
frontmatter = yaml.safe_load(parts[1])
except Exception:
return False
+2 -1
View File
@@ -451,7 +451,8 @@ class IntegrationManifest:
_ensure_safe_manifest_destination(self.project_root, path)
os.replace(temp_path, path)
finally:
temp_path.unlink(missing_ok=True)
if temp_path.exists():
temp_path.unlink()
return path
@classmethod
+3 -20
View File
@@ -5576,7 +5576,7 @@ class PresetResolver:
if not layers:
return None
def _read_layer_content(layer: Dict[str, Any]) -> Optional[str]:
def _read_layer_content(layer: Dict[str, Any]) -> str:
"""Read a layer's raw text, rewriting extension-relative subdir
references (agents/, knowledge-base/, etc.) to their installed
location when the layer is extension-provided (#2101).
@@ -5586,18 +5586,8 @@ class PresetResolver:
rewrite when it wins outright above or serves as the
composition base below — never as a mid-stack composing
(append/prepend/wrap) layer.
Returns None when the layer cannot be read or decoded:
collect_all_layers deliberately keeps a non-UTF-8 legacy layer
(with its "replace" default) so unrelated commands still
resolve, so the same tolerance must apply here — the documented
contract is "Composed content string, or None if not found",
not a raw UnicodeDecodeError at composition time.
"""
try:
text = layer["path"].read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return None
text = layer["path"].read_text(encoding="utf-8")
extension_id = layer.get("extension_id")
extension_dir = layer.get("extension_dir")
if extension_id and extension_dir:
@@ -5635,8 +5625,6 @@ class PresetResolver:
# Convert to reversed_layers index
base_reversed_idx = len(layers) - 1 - base_layer_idx
content = _read_layer_content(layers[base_layer_idx])
if content is None:
return None
# Compose only the layers above the base (higher priority = lower index in layers,
# higher index in reversed_layers). Process bottom-up from base+1.
start_idx = base_reversed_idx + 1
@@ -5680,12 +5668,7 @@ class PresetResolver:
# Apply composition layers from bottom to top
for layer in reversed_layers[start_idx:]:
try:
layer_content = layer["path"].read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
# Same tolerance as _read_layer_content: an unreadable layer
# means the composed result cannot be produced.
return None
layer_content = layer["path"].read_text(encoding="utf-8")
strategy = layer["strategy"]
if is_command:
+2 -47
View File
@@ -16,22 +16,6 @@ from .integrations.manifest import IntegrationManifest
logger = logging.getLogger(__name__)
# Managed ``.specify/.gitignore``. Keeps machine-local Spec Kit state out of
# version control while leaving shareable project files (specs, constitution,
# templates, scripts, extension config) tracked. Patterns are relative to the
# ``.specify/`` directory the file lives in.
SPECIFY_GITIGNORE_CONTENT = """\
# Machine-local Spec Kit state — not meant to be shared.
# Managed by the Specify CLI; safe to edit (your changes are preserved on refresh).
# Local pointer to the current feature directory. Rewritten every time you
# switch features, so it is per-checkout state rather than something to share.
feature.json
# Per-machine extension config overrides.
extensions/*/local-config.yml
"""
# Matches a SHA-256 digest in its normalized form: exactly 64 hexadecimal
# characters. Callers lowercase the declared value before matching (see
# ``expected_hex = raw.lower()`` below), so an uppercase digest is accepted and
@@ -278,7 +262,8 @@ def _write_shared_bytes(
_ensure_safe_shared_destination(project_path, dest)
os.replace(temp_path, dest)
finally:
temp_path.unlink(missing_ok=True)
if temp_path.exists():
temp_path.unlink()
_BASH_FORMAT_COMMAND_RE = re.compile(
@@ -623,36 +608,6 @@ def install_shared_infra(
)
planned_templates.append((dst, rel, content))
# Managed ``.specify/.gitignore`` — keeps machine-local state (the
# ``feature.json`` pointer and per-machine ``local-config.yml`` overrides)
# out of git while leaving everything else shareable. Routed through the
# same overwrite/skip/preserve policy as templates so ``--force`` refreshes
# it and user edits are preserved. Like every other shared-infra file it is
# tracked in ``speckit.manifest.json`` (not the per-integration manifest) and
# is therefore intentionally left in place by ``integration uninstall``.
specify_dir = project_path / ".specify"
if _ensure_or_bucket_dir(specify_dir):
gitignore_dst = specify_dir / ".gitignore"
gitignore_rel = gitignore_dst.relative_to(project_path).as_posix()
seen_rels.add(gitignore_rel)
if _safe_dest_or_bucket(gitignore_dst, gitignore_rel):
write, bucket = _decide_overwrite(gitignore_rel, gitignore_dst)
if write:
planned_templates.append(
(gitignore_dst, gitignore_rel, SPECIFY_GITIGNORE_CONTENT)
)
elif bucket == "preserved":
preserved_user_files.append(gitignore_rel)
else:
skipped_files.append(gitignore_rel)
if gitignore_dst.is_file() and gitignore_rel not in prior_hashes:
try:
manifest.record_existing(gitignore_rel, recovered=True)
except (OSError, ValueError) as exc:
console.print(
f"[yellow]⚠[/yellow] could not record {gitignore_rel} in manifest: {exc}"
)
for dst_path, rel, content, mode in planned_copies:
if not _ensure_or_bucket_dir(dst_path.parent):
continue
-6
View File
@@ -1594,12 +1594,6 @@ def workflow_status(
except ValueError as exc:
err.print(f"[red]Error:[/red] {_escape_markup(str(exc))}")
raise typer.Exit(1)
except OSError as exc:
# An unreadable state.json (bad permissions, a directory in its
# place, I/O error) must fail as cleanly as the malformed-JSON
# case above -- `workflow resume` already handles OSError here.
err.print(f"[red]Error:[/red] {_escape_markup(str(exc))}")
raise typer.Exit(1)
if json_output:
# Build on the shared run/resume payload so the common fields
@@ -222,6 +222,32 @@ def test_python_custom_markers_matching_bash(tmp_path: Path) -> None:
assert "old" not in content
@requires_posix_bash
def test_python_blank_markers_use_defaults_matching_bash(tmp_path: Path) -> None:
# Regression: with blank markers (config relying on the built-in defaults),
# the Bash port must fall back to DEFAULT_START/END, matching the Python and
# PowerShell ports. Previously the Bash config-parser transport dropped the
# trailing empty marker lines under $(...) command substitution, tripping the
# "malformed config parser output" guard so the default-marker substitution
# became unreachable and the context file was never updated.
markers = {"start": "", "end": ""}
repo_a, repo_b = twin_projects(
tmp_path, context_file="AGENTS.md", context_markers=markers
)
add_plan(repo_a)
add_plan(repo_b)
bash = run_bash(repo_a)
py = run_python(repo_b)
assert_parity(bash, py, repo_a, repo_b)
content = (repo_b / "AGENTS.md").read_bytes()
assert content == (repo_a / "AGENTS.md").read_bytes()
assert b"<!-- SPECKIT START -->" in content
assert b"<!-- SPECKIT END -->" in content
assert b"at specs/001-demo/plan.md" in content
@requires_posix_bash
def test_python_multiple_context_files_dedup_matching_bash(tmp_path: Path) -> None:
files = ["AGENTS.md", "docs/CONTEXT.md", "AGENTS.md"]
@@ -318,19 +344,14 @@ def test_python_mtime_fallback_matching_bash(tmp_path: Path) -> None:
@requires_posix_bash
def test_python_mtime_fallback_finds_nested_plan_matching_bash(
tmp_path: Path,
) -> None:
"""The mtime fallback must recurse into scoped layouts.
A plan created under specs/<scope>/<feature>/plan.md (as produced via
SPECIFY_FEATURE_DIRECTORY) is more than one level below specs/. The old
Python port used a one-level specs/*/plan.md glob and missed it, while the
bash/PowerShell twins recurse (#3024). This locks in the parity.
"""
def test_python_mtime_fallback_finds_nested_plan_matching_bash(tmp_path: Path) -> None:
# Regression: the mtime fallback must discover plan.md in nested scoped
# layouts (specs/<scope>/<feature>/plan.md), matching the Bash/PowerShell
# ports and the documented recursive-discovery contract (see #3024). A
# one-level scan (specs/*/plan.md) would miss this and omit the plan link.
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
for repo in (repo_a, repo_b):
plan = repo / "specs" / "backend" / "001-nested" / "plan.md"
plan = repo / "specs" / "scope-a" / "002-nested" / "plan.md"
plan.parent.mkdir(parents=True, exist_ok=True)
plan.write_text("# plan\n", encoding="utf-8")
@@ -340,39 +361,7 @@ def test_python_mtime_fallback_finds_nested_plan_matching_bash(
assert_parity(bash, py, repo_a, repo_b)
content = (repo_b / "AGENTS.md").read_bytes()
assert content == (repo_a / "AGENTS.md").read_bytes()
assert b"at specs/backend/001-nested/plan.md" in content
@requires_posix_bash
def test_python_mtime_fallback_skips_plan_reached_through_escaping_symlink(
tmp_path: Path,
) -> None:
"""A plan reached via a specs/ symlink out of the project is not selected.
``relative_to()`` is lexical, so ``specs/linked/001-x/plan.md`` looks
in-project even when ``specs/linked`` points outside the tree. Resolving
before the containment check rejects it, so the fallback finds nothing and
the ``at <plan>`` line is omitted rather than naming an out-of-project file
with an in-project-looking path. Mirrors the bash twin's ``_resolved_rel``.
"""
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
for repo in (repo_a, repo_b):
outside = repo.parent / f"outside-{repo.name}" / "001-x"
outside.mkdir(parents=True, exist_ok=True)
(outside / "plan.md").write_text("# plan\n", encoding="utf-8")
specs = repo / "specs"
specs.mkdir(parents=True, exist_ok=True)
(specs / "linked").symlink_to(outside.parent, target_is_directory=True)
# Sanity: the plan really is reachable through the symlink.
assert (specs / "linked" / "001-x" / "plan.md").is_file()
bash = run_bash(repo_a)
py = run_python(repo_b)
assert_parity(bash, py, repo_a, repo_b)
content = (repo_b / "AGENTS.md").read_bytes()
assert content == (repo_a / "AGENTS.md").read_bytes()
assert b"\nat " not in content
assert b"at specs/scope-a/002-nested/plan.md" in content
@requires_posix_bash
@@ -519,31 +508,6 @@ def test_python_fresh_context_file_matches_powershell(tmp_path: Path) -> None:
assert (repo_a / "AGENTS.md").read_bytes() == (repo_b / "AGENTS.md").read_bytes()
@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available")
def test_python_mtime_fallback_finds_nested_plan_matches_powershell(
tmp_path: Path,
) -> None:
"""Python's mtime fallback must recurse like the PowerShell twin.
With no feature.json, discovery falls back to scanning under specs/. A plan
at specs/<scope>/<feature>/plan.md sits more than one level deep; the old
Python one-level glob missed it while PowerShell already recurses (#3024).
"""
repo_a = make_project(tmp_path / "proj-ps", context_file="AGENTS.md")
repo_b = make_project(tmp_path / "proj-py", context_file="AGENTS.md")
for repo in (repo_a, repo_b):
plan = repo / "specs" / "backend" / "001-nested" / "plan.md"
plan.parent.mkdir(parents=True, exist_ok=True)
plan.write_text("# plan\n", encoding="utf-8")
ps = run_powershell(repo_a)
py = run_python(repo_b)
assert ps.returncode == py.returncode == 0, ps.stderr + py.stderr
assert (repo_a / "AGENTS.md").read_bytes() == (repo_b / "AGENTS.md").read_bytes()
assert b"at specs/backend/001-nested/plan.md" in (repo_b / "AGENTS.md").read_bytes()
@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available")
def test_python_upsert_matches_powershell(tmp_path: Path) -> None:
repo_a = make_project(tmp_path / "proj-ps", context_file="AGENTS.md")
@@ -186,50 +186,6 @@ def test_local_zip_uses_bounded_archive_open(tmp_path: Path):
_local_manifest_source(str(artifact))
def test_local_zip_wraps_malformed_manifest_yaml(tmp_path: Path):
"""A malformed bundle.yml inside a .zip must raise BundlerError.
The zip branch parses YAML inline rather than through load_yaml(), so the
raw yaml.YAMLError used to escape. It is neither a ValueError nor an
OSError, so nothing upstream caught it.
"""
artifact = tmp_path / "bad-manifest.zip"
with zipfile.ZipFile(artifact, "w") as archive:
archive.writestr("bundle.yml", "bundle: [unclosed\n id: demo\n")
with pytest.raises(BundlerError, match="Invalid YAML"):
_local_manifest_source(str(artifact))
def test_malformed_manifest_yaml_fails_alike_for_every_local_source(tmp_path: Path):
"""`bundle install` reports malformed YAML the same way for all 3 sources.
Directory and bundle.yml sources already exited 1 with an "Invalid YAML"
message; the .zip source dumped a yaml.parser.ParserError traceback.
"""
bad_yaml = "bundle: [unclosed\n id: demo\n"
directory = tmp_path / "dir-src"
directory.mkdir()
(directory / "bundle.yml").write_text(bad_yaml, encoding="utf-8")
manifest_file = tmp_path / "standalone.yml"
manifest_file.write_text(bad_yaml, encoding="utf-8")
artifact = tmp_path / "artifact.zip"
with zipfile.ZipFile(artifact, "w") as archive:
archive.writestr("bundle.yml", bad_yaml)
runner = CliRunner()
for source in (directory, manifest_file, artifact):
result = runner.invoke(app, ["bundle", "install", str(source)])
assert result.exit_code == 1, f"{source.name}: {result.output}"
assert result.exception is None or isinstance(
result.exception, SystemExit
), f"{source.name} leaked {type(result.exception).__name__}"
assert "Invalid YAML" in result.output, f"{source.name}: {result.output}"
def test_invalid_local_manifest_is_rejected_before_project_init(
tmp_path: Path,
monkeypatch,
-93
View File
@@ -1067,99 +1067,6 @@ class TestInitIntegrationFlag:
assert "not updated" in result.output
def test_init_here_force_reapplies_installed_presets(self, tmp_path, monkeypatch):
"""Regression for #3990: init --here --force must call _register_presets_for_agent
after setup() so preset-composed files are not silently reverted to core."""
from unittest.mock import MagicMock, patch
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "force-preset-reapply"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
# First init to create a valid project structure.
result = runner.invoke(app, [
"init", "--here", "--force",
"--integration", "claude",
"--script", "sh",
"--ignore-agent-tools",
], catch_exceptions=False)
assert result.exit_code == 0, result.output
# Second init --here --force: verify _register_presets_for_agent is called.
# Patch at the source module since init.py does a lazy import of these functions.
mock_presets = MagicMock()
mock_extensions = MagicMock()
with (
patch(
"specify_cli.integrations._helpers._register_presets_for_agent",
mock_presets,
),
patch(
"specify_cli.integrations._helpers._register_extensions_for_agent",
mock_extensions,
),
):
result2 = runner.invoke(app, [
"init", "--here", "--force",
"--integration", "claude",
"--script", "sh",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result2.exit_code == 0, result2.output
assert mock_presets.called, (
"_register_presets_for_agent was not called during init --here --force"
)
assert mock_extensions.called, (
"_register_extensions_for_agent was not called during init --here --force"
)
def test_init_here_without_force_does_not_reapply_presets(self, tmp_path):
"""Without --force (fresh project), _register_presets_for_agent should NOT be called."""
from unittest.mock import MagicMock, patch
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "no-force-preset"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
mock_presets = MagicMock()
with patch(
"specify_cli.integrations._helpers._register_presets_for_agent",
mock_presets,
):
result = runner.invoke(app, [
"init", "--here",
"--integration", "claude",
"--script", "sh",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
# On a fresh project without --force the reapply guard should not fire.
assert not mock_presets.called, (
"_register_presets_for_agent should not be called on a fresh init without --force"
)
class TestForceExistingDirectory:
"""Tests for --force merging into an existing named directory."""
-47
View File
@@ -1263,53 +1263,6 @@ class TestCommandRunner:
)
argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None)
assert argv is None
def test_unreadable_template_returns_none(self, tmp_path):
"""A command template that cannot be read must resolve to no argv.
Every other failure inside ``_resolve_event_command_argv`` — missing
frontmatter, malformed YAML, absent scripts — degrades to ``None`` so
the dispatcher treats the command as declaring no runnable script.
The initial ``read_text`` was the one step outside that boundary: a
non-UTF-8 template raised a raw ``UnicodeDecodeError`` through
``resolve_and_run_event_command`` and out of ``specify event run``.
"""
from specify_cli.events import _resolve_event_command_argv
cmd_dir = tmp_path / ".specify" / "templates" / "commands"
cmd_dir.mkdir(parents=True)
(cmd_dir / "boot.md").write_bytes(
b"---\ndescription: \"B\xff\xfeoot\"\n---\nBody\n"
)
argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None)
assert argv is None
def test_permission_denied_template_returns_none(self, tmp_path, monkeypatch):
"""The same boundary must cover ``OSError`` (e.g. permission denied).
Mocked rather than chmod-based so the case also holds under
privileged CI where permission bits are not enforced.
"""
from specify_cli.events import _resolve_event_command_argv
cmd_dir = tmp_path / ".specify" / "templates" / "commands"
cmd_dir.mkdir(parents=True)
template = cmd_dir / "boot.md"
template.write_text("---\ndescription: Boot\n---\nBody\n")
original_read_text = Path.read_text
def failing_read_text(self_path, *args, **kwargs):
if self_path == template:
raise PermissionError(13, "Permission denied")
return original_read_text(self_path, *args, **kwargs)
monkeypatch.setattr(Path, "read_text", failing_read_text)
argv = _resolve_event_command_argv(template, tmp_path, None)
assert argv is None
def test_ps_variant_prefixed_with_powershell_launcher(self, tmp_path):
@@ -238,7 +238,6 @@ class MarkdownIntegrationTests:
files.append(".specify/init-options.json")
files.append(f".specify/integrations/{self.KEY}.manifest.json")
files.append(".specify/integrations/speckit.manifest.json")
files.append(".specify/.gitignore")
if script_variant == "sh":
for name in ["check-prerequisites.sh", "common.sh", "create-new-feature.sh",
@@ -141,91 +141,6 @@ class SkillsIntegrationTests:
assert isinstance(fm["description"], str)
assert len(fm["description"]) > 0, f"{f} has empty description"
def test_skill_frontmatter_preserves_multiline_description(
self, tmp_path, monkeypatch
):
"""A multiline (block-scalar) description must round-trip exactly.
The hand-built SKILL.md frontmatter used to only escape backslash and
quote, so a block-scalar description was emitted with raw newlines inside
a double-quoted scalar and reparsed with those newlines collapsed to
spaces. The description must survive byte-for-byte."""
from pathlib import Path
i = get_integration(self.KEY)
# Hermes writes to ~/.hermes/skills/ — isolate Path.home() to prevent
# overwriting a developer's real global skill directory.
if self.KEY == "hermes":
home = tmp_path / "home"
home.mkdir(exist_ok=True)
monkeypatch.setattr(Path, "home", lambda: home)
template = tmp_path / "sample.md"
template.write_text(
"---\n"
"description: |\n"
" first line\n"
" second line\n"
"scripts:\n"
" sh: scripts/bash/x.sh\n"
"---\n"
"Body\n",
encoding="utf-8",
)
monkeypatch.setattr(i, "list_command_templates", lambda: [template])
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
skill_files = [f for f in created if f.name == "SKILL.md"]
assert len(skill_files) == 1
content = skill_files[0].read_text(encoding="utf-8")
fm = yaml.safe_load(content.split("---", 2)[1])
assert "\n" in fm["description"]
assert fm["description"] == "first line\nsecond line\n"
def test_skill_frontmatter_preserves_control_characters(
self, tmp_path, monkeypatch
):
"""A description carrying a C0/DEL control char must round-trip exactly.
A control character can reach ``description`` via a YAML escape in the
source template (``"a\\x08b"`` parses to a real U+0008). The old
hand-built frontmatter only escaped backslash and quote, so the raw
control char landed inside the emitted double-quoted scalar and made the
SKILL.md unparseable / lossy. ``yaml_quote`` must escape it so the
value survives byte-for-byte."""
from pathlib import Path
i = get_integration(self.KEY)
# Hermes writes to ~/.hermes/skills/ — isolate Path.home() to prevent
# overwriting a developer's real global skill directory.
if self.KEY == "hermes":
home = tmp_path / "home"
home.mkdir(exist_ok=True)
monkeypatch.setattr(Path, "home", lambda: home)
template = tmp_path / "sample.md"
template.write_text(
"---\n"
'description: "a\\x08b\\ttab"\n'
"scripts:\n"
" sh: scripts/bash/x.sh\n"
"---\n"
"Body\n",
encoding="utf-8",
)
monkeypatch.setattr(i, "list_command_templates", lambda: [template])
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
skill_files = [f for f in created if f.name == "SKILL.md"]
assert len(skill_files) == 1
content = skill_files[0].read_text(encoding="utf-8")
fm = yaml.safe_load(content.split("---", 2)[1])
assert fm["description"] == "a\x08b\ttab"
def test_templates_are_processed(self, tmp_path):
"""Skill body must have placeholders replaced, not raw templates."""
i = get_integration(self.KEY)
@@ -484,7 +399,6 @@ class SkillsIntegrationTests:
".specify/integration.json",
f".specify/integrations/{self.KEY}.manifest.json",
".specify/integrations/speckit.manifest.json",
".specify/.gitignore",
".specify/memory/.constitution-template.json",
".specify/memory/constitution.md",
]
@@ -488,7 +488,6 @@ class TomlIntegrationTests:
files.append(".specify/init-options.json")
files.append(f".specify/integrations/{self.KEY}.manifest.json")
files.append(".specify/integrations/speckit.manifest.json")
files.append(".specify/.gitignore")
if script_variant == "sh":
for name in [
@@ -402,7 +402,6 @@ class YamlIntegrationTests:
files.append(".specify/init-options.json")
files.append(f".specify/integrations/{self.KEY}.manifest.json")
files.append(".specify/integrations/speckit.manifest.json")
files.append(".specify/.gitignore")
if script_variant == "sh":
for name in [
+127 -201
View File
@@ -223,6 +223,33 @@ class TestActiveCatalogs:
# ---------------------------------------------------------------------------
class _OversizedResponse:
"""Response stub that supports bounded streaming reads for oversized-catalog tests."""
def __init__(self, data, url=""):
self._data = json.dumps(data).encode()
self._url = url if isinstance(url, str) else url.full_url
self._pos = 0
def read(self, n=-1):
if n < 0:
chunk = self._data[self._pos:]
self._pos = len(self._data)
return chunk
chunk = self._data[self._pos : self._pos + n]
self._pos += len(chunk)
return chunk
def geturl(self):
return self._url
def __enter__(self):
return self
def __exit__(self, *a):
pass
class TestCatalogFetch:
"""Tests that use a local HTTP server stub via monkeypatch."""
@@ -233,15 +260,15 @@ class TestCatalogFetch:
def __init__(self, data, url=""):
self._data = json.dumps(data).encode()
self._url = url if isinstance(url, str) else url.full_url
self._offset = 0
self._pos = 0
def read(self, size=-1):
if size == -1:
chunk = self._data[self._offset:]
self._offset = len(self._data)
else:
chunk = self._data[self._offset:self._offset + size]
self._offset += len(chunk)
def read(self, n=-1):
if n < 0:
chunk = self._data[self._pos:]
self._pos = len(self._data)
return chunk
chunk = self._data[self._pos:self._pos + n]
self._pos += len(chunk)
return chunk
def geturl(self):
@@ -330,187 +357,6 @@ class TestCatalogFetch:
results = cat.search()
assert "acme-coder" in [r["id"] for r in results]
def test_fetch_rejects_oversized_catalog_response(
self, tmp_path, monkeypatch
):
"""Regression: _fetch_single_catalog must use read_response_limited
with MAX_JSON_METADATA_BYTES, not unbounded resp.read()."""
from specify_cli.integrations.catalog import (
IntegrationCatalog,
IntegrationCatalogError,
)
import specify_cli.integrations.catalog as catalog_module
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
(tmp_path / ".specify").mkdir()
cat = IntegrationCatalog(tmp_path)
# Set limit very small so any response is oversized
monkeypatch.setattr(catalog_module, "MAX_JSON_METADATA_BYTES", 32)
class _OversizedResponse:
def __init__(self):
self._data = b"x" * 64
self._offset = 0
def read(self, size=-1):
if size == -1:
chunk = self._data[self._offset:]
self._offset = len(self._data)
else:
chunk = self._data[self._offset:self._offset + size]
self._offset += len(chunk)
return chunk
def geturl(self):
return "https://example.com/catalog.json"
def __enter__(self):
return self
def __exit__(self, *a):
pass
import specify_cli.authentication.http as _auth_http
def fake_urlopen(req, timeout=10):
return _OversizedResponse()
monkeypatch.setattr(_auth_http.urllib.request, "urlopen", fake_urlopen)
from specify_cli.integrations.catalog import IntegrationCatalogEntry
entry = IntegrationCatalogEntry(
url="https://example.com/catalog.json",
name="test",
priority=1,
install_allowed=True,
)
with pytest.raises(IntegrationCatalogError, match="exceeds maximum size"):
cat._fetch_single_catalog(entry, force_refresh=True)
def _patch_urlopen_bytes(self, monkeypatch, bodies):
"""Patch urlopen to serve raw *bodies* keyed by URL substring.
Mirrors ``_patch_urlopen`` but passes the bytes through verbatim: these
tests need a body that is not valid UTF-8, which ``json.dumps`` cannot
produce.
"""
class _RawResponse:
def __init__(self, data, url):
self._data = data
self._url = url
self._offset = 0
def read(self, size=-1):
if size == -1:
chunk = self._data[self._offset:]
self._offset = len(self._data)
else:
chunk = self._data[self._offset:self._offset + size]
self._offset += len(chunk)
return chunk
def geturl(self):
return self._url
def __enter__(self):
return self
def __exit__(self, *a):
pass
def fake_urlopen(req, timeout=10):
url = req if isinstance(req, str) else req.full_url
for marker, body in bodies.items():
if marker in url:
return _RawResponse(body, url)
raise AssertionError(f"unexpected URL requested: {url}")
import specify_cli.authentication.http as _auth_http
monkeypatch.setattr(_auth_http.urllib.request, "urlopen", fake_urlopen)
def test_fetch_wraps_non_utf8_catalog_response(self, tmp_path, monkeypatch):
"""Regression: a non-UTF-8 response body must raise IntegrationCatalogError.
``.decode("utf-8")`` runs before ``json.loads``, so the resulting
UnicodeDecodeError is not a JSONDecodeError and slipped past both
handlers as a raw traceback.
"""
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
(tmp_path / ".specify").mkdir(exist_ok=True)
cat = IntegrationCatalog(tmp_path)
self._patch_urlopen_bytes(
monkeypatch,
{"catalog.json": b'{"schema_version": "1.0", "name": "\xff\xfe"}'},
)
entry = IntegrationCatalogEntry(
url="https://example.com/catalog.json",
name="test",
priority=1,
install_allowed=True,
)
with pytest.raises(IntegrationCatalogError, match="not valid UTF-8"):
cat._fetch_single_catalog(entry, force_refresh=True)
def test_search_skips_non_utf8_catalog(self, tmp_path, monkeypatch, capsys):
"""A single non-UTF-8 catalog must not take down the whole search.
``_get_merged_integrations`` is built to warn and continue on a bad
catalog; an unwrapped UnicodeDecodeError defeated that entirely.
"""
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
specify = tmp_path / ".specify"
specify.mkdir(exist_ok=True)
(specify / "integration-catalogs.yml").write_text(
"catalogs:\n"
" - name: broken\n"
" url: https://example.com/broken.json\n"
" priority: 1\n"
" - name: healthy\n"
" url: https://example.com/healthy.json\n"
" priority: 2\n",
encoding="utf-8",
)
healthy = json.dumps(
{
"schema_version": "1.0",
"integrations": {
"acme-coder": {
"name": "Acme Coder",
"version": "1.0.0",
"description": "Acme integration",
}
},
}
).encode("utf-8")
self._patch_urlopen_bytes(
monkeypatch,
{
"broken.json": b'{"schema_version": "1.0", "name": "\xff\xfe"}',
"healthy.json": healthy,
},
)
cat = IntegrationCatalog(tmp_path)
results = cat.search()
assert "acme-coder" in [r["id"] for r in results]
assert "broken" in capsys.readouterr().err
def test_search_by_tag(self, tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
@@ -586,6 +432,90 @@ class TestCatalogFetch:
with pytest.raises(IntegrationCatalogError, match="Failed to fetch any integration catalog"):
cat.search()
def test_oversized_catalog_response_rejected(self, tmp_path, monkeypatch):
"""Response exceeding MAX_JSON_METADATA_BYTES is caught as IntegrationCatalogError.
The per-entry error is logged as a warning and skipped (not fatal).
When ALL catalogs are oversized, search() raises the aggregate error.
"""
from specify_cli._download_security import MAX_JSON_METADATA_BYTES
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
(tmp_path / ".specify").mkdir()
cat = IntegrationCatalog(tmp_path)
# Build a valid catalog dict whose JSON encoding exceeds the limit.
oversized = {
"schema_version": "1.0",
"integrations": {},
"padding": "x" * (MAX_JSON_METADATA_BYTES + 1),
}
import specify_cli.authentication.http as _auth_http
def _oversized_urlopen(req, timeout=10):
url = req if isinstance(req, str) else req.full_url
return _OversizedResponse(oversized, url)
monkeypatch.setattr(_auth_http.urllib.request, "urlopen", _oversized_urlopen)
# Both default + community catalogs are oversized → all fail → aggregate error.
# The per-entry IntegrationCatalogError (with "exceeds maximum size") is
# logged as a warning; the aggregate raise has a different message.
with pytest.raises(IntegrationCatalogError, match="Failed to fetch any integration catalog"):
cat.search()
def test_oversized_catalog_does_not_block_healthy_one(self, tmp_path, monkeypatch):
"""When one catalog is oversized, the healthy catalog still returns results."""
from specify_cli._download_security import MAX_JSON_METADATA_BYTES
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
specify = tmp_path / ".specify"
specify.mkdir()
healthy_catalog = {
"schema_version": "1.0",
"integrations": {
"good-agent": {
"id": "good-agent",
"name": "Good Agent",
"version": "1.0.0",
"description": "A healthy integration",
"author": "test-org",
},
},
}
oversized_catalog = {
"schema_version": "1.0",
"integrations": {},
"padding": "x" * (MAX_JSON_METADATA_BYTES + 1),
}
cfg = specify / "integration-catalogs.yml"
cfg.write_text(yaml.dump({"catalogs": [
{"url": "https://healthy.example.com/catalog.json", "name": "healthy", "priority": 1, "install_allowed": True},
{"url": "https://oversized.example.com/catalog.json", "name": "oversized", "priority": 2, "install_allowed": True},
]}))
cat = IntegrationCatalog(tmp_path)
import specify_cli.authentication.http as _auth_http
def _multi_catalog_urlopen(req, timeout=10):
url = req if isinstance(req, str) else req.full_url
if "oversized" in url:
return _OversizedResponse(oversized_catalog, url)
return _OversizedResponse(healthy_catalog, url)
monkeypatch.setattr(_auth_http.urllib.request, "urlopen", _multi_catalog_urlopen)
# The oversized catalog is skipped; the healthy catalog's integrations are returned.
results = cat.search()
ids = [r["id"] for r in results]
assert "good-agent" in ids
def test_clear_cache(self, tmp_path):
(tmp_path / ".specify").mkdir()
cat = IntegrationCatalog(tmp_path)
@@ -783,23 +713,19 @@ class TestIntegrationListCatalog:
def __init__(self, data, url=""):
self._data = json.dumps(data).encode()
self._url = url if isinstance(url, str) else url.full_url
self._offset = 0
def read(self, size=-1):
if size == -1:
chunk = self._data[self._offset:]
self._offset = len(self._data)
else:
chunk = self._data[self._offset:self._offset + size]
self._offset += len(chunk)
self._pos = 0
def read(self, n=-1):
if n < 0:
chunk = self._data[self._pos:]
self._pos = len(self._data)
return chunk
chunk = self._data[self._pos:self._pos + n]
self._pos += len(chunk)
return chunk
def geturl(self):
return self._url
def __enter__(self):
return self
def __exit__(self, *a):
pass
@@ -185,7 +185,6 @@ class TestClineIntegration(MarkdownIntegrationTests):
files.append(".specify/init-options.json")
files.append(f".specify/integrations/{self.KEY}.manifest.json")
files.append(".specify/integrations/speckit.manifest.json")
files.append(".specify/.gitignore")
if script_variant == "sh":
for name in [
@@ -274,7 +274,6 @@ class TestCopilotCommandsMode:
".specify/init-options.json",
".specify/integrations/copilot.manifest.json",
".specify/integrations/speckit.manifest.json",
".specify/.gitignore",
".specify/scripts/bash/check-prerequisites.sh",
".specify/scripts/bash/common.sh",
".specify/scripts/bash/create-new-feature.sh",
@@ -338,7 +337,6 @@ class TestCopilotCommandsMode:
".specify/init-options.json",
".specify/integrations/copilot.manifest.json",
".specify/integrations/speckit.manifest.json",
".specify/.gitignore",
".specify/scripts/powershell/check-prerequisites.ps1",
".specify/scripts/powershell/common.ps1",
".specify/scripts/powershell/create-new-feature.ps1",
@@ -849,7 +847,6 @@ class TestCopilotSkillsMode:
".specify/integration.json",
".specify/integrations/copilot.manifest.json",
".specify/integrations/speckit.manifest.json",
".specify/.gitignore",
# Scripts (sh)
".specify/scripts/bash/check-prerequisites.sh",
".specify/scripts/bash/common.sh",
@@ -342,7 +342,6 @@ class TestGenericIntegration:
".specify/integration.json",
".specify/integrations/generic.manifest.json",
".specify/integrations/speckit.manifest.json",
".specify/.gitignore",
".specify/memory/.constitution-template.json",
".specify/memory/constitution.md",
".specify/scripts/bash/check-prerequisites.sh",
@@ -400,7 +399,6 @@ class TestGenericIntegration:
".specify/integration.json",
".specify/integrations/generic.manifest.json",
".specify/integrations/speckit.manifest.json",
".specify/.gitignore",
".specify/memory/.constitution-template.json",
".specify/memory/constitution.md",
".specify/scripts/powershell/check-prerequisites.ps1",
@@ -178,66 +178,3 @@ class TestHermesSkillFrontmatterQuoting:
fm = _parse_frontmatter(skill_files[0])
assert fm["description"] == MULTILINE
def test_dashed_description_is_preserved(self, tmp_path, monkeypatch):
"""Hermes overrides setup(), so it needs the same line-anchored parse."""
home = tmp_path / "home"
home.mkdir(exist_ok=True)
monkeypatch.setattr(Path, "home", lambda: home)
integration = get_integration("hermes")
monkeypatch.setattr(
integration,
"shared_commands_dir",
lambda: _fake_templates(tmp_path, DASHED_TEMPLATE),
)
manifest = IntegrationManifest("hermes", tmp_path)
created = integration.setup(tmp_path, manifest)
skill_files = [f for f in created if f.name == "SKILL.md"]
assert len(skill_files) == 1
fm = _parse_frontmatter_line_anchored(skill_files[0])
assert fm["description"] == DASHED_DESCRIPTION
content = skill_files[0].read_text(encoding="utf-8")
lines = content.splitlines(keepends=True)
end = next(i for i in range(1, len(lines)) if lines[i].rstrip() == "---")
body = "".join(lines[end + 1 :])
assert "name-marker: sentinel" not in body
class TestKimiGeneratedSkillDetection:
"""``_is_speckit_generated_skill`` must survive a ``---`` in a value.
Teardown only removes a legacy skill directory it recognizes as
Speckit-generated via the frontmatter ``metadata`` block. A substring split
truncated the frontmatter before ``metadata`` when a description embedded
``---``, so the directory was left behind on uninstall.
"""
def _write_skill(self, skill_dir: Path, description: str) -> None:
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
"---\n"
'name: "speckit-plan"\n'
f"description: {description}\n"
"metadata:\n"
' author: "github-spec-kit"\n'
' source: "templates/commands/plan.md"\n'
"---\n\nBody.\n",
encoding="utf-8",
)
def test_detects_skill_with_dashes_in_description(self, tmp_path):
from specify_cli.integrations.kimi import _is_speckit_generated_skill
skill_dir = tmp_path / "speckit-plan"
self._write_skill(skill_dir, "Separate sections with --- markers")
assert _is_speckit_generated_skill(skill_dir) is True
def test_still_detects_plain_description(self, tmp_path):
from specify_cli.integrations.kimi import _is_speckit_generated_skill
skill_dir = tmp_path / "speckit-plan"
self._write_skill(skill_dir, "Plain description")
assert _is_speckit_generated_skill(skill_dir) is True
@@ -181,52 +181,6 @@ def test_python_text_output_matches_bash(prereq_repo: Path) -> None:
assert _normalize_status_text(py.stdout) == _normalize_status_text(bash.stdout)
def test_python_text_output_survives_a_legacy_stdout_code_page(
prereq_repo: Path,
) -> None:
"""Text mode must not crash when stdout cannot encode the status glyphs.
On Windows sys.stdout falls back to the ANSI code page whenever it is not a
console — which is every time an agent or a workflow step captures the
output. U+2713 is unencodable in cp1252, so printing it raised
UnicodeEncodeError and truncated the report right after "AVAILABLE_DOCS:".
The ASCII fallback is the rendering these markers already have in-tree
(Test-FileExists in scripts/powershell/common.ps1, and
normalize_status_text here).
"""
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True)
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
# research.md is present and the rest are not, so BOTH status markers are
# produced in the same cp1252 subprocess: U+2713 for the available document
# and U+2717 for the missing ones. Asserting only one of them would let a
# fallback that always returned "[FAIL]" pass.
(feat / "research.md").write_text("# research\n", encoding="utf-8")
(feat / "contracts").mkdir() # present but empty -> reported missing
_write_feature_json(prereq_repo)
env = _clean_env()
env["PYTHONIOENCODING"] = "cp1252"
result = _run(_py_cmd(prereq_repo, "--include-tasks"), prereq_repo, env=env)
assert result.returncode == 0, result.stderr
assert "UnicodeEncodeError" not in result.stderr
assert "AVAILABLE_DOCS:" in result.stdout
# Every per-document line must still be there, not truncated away by the
# encode error.
for doc in (
"research.md",
"data-model.md",
"contracts/",
"quickstart.md",
"tasks.md",
):
assert doc in result.stdout, (doc, result.stdout)
# Both fallback markers, so neither branch of _status_marker can regress.
assert "[OK] research.md" in result.stdout, result.stdout
assert "[FAIL] quickstart.md" in result.stdout, result.stdout
@requires_bash
def test_python_help_output_matches_bash(prereq_repo: Path) -> None:
bash = _run(_bash_cmd(prereq_repo, "--help"), prereq_repo)
-188
View File
@@ -475,194 +475,6 @@ def test_safe_extract_tar_enforces_entry_and_size_limits(tmp_path):
safe_extract_tar(archive_path, tmp_path / "total", max_total_bytes=7)
def _truncated_tar_gz_bytes(keep_bytes):
"""Return the leading *keep_bytes* of a multi-member tar.gz's bytes.
A gzip stream cut short this way ends before its end-of-stream marker, so
reading it raises a bare ``EOFError`` from the gzip layer. ``tarfile``
decompresses lazily, so *where* that surfaces depends on how much is kept:
a very short prefix fails in ``tarfile.open`` itself, while a longer one
opens fine and only fails once members are iterated.
"""
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
for index in range(5):
info = tarfile.TarInfo(f"file{index}.txt")
content = bytes(range(256)) * 400
info.size = len(content)
archive.addfile(info, io.BytesIO(content))
return buffer.getvalue()[:keep_bytes]
def test_detect_archive_format_rejects_truncated_tar_gz(tmp_path):
# A gzip stream truncated before tarfile can read its first header raises a
# bare EOFError -- not a TarError -- from the format probe. Catching only
# TarError let it escape as a raw exception instead of leaving is_tar_gz
# False and reporting the module's clean format-mismatch error.
archive_path = tmp_path / "truncated.tar.gz"
archive_path.write_bytes(_truncated_tar_gz_bytes(64))
with pytest.raises(ValueError, match="format mismatch"):
detect_archive_format(archive_path)
@pytest.mark.parametrize("keep_bytes", [64, 512, 2048])
def test_safe_extract_tar_rejects_truncated_archive(tmp_path, keep_bytes):
# The same bare EOFError, from tarfile.open on a short prefix and from
# member iteration on a longer one. Both sites reported it raw.
archive_path = tmp_path / f"truncated-{keep_bytes}.tar.gz"
archive_path.write_bytes(_truncated_tar_gz_bytes(keep_bytes))
with pytest.raises(ValueError, match="Invalid tar.gz archive"):
safe_extract_tar(archive_path, tmp_path / f"out-{keep_bytes}")
def test_safe_extract_tar_wraps_truncation_in_caller_error_type(tmp_path):
# The leak bypassed the caller's domain error type entirely, so callers
# that only catch their own error (or ValueError) crashed the command.
archive_path = tmp_path / "truncated.tar.gz"
archive_path.write_bytes(_truncated_tar_gz_bytes(2048))
with pytest.raises(_CustomZipError, match="Invalid tar.gz archive"):
safe_extract_tar(
archive_path,
tmp_path / "out",
error_type=_CustomZipError,
)
def test_safe_extract_archive_rejects_truncated_tar_gz(tmp_path):
archive_path = tmp_path / "truncated.tar.gz"
archive_path.write_bytes(_truncated_tar_gz_bytes(2048))
with pytest.raises(ValueError):
safe_extract_archive(archive_path, tmp_path / "out")
#: Bytes of the first member's data that decompress cleanly before the invalid
#: deflate block. Must exceed the gzip read buffer so ``tarfile`` has to seek
#: forward over member data to reach the second header -- see
#: ``_corrupt_deflate_tar_gz_bytes``. The members are twice this size, so the
#: corruption stays well inside the first member's data.
_CORRUPT_DEFLATE_CLEAN_BYTES = 256 * 1024
_CORRUPT_DEFLATE_MEMBER_BYTES = 2 * _CORRUPT_DEFLATE_CLEAN_BYTES
def _corrupt_deflate_tar_gz_bytes():
"""Return a tar.gz whose deflate stream is corrupt mid-member.
Unlike truncation, which the gzip layer reports as ``EOFError``, an invalid
deflate block raises ``zlib.error``. ``tarfile`` converts that to
``ReadError`` when it surfaces while reading a member *header*, but the
forward seek it performs to skip over member *data* sits outside that
conversion, so the raw ``zlib.error`` escapes from there.
Two details keep this deterministic across zlib versions:
* The corruption is a block header whose ``BTYPE`` is the reserved value
``0b11``, which every zlib rejects as "invalid block type". Mangling
arbitrary bytes instead is *not* portable -- the garbage may still decode
structurally and fail the later gzip CRC check as ``BadGzipFile`` (an
``OSError``, which the handler already caught) rather than raising
``zlib.error`` at all.
* The stream is assembled by hand so the invalid block lands after
``_CORRUPT_DEFLATE_CLEAN_BYTES`` of valid data. That is past the gzip read
buffer, so the first header reads clean and the failure happens during the
seek over member data rather than during a header read.
"""
plain = io.BytesIO()
with tarfile.open(fileobj=plain, mode="w") as archive:
for index in range(2):
info = tarfile.TarInfo(f"file{index}.txt")
content = bytes((i * 7 + index) % 256 for i in range(1024)) * (
_CORRUPT_DEFLATE_MEMBER_BYTES // 1024
)
info.size = len(content)
archive.addfile(info, io.BytesIO(content))
clean_prefix = plain.getvalue()[:_CORRUPT_DEFLATE_CLEAN_BYTES]
compressor = zlib.compressobj(1, zlib.DEFLATED, -15)
deflate = compressor.compress(clean_prefix)
deflate += compressor.flush(zlib.Z_SYNC_FLUSH)
deflate += b"\x06" # BTYPE=0b11 (reserved) -> "invalid block type"
gzip_header = b"\x1f\x8b\x08\x00" + b"\x00" * 4 + b"\x00\xff"
trailer = struct.pack("<II", zlib.crc32(clean_prefix), len(clean_prefix))
return gzip_header + deflate + trailer
def test_corrupt_deflate_fixture_raises_bare_zlib_error():
# Guards the fixture itself: the tests below are only meaningful while this
# archive reaches the module as a bare zlib.error -- neither a TarError nor
# an OSError, so a (TarError, OSError) handler would miss it. If a future
# Python or zlib wraps it, this fails loudly instead of the coverage
# silently decaying into a duplicate of the EOFError cases.
archive_file = io.BytesIO(_corrupt_deflate_tar_gz_bytes())
with tarfile.open(fileobj=archive_file, mode="r:gz") as archive:
with pytest.raises(zlib.error) as excinfo:
for _member in archive:
pass
# The whole point of the zlib.error arm: a (TarError, OSError) handler --
# what the two extraction sites had before the fix -- does not catch this.
# tarfile.ReadError and gzip.BadGzipFile would both be caught already, so
# if the fixture ever degrades into one of those it proves nothing.
assert not isinstance(excinfo.value, tarfile.TarError)
assert not isinstance(excinfo.value, OSError)
def test_detect_archive_format_accepts_corrupt_deflate_tar_gz(tmp_path):
# Detection is a format probe, not an integrity check: tarfile.open reads
# only the first member header, which is intact here, so the archive is
# correctly identified as tar.gz and the corruption is caught later by
# safe_extract_tar (see the tests below).
#
# Note this does not exercise the probe's zlib.error handling, which is
# unreachable: the header read is inside tarfile's own
# zlib.error -> ReadError conversion, so the probe sees ReadError. The
# zlib.error arm of _TAR_DECOMPRESSION_ERRORS is defensive at this site and
# load-bearing only at the two safe_extract_tar sites.
archive_path = tmp_path / "corrupt.tar.gz"
archive_path.write_bytes(_corrupt_deflate_tar_gz_bytes())
assert detect_archive_format(archive_path) == "tar.gz"
def test_safe_extract_tar_rejects_corrupt_deflate(tmp_path):
archive_path = tmp_path / "corrupt.tar.gz"
archive_path.write_bytes(_corrupt_deflate_tar_gz_bytes())
with pytest.raises(ValueError, match="Invalid tar.gz archive"):
safe_extract_tar(archive_path, tmp_path / "out")
def test_safe_extract_tar_wraps_corrupt_deflate_in_caller_error_type(tmp_path):
# zlib.error must reach the caller's domain error type, exactly as EOFError
# does, so this cannot regress independently of the truncation handling.
archive_path = tmp_path / "corrupt.tar.gz"
archive_path.write_bytes(_corrupt_deflate_tar_gz_bytes())
with pytest.raises(_CustomZipError, match="Invalid tar.gz archive"):
safe_extract_tar(
archive_path,
tmp_path / "out",
error_type=_CustomZipError,
)
def test_safe_extract_archive_wraps_corrupt_deflate_in_caller_error_type(tmp_path):
archive_path = tmp_path / "corrupt.tar.gz"
archive_path.write_bytes(_corrupt_deflate_tar_gz_bytes())
with pytest.raises(_CustomZipError, match="Invalid tar.gz archive"):
safe_extract_archive(
archive_path,
tmp_path / "out",
error_type=_CustomZipError,
)
@pytest.mark.parametrize("suffix", [".zip", ".tar.gz", ".tgz"])
def test_safe_extract_archive_has_format_parity(tmp_path, suffix):
archive_path = tmp_path / f"package{suffix}"
-25
View File
@@ -1291,31 +1291,6 @@ class TestExtensionRegistry:
result = registry.list()
assert result == {}
def test_load_starts_fresh_for_non_utf8_registry(self, temp_dir):
"""A registry file with undecodable bytes must start fresh, not raise.
``_load()`` already treats malformed JSON as "corrupted registry,
start fresh", but a registry whose *bytes* cannot be decoded as UTF-8
raised a raw ``UnicodeDecodeError`` from the text-mode read before
JSON parsing began — the same corruption class reaching a different
exception type. Because the registry is loaded in ``__init__``, that
traceback broke *every* extension command on the project.
"""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
(extensions_dir / ExtensionRegistry.REGISTRY_FILE).write_bytes(
b"\xff\xfe not utf-8 \xc3\x28"
)
registry = ExtensionRegistry(extensions_dir)
assert registry.data == {
"schema_version": ExtensionRegistry.SCHEMA_VERSION,
"extensions": {},
}
assert registry.list() == {}
assert not registry.is_installed("test-ext")
# ===== ExtensionManager Tests =====
-176
View File
@@ -1,176 +0,0 @@
"""`specify init` must render user-supplied values literally, not as Rich markup.
`commands/init.py` interpolated the project name, `--integration`/`--script`
values and paths straight into Rich markup f-strings. A name containing a
tag-shaped bracket run was therefore consumed as markup:
* ``specify init "proj [v2]"`` succeeded and created the directory, but the
Next Steps panel printed ``cd proj`` -- a command that fails when pasted.
* ``specify init "app[/red]x"`` created the directory and then died with
``MarkupError``, so the user saw a traceback for a project that had in fact
been scaffolded.
Every sibling CLI module (extensions, presets, workflows, integrations) already
escapes user-controlled display values; init.py was the outlier.
"""
from __future__ import annotations
import os
import re
import subprocess
from pathlib import Path
import pytest
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.commands.init import _shell_quote_arg
from tests.conftest import requires_bash
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
def _strip(text: str) -> str:
return _ANSI.sub("", text or "")
def _init(tmp_path: Path, name: str):
"""Run a fully offline, non-interactive `specify init <name>`."""
previous = os.getcwd()
os.chdir(tmp_path)
try:
return CliRunner().invoke(
app,
[
"init",
name,
"--integration",
"generic",
"--integration-options",
"--commands-dir .agent/commands",
"--ignore-agent-tools",
"--offline",
],
catch_exceptions=True,
)
finally:
os.chdir(previous)
@pytest.mark.parametrize("name", ["proj [v2]", "my[bold]app"])
def test_next_steps_cd_shows_the_real_project_name(tmp_path: Path, name: str):
"""The `cd` line must name the directory that was actually created."""
result = _init(tmp_path, name)
assert result.exit_code == 0, _strip(result.stdout)
assert (tmp_path / name).is_dir()
out = _strip(result.stdout)
cd_lines = [line for line in out.splitlines() if "cd " in line]
assert cd_lines, out
assert f"cd {_shell_quote_arg(name)}" in " ".join(cd_lines), cd_lines
def test_closing_tag_in_project_name_does_not_crash(tmp_path: Path):
"""A name forming a closing tag raised MarkupError *after* the project had
been created, so init reported failure for work it had completed."""
name = "app[/red]x"
result = _init(tmp_path, name)
assert result.exception is None or not isinstance(
result.exception, Exception
) or "MarkupError" not in type(result.exception).__name__, (
f"unexpected {type(result.exception).__name__}: {result.exception}"
)
assert result.exit_code == 0, _strip(result.stdout)
assert (tmp_path / name).is_dir()
assert f"cd {_shell_quote_arg(name)}" in _strip(result.stdout)
def test_invalid_integration_value_is_rendered_literally(tmp_path: Path):
"""An invalid `--integration` value is echoed back; it must not be parsed as
markup (nor raise) when it contains a bracket run."""
previous = os.getcwd()
os.chdir(tmp_path)
try:
result = CliRunner().invoke(
app,
["init", "proj", "--integration", "nope[/red]", "--ignore-agent-tools"],
catch_exceptions=True,
)
finally:
os.chdir(previous)
assert result.exit_code != 0
assert "nope[/red]" in _strip(result.stdout)
def _cd_argument(stdout: str) -> str:
"""Return the argument of the printed `cd` command, verbatim.
The line is rendered inside a Rich panel, so the trailing box-drawing
border and its padding are stripped before the argument is compared.
"""
marker = "Go to the project folder: cd "
for line in _strip(stdout).splitlines():
if marker in line:
return line.split(marker, 1)[1].rstrip().rstrip("│").rstrip()
raise AssertionError(f"no cd line in output:\n{stdout}")
@pytest.mark.parametrize("name", ["proj v2", "my project"])
def test_cd_line_quotes_a_name_containing_whitespace(tmp_path: Path, name: str):
"""Rich-escaping alone left `cd proj v2`, which every shell reads as two
arguments, so the copy-pasted command did not enter the directory."""
result = _init(tmp_path, name)
assert result.exit_code == 0, _strip(result.stdout)
assert (tmp_path / name).is_dir()
printed = _cd_argument(result.stdout)
assert printed != name, "a whitespace-bearing name must be quoted"
assert name in printed, printed
assert printed == _shell_quote_arg(name)
def test_ordinary_name_is_not_quoted(tmp_path: Path):
"""The common case must stay byte-identical: no gratuitous quoting."""
result = _init(tmp_path, "my-project")
assert result.exit_code == 0, _strip(result.stdout)
assert _cd_argument(result.stdout) == "my-project"
@requires_bash
@pytest.mark.parametrize("name", ["proj v2", "proj [v2]", "my-project"])
def test_printed_cd_command_actually_changes_directory(tmp_path: Path, name: str):
"""Execute the printed command rather than only inspecting it.
This is the assertion the string comparisons cannot make: the rendered
`cd <arg>` is fed to a real shell and must land in the created directory.
"""
result = _init(tmp_path, name)
assert result.exit_code == 0, _strip(result.stdout)
target = tmp_path / name
assert target.is_dir()
printed = _cd_argument(result.stdout)
proc = subprocess.run(
["bash", "-c", f"cd {printed} && pwd"],
cwd=tmp_path,
capture_output=True,
text=True,
)
assert proc.returncode == 0, f"cd {printed!r} failed: {proc.stderr}"
assert Path(proc.stdout.strip()).name == name, proc.stdout
def test_shell_quote_arg_is_host_appropriate():
"""The helper follows `_version._render_argv`: list2cmdline on Windows,
shlex.quote elsewhere. Names needing no quoting round-trip unchanged."""
assert _shell_quote_arg("my-project") == "my-project"
quoted = _shell_quote_arg("my project")
assert quoted != "my project"
if os.name == "nt":
assert quoted == '"my project"'
else:
assert quoted == "'my project'"
-107
View File
@@ -11425,113 +11425,6 @@ class TestResolveContent:
content = resolver.resolve_content("nonexistent")
assert content is None
def test_resolve_content_unreadable_winning_layer_returns_none(self, project_dir):
"""An undecodable winning layer must yield None, not a raw traceback.
``collect_all_layers`` deliberately keeps a non-UTF-8 legacy command
layer (with its ``replace`` default) so unrelated commands still
resolve. ``resolve_content`` then read that same file without a
boundary, so the tolerated layer crashed with ``UnicodeDecodeError``
at composition time — reachable from ``specify preset add`` via
``_register_commands``. The documented contract is "Composed content
string, or None if not found".
"""
presets_dir = project_dir / ".specify" / "presets"
command_path = (
presets_dir / "legacy-pack" / "commands" / "speckit.legacy.md"
)
command_path.parent.mkdir(parents=True)
command_path.write_bytes(b"\xff\xfe")
PresetRegistry(presets_dir).add(
"legacy-pack", {"version": "1.0.0", "priority": 10}
)
resolver = PresetResolver(project_dir)
content = resolver.resolve_content("speckit.legacy", "command")
assert content is None
def test_resolve_content_unreadable_base_under_composing_layer(
self, project_dir, temp_dir, valid_pack_data
):
"""An undecodable base beneath a valid composing layer yields None.
Covers the base-read guard: the winning layer composes (append), so
resolution reads the base layer beneath it — here the core template,
corrupted to non-UTF-8 — and must return None instead of crashing.
"""
pack_data = {**valid_pack_data}
pack_data["preset"] = {**valid_pack_data["preset"], "id": "append-pack", "name": "Append"}
pack_data["provides"] = {
"templates": [{
"type": "template",
"name": "spec-template",
"file": "templates/spec-template.md",
"strategy": "append",
}]
}
pack_dir = temp_dir / "append-pack"
pack_dir.mkdir()
with open(pack_dir / "preset.yml", 'w') as f:
yaml.dump(pack_data, f)
(pack_dir / "templates").mkdir()
(pack_dir / "templates" / "spec-template.md").write_text("## Appended Section\n")
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
core_spec = project_dir / ".specify" / "templates" / "spec-template.md"
core_spec.write_bytes(b"\xff\xfe")
resolver = PresetResolver(project_dir)
assert resolver.resolve_content("spec-template") is None
def test_resolve_content_unreadable_composing_layer(
self, project_dir, temp_dir, valid_pack_data, monkeypatch
):
"""An unreadable composing layer over a valid base yields None.
Covers the composition-loop read and the ``OSError`` half of the
boundary: the base (core template) reads fine, but the append layer
raises a mocked ``PermissionError`` — mocked so the case also holds
under privileged CI where permission bits are not enforced.
"""
pack_data = {**valid_pack_data}
pack_data["preset"] = {**valid_pack_data["preset"], "id": "append-pack", "name": "Append"}
pack_data["provides"] = {
"templates": [{
"type": "template",
"name": "spec-template",
"file": "templates/spec-template.md",
"strategy": "append",
}]
}
pack_dir = temp_dir / "append-pack"
pack_dir.mkdir()
with open(pack_dir / "preset.yml", 'w') as f:
yaml.dump(pack_data, f)
(pack_dir / "templates").mkdir()
(pack_dir / "templates" / "spec-template.md").write_text("## Appended Section\n")
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
layer_path = (
project_dir / ".specify" / "presets" / "append-pack"
/ "templates" / "spec-template.md"
)
assert layer_path.is_file()
original_read_text = Path.read_text
def failing_read_text(self_path, *args, **kwargs):
if self_path == layer_path:
raise PermissionError(13, "Permission denied")
return original_read_text(self_path, *args, **kwargs)
monkeypatch.setattr(Path, "read_text", failing_read_text)
resolver = PresetResolver(project_dir)
assert resolver.resolve_content("spec-template") is None
def test_resolve_content_replace_strategy(self, project_dir, temp_dir, valid_pack_data):
"""Test resolve_content with default replace strategy."""
manager = PresetManager(project_dir)
-25
View File
@@ -205,28 +205,3 @@ def test_missing_template_error_matches_all_variants(repo: Path) -> None:
assert bash.returncode == ps.returncode == py.returncode == 1
assert bash.stdout == ps.stdout == py.stdout == ""
assert bash.stderr == ps.stderr == py.stderr
def test_python_text_output_survives_a_legacy_stdout_code_page(repo: Path) -> None:
"""Text mode must not crash when stdout cannot encode the status glyphs.
On Windows sys.stdout falls back to the ANSI code page whenever it is not a
console — which is every time an agent or a workflow step captures the
output. U+2713 is unencodable in cp1252, so printing it raised
UnicodeEncodeError and truncated the document listing. The ASCII fallback is
the rendering these markers already have in-tree (Test-FileExists in
scripts/powershell/common.ps1, and normalize_status_text).
"""
feature = repo / "specs" / "001-my-feature"
(feature / "research.md").write_text("# research\n", encoding="utf-8")
(feature / "contracts").mkdir()
env = clean_env()
env["PYTHONIOENCODING"] = "cp1252"
result = run(py_cmd(repo, SCRIPT), repo, env=env)
assert result.returncode == 0, result.stderr
assert "UnicodeEncodeError" not in result.stderr
for doc in ("research.md", "data-model.md", "contracts/", "quickstart.md"):
assert doc in result.stdout, (doc, result.stdout)
assert "[OK] research.md" in normalize_status_text(result.stdout), result.stdout
-103
View File
@@ -1,103 +0,0 @@
"""Tests for the managed ``.specify/.gitignore`` written by shared-infra install.
The Specify CLI scaffolds a ``.specify/.gitignore`` so machine-local Spec Kit
state (the ``feature.json`` current-feature pointer and per-machine extension
``local-config.yml`` overrides) stays out of version control while everything
else under ``.specify/`` remains shareable. These tests pin that behaviour:
the file is created and manifest-tracked, its patterns actually make git ignore
the intended paths, user edits are preserved on a plain re-run, and ``--force``
restores the managed content.
"""
from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
import pytest
from specify_cli import _install_shared_infra
from specify_cli.shared_infra import SPECIFY_GITIGNORE_CONTENT
def _install(project: Path, **kwargs) -> None:
(project / ".specify").mkdir(parents=True, exist_ok=True)
_install_shared_infra(project, "sh", **kwargs)
def test_gitignore_is_written_and_tracked(tmp_path: Path) -> None:
project = tmp_path / "proj"
_install(project)
gitignore = project / ".specify" / ".gitignore"
assert gitignore.is_file()
content = gitignore.read_text(encoding="utf-8")
assert "feature.json" in content
assert "extensions/*/local-config.yml" in content
manifest = json.loads(
(project / ".specify" / "integrations" / "speckit.manifest.json").read_text(
encoding="utf-8"
)
)
assert ".specify/.gitignore" in manifest.get("files", {})
@pytest.mark.skipif(shutil.which("git") is None, reason="git not available")
def test_git_ignores_the_intended_paths(tmp_path: Path) -> None:
project = tmp_path / "proj"
project.mkdir()
subprocess.run(["git", "init", "-q"], cwd=project, check=True)
_install(project)
(project / ".specify" / "feature.json").write_text("{}", encoding="utf-8")
ext_local = project / ".specify" / "extensions" / "git" / "local-config.yml"
ext_local.parent.mkdir(parents=True, exist_ok=True)
ext_local.write_text("x\n", encoding="utf-8")
for rel in (
".specify/feature.json",
".specify/extensions/git/local-config.yml",
):
result = subprocess.run(
["git", "check-ignore", rel],
cwd=project,
capture_output=True,
text=True,
)
assert result.returncode == 0, f"{rel} was not ignored"
# A shareable file under .specify/ must NOT be ignored.
tracked = subprocess.run(
["git", "check-ignore", ".specify/memory/constitution.md"],
cwd=project,
capture_output=True,
text=True,
)
assert tracked.returncode == 1
def test_user_edits_preserved_by_default(tmp_path: Path) -> None:
project = tmp_path / "proj"
_install(project)
gitignore = project / ".specify" / ".gitignore"
gitignore.write_text("# my customization\n", encoding="utf-8")
_install(project) # plain re-run must not clobber user edits
assert gitignore.read_text(encoding="utf-8") == "# my customization\n"
def test_force_restores_managed_content(tmp_path: Path) -> None:
project = tmp_path / "proj"
_install(project)
gitignore = project / ".specify" / ".gitignore"
gitignore.write_text("# my customization\n", encoding="utf-8")
_install(project, force=True)
assert gitignore.read_text(encoding="utf-8") == SPECIFY_GITIGNORE_CONTENT
-51
View File
@@ -16705,57 +16705,6 @@ steps:
assert "corrupt run state" not in captured.out
assert captured.out.strip() == ""
def test_status_unreadable_run_state_exits_cleanly(
self, project_dir, monkeypatch
):
"""`workflow status <run_id>` gained a ValueError boundary to match
`workflow resume`, but not resume's OSError one -- so an unreadable
state.json (bad permissions, a directory in its place, an I/O error)
still leaked a raw traceback. exists() is True for a directory, so
the guard passes and open() raises OSError."""
from typer.testing import CliRunner
from specify_cli import app
monkeypatch.chdir(project_dir)
runs_dir = project_dir / ".specify" / "workflows" / "runs" / "abc123"
runs_dir.mkdir(parents=True, exist_ok=True)
# A directory where state.json should be: exists() passes, open() fails.
(runs_dir / "state.json").mkdir(exist_ok=True)
runner = CliRunner()
result = runner.invoke(app, ["workflow", "status", "abc123"])
assert result.exit_code != 0
assert result.exception is None or isinstance(result.exception, SystemExit)
assert "Error" in result.output
def test_status_json_unreadable_run_state_error_goes_to_stderr(
self, project_dir, monkeypatch, capsys
):
"""The OSError handler must route to stderr under --json too, so the
stdout JSON stream stays parseable -- mirroring the sibling
FileNotFoundError/ValueError handlers."""
import typer
from specify_cli.workflows import _commands
from specify_cli.workflows.engine import RunState
(project_dir / ".specify" / "workflows").mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(
_commands, "_require_specify_project", lambda: project_dir
)
def _raise_os_error(*args, **kwargs):
raise PermissionError(13, "Permission denied")
monkeypatch.setattr(RunState, "load", _raise_os_error)
with pytest.raises(typer.Exit) as exc:
_commands.workflow_status("some-run", json_output=True)
assert exc.value.exit_code == 1
captured = capsys.readouterr()
assert "Permission denied" in captured.err
assert "Permission denied" not in captured.out
assert captured.out.strip() == ""
def test_status_no_run_id_list_path_unaffected(self, project_dir, monkeypatch):
"""The no-run-id list-all-runs path must remain unaffected by the
new single-run ValueError boundary."""