Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
d14ccb8641 chore: bump version to 0.12.7 2026-07-07 20:00:30 +00:00
38 changed files with 151 additions and 2104 deletions

View File

@@ -2,21 +2,6 @@
<!-- insert new changelog below this comment -->
## [0.12.8] - 2026-07-08
### Changed
- [extension] Add LLM Wiki extension to community catalog (#3361)
- Docs: Document missing CLI flags and integrations (#3182)
- Docs: Remove Cursor from CLI check list in README (#3184)
- feat(extensions): port update-agent-context to Python (#3387)
- fix(scripts): fall through to grep/sed when python3 is a broken stub in feature.json parser (#3312)
- fix(toml): escape control characters so generated command files parse (#3341)
- fix(cli): exit cleanly on malformed IPv6 URLs in `extension`/`preset`/`workflow add` (#3369)
- fix(github-http): return None on malformed GHES port instead of raising (#3379)
- fix(integrations): guard _sha256 against unreadable managed files (#3376)
- chore: release 0.12.7, begin 0.12.8.dev0 development (#3398)
## [0.12.7] - 2026-07-07
### Changed

View File

@@ -406,7 +406,7 @@ specify init . --force --integration copilot
specify init --here --force --integration copilot
```
The CLI checks that the selected integration's required CLI tool is installed on your machine when that integration has `requires_cli: True`. If you do not have the required tool installed, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command:
The CLI will check that your selected agent's CLI tool is installed (for integrations that require a CLI), such as Claude Code, Gemini CLI, Qwen Code, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi Coding Agent, Oh My Pi, Forge, Goose, Mistral Vibe, or ZCode. If you don't have the required tool installed, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command:
```bash
specify init <project_name> --integration copilot --ignore-agent-tools

View File

@@ -52,14 +52,13 @@ The following community-contributed extensions are available in [`catalog.commun
| Cost Tracker | Track real LLM dollar cost across SDD workflows — per-feature budgets, per-integration comparison, and finance-ready exports | `visibility` | Read+Write | [spec-kit-cost](https://github.com/Quratulain-bilal/spec-kit-cost) |
| Data Model Diagram | Generates Mermaid ER diagrams from Spec Kit data models after planning | `docs` | Read+Write | [spec-kit-data-model-diagram](https://github.com/benizzio/spec-kit-data-model-diagram) |
| DocGuard — CDD Enforcement | The only doc-integrity engine with an MCP server, SARIF output, and a deterministic zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, GitHub Action with PR annotations, spec-kit hooks. Pure Node.js, one pinned dep. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
| EARS Requirements Syntax | Author, lint, and convert requirements using EARS - the five industry-standard sentence patterns for unambiguous, testable requirements | `docs` | Read+Write | [spec-kit-ears](https://github.com/dhruv-15-03/spec-kit-ears) |
| Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) |
| Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) |
| FixIt Extension | Spec-aware bug fixing — maps bugs to spec artifacts, proposes a plan, applies minimal changes | `code` | Read+Write | [spec-kit-fixit](https://github.com/speckit-community/spec-kit-fixit) |
| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |
| GitHub Issues Integration 1 | Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability | `integration` | Read+Write | [spec-kit-github-issues](https://github.com/Fatima367/spec-kit-github-issues) |
| GitHub Issues Integration 2 | Creates and syncs local specs from an existing GitHub issue | `integration` | Read+Write | [spec-kit-issue](https://github.com/aaronrsun/spec-kit-issue) |
| Golden Demo | Deterministic behavioral drift oracle. Extracts acceptance criteria, generates fuzz test vectors (seed=42), compares golden Python implementations against real code in any language. CI/CD gatekeeper with warn/strict modes. | `docs` | Read+Write | [spec-kit-golden-demo](https://github.com/jasstt/spec-kit-golden-demo) |
| Golden Demo | Extracts acceptance criteria from specs, builds test vectors, and produces a behavioral drift report — complementary to Architecture Guard and CDD | `docs` | Read+Write | [spec-kit-golden-demo](https://github.com/jasstt/spec-kit-golden-demo) |
| 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) |
@@ -68,7 +67,6 @@ The following community-contributed extensions are available in [`catalog.commun
| 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) |
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
| Linear Integration | Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional). | `integration` | Read+Write | [spec-kit-linear-sync](https://github.com/ashbrener/spec-kit-linear-sync) |
| LLM Wiki | LLM-maintained compounding project wiki: source ingestion, cited answers, and consistency linting | `docs` | Read+Write | [spec-kit-wiki](https://github.com/formin/spec-kit-wiki) |
| Loop Engineering | Engineer safe autonomous agent loops for spec-driven development: a maker/checker split, externalized loop state, and stay-the-engineer guardrails against comprehension debt and cognitive surrender | `process` | Read+Write | [spec-kit-loop](https://github.com/formin/spec-kit-loop) |
| MAQA — Multi-Agent & Quality Assurance | Coordinator → feature → QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins. Optional CI gate. | `process` | Read+Write | [spec-kit-maqa-ext](https://github.com/GenieRobot/spec-kit-maqa-ext) |
| MAQA Azure DevOps Integration | Azure DevOps Boards integration for MAQA — syncs User Stories and Task children as features progress | `integration` | Read+Write | [spec-kit-maqa-azure-devops](https://github.com/GenieRobot/spec-kit-maqa-azure-devops) |

View File

@@ -26,7 +26,7 @@ specify extension add <name>
| --------------- | -------------------------------------------------------- |
| `--dev` | Install from a local directory (for development) |
| `--from <url>` | Install from a custom URL instead of the catalog |
| `--force` | Overwrite if the extension is already installed |
| `--force` | Overwrite if already installed |
| `--priority <N>`| Resolution priority (default: 10; lower = higher precedence) |
Installs an extension from the catalog, a URL, or a local directory. Extension commands are automatically registered with the currently installed AI coding agent integration.

View File

@@ -1,352 +0,0 @@
#!/usr/bin/env python3
"""Refresh the managed Spec Kit section in the coding agent's context file(s).
Python port of ``update-agent-context.sh`` / ``update-agent-context.ps1``.
Reads ``context_files`` or ``context_file``, plus ``context_markers.{start,end}``,
from the agent-context extension config:
.specify/extensions/agent-context/agent-context-config.yml
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 ``specs/*/plan.md`` only when feature.json is absent or its
plan does not exist yet.
"""
from __future__ import annotations
import json
import os
import re
import sys
from pathlib import Path
DEFAULT_START = "<!-- SPECKIT START -->"
DEFAULT_END = "<!-- SPECKIT END -->"
def _err(message: str) -> None:
print(message, file=sys.stderr)
def _get_str(obj: object, *keys: str) -> str:
node = obj
for key in keys:
if isinstance(node, dict) and key in node:
node = node[key]
else:
return ""
return node if isinstance(node, str) else ""
def _collect_context_files(data: dict, project_root: str) -> list[str]:
"""Resolve the managed context files from config, mirroring the bash logic."""
context_files: list[str] = []
seen: set[str] = set()
case_insensitive = sys.platform.startswith(("win32", "cygwin", "msys"))
def add(value: object) -> None:
if not isinstance(value, str):
return
candidate = value.strip()
if not candidate:
return
key = candidate.casefold() if case_insensitive else candidate
if key in seen:
return
context_files.append(candidate)
seen.add(key)
raw_files = data.get("context_files")
if isinstance(raw_files, list):
for value in raw_files:
add(value)
if not context_files:
add(_get_str(data, "context_file"))
if not context_files:
# Self-seed: when the config declares no target, derive one from the
# active integration recorded in init-options.json, mapped through the
# bundled agent-context-defaults.json file. Independent of the Specify
# CLI by design.
integration_key = ""
try:
with open(
f"{project_root}/.specify/init-options.json", "r", encoding="utf-8"
) as fh:
opts = json.load(fh)
if isinstance(opts, dict):
value = opts.get("integration") or opts.get("ai") or ""
integration_key = value if isinstance(value, str) else ""
except Exception:
integration_key = ""
if integration_key:
defaults_path = (
f"{project_root}/.specify/extensions/agent-context/"
"agent-context-defaults.json"
)
mapping = {}
try:
with open(defaults_path, "r", encoding="utf-8") as fh:
loaded = json.load(fh)
agents = loaded.get("agents", {}) if isinstance(loaded, dict) else {}
mapping = agents if isinstance(agents, dict) else {}
except Exception:
_err(
"agent-context: unable to read %s; cannot self-seed the context "
"file. Set context_file in the extension config." % defaults_path
)
mapping = {}
add(mapping.get(integration_key, "") or "")
if not context_files:
_err(
"agent-context: no default context file is known for integration "
"%s. Set context_file in the extension config to choose one."
% integration_key
)
return context_files
def _validate_context_file(project_root: str, context_file: str) -> str | None:
"""Return an error message when the path escapes the project root."""
if context_file.startswith("/") or re.match(r"^[A-Za-z]:", context_file):
return (
"agent-context: context files must be project-relative paths; "
f"got '{context_file}'."
)
if "\\" in context_file:
return (
"agent-context: context files must not contain backslash separators; "
f"got '{context_file}'."
)
if ".." in context_file.split("/"):
return (
"agent-context: context files must not contain '..' path segments; "
f"got '{context_file}'."
)
root = Path(project_root).resolve()
target = (root / context_file).resolve()
try:
target.relative_to(root)
except ValueError:
return (
"agent-context: context file path resolves outside the project root; "
f"got '{context_file}'."
)
return None
def _resolve_plan_path(project_root: str) -> str:
"""Derive the plan path: feature.json first, then the mtime fallback."""
plan_path = ""
feature_json = Path(project_root) / ".specify" / "feature.json"
if feature_json.is_file():
feature_dir = ""
try:
with open(feature_json, "r", encoding="utf-8") as fh:
data = json.load(fh)
value = data.get("feature_directory", "")
feature_dir = value if isinstance(value, str) else ""
except Exception:
feature_dir = ""
# Normalize backslashes (written by PS on Windows) before path ops.
feature_dir = feature_dir.replace("\\", "/").rstrip("/")
if feature_dir:
# feature_directory may be relative or absolute (absolute paths
# outside the project root are preserved as-is), including
# drive-qualified paths (C:/...) written by PowerShell on Windows.
if feature_dir.startswith("/") or re.match(r"^[A-Za-z]:/", feature_dir):
candidate = Path(feature_dir) / "plan.md"
else:
candidate = Path(project_root) / feature_dir / "plan.md"
if candidate.is_file():
# Resolve symlinks before comparing so paths like /var/… vs
# /private/var/… (macOS) are treated as equivalent.
root = Path(project_root).resolve()
resolved = candidate.resolve()
try:
plan_path = resolved.relative_to(root).as_posix()
except ValueError:
plan_path = resolved.as_posix()
if not plan_path:
root = Path(project_root).resolve()
plans = sorted(
(root / "specs").glob("*/plan.md"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
if plans:
try:
plan_path = plans[0].relative_to(root).as_posix()
except ValueError:
plan_path = ""
return plan_path
def _build_section(marker_start: str, marker_end: str, plan_path: str) -> str:
lines = [
marker_start,
"For additional context about technologies to be used, project structure,",
"shell commands, and other important information, read the current plan",
]
if plan_path:
lines.append(f"at {plan_path}")
lines.append(marker_end)
return "\n".join(lines) + "\n"
def ensure_mdc_frontmatter(content: str) -> str:
"""Ensure ``.mdc`` content has YAML frontmatter with ``alwaysApply: true``.
Cursor only auto-loads ``.mdc`` rule files that carry frontmatter with
``alwaysApply: true``. Prepend it when missing, or repair the value while
preserving any existing frontmatter comments/formatting.
"""
leading_ws = len(content) - len(content.lstrip())
leading = content[:leading_ws]
stripped = content[leading_ws:]
if not stripped.startswith("---"):
return "---\nalwaysApply: true\n---\n\n" + content
match = re.match(
r"^(---[ \t]*\r?\n)(.*?)(\r?\n---[ \t]*)(\r?\n|$)(.*)",
stripped,
re.DOTALL,
)
if not match:
return "---\nalwaysApply: true\n---\n\n" + content
opening, fm_text, closing, sep, rest = match.groups()
newline = "\r\n" if "\r\n" in opening else "\n"
if re.search(r"(?m)^[ \t]*alwaysApply[ \t]*:[ \t]*true[ \t]*(?:#.*)?$", fm_text):
return content
if re.search(r"(?m)^[ \t]*alwaysApply[ \t]*:", fm_text):
fm_text = re.sub(
r"(?m)^([ \t]*)alwaysApply[ \t]*:.*?([ \t]*(?:#.*)?)$",
r"\1alwaysApply: true\2",
fm_text,
count=1,
)
elif fm_text.strip():
fm_text = fm_text + newline + "alwaysApply: true"
else:
fm_text = "alwaysApply: true"
return f"{leading}{opening}{fm_text}{closing}{sep}{rest}"
def _upsert_section(
ctx_path: str, marker_start: str, marker_end: str, section: str
) -> None:
"""Insert or replace the managed section, then normalize and write."""
if os.path.exists(ctx_path):
with open(ctx_path, "r", encoding="utf-8-sig") as fh:
content = fh.read()
s = content.find(marker_start)
e = content.find(marker_end, s if s != -1 else 0)
if s != -1 and e != -1 and e > s:
end_of_marker = e + len(marker_end)
if end_of_marker < len(content) and content[end_of_marker] == "\r":
end_of_marker += 1
if end_of_marker < len(content) and content[end_of_marker] == "\n":
end_of_marker += 1
new_content = content[:s] + section + content[end_of_marker:]
elif s != -1:
new_content = content[:s] + section
elif e != -1:
end_of_marker = e + len(marker_end)
if end_of_marker < len(content) and content[end_of_marker] == "\r":
end_of_marker += 1
if end_of_marker < len(content) and content[end_of_marker] == "\n":
end_of_marker += 1
new_content = section + content[end_of_marker:]
else:
if content and not content.endswith("\n"):
content += "\n"
new_content = (content + "\n" + section) if content else section
else:
new_content = section
new_content = new_content.replace("\r\n", "\n").replace("\r", "\n")
if ctx_path.casefold().endswith(".mdc"):
new_content = ensure_mdc_frontmatter(new_content)
with open(ctx_path, "wb") as fh:
fh.write(new_content.encode("utf-8"))
def main(argv: list[str] | None = None) -> int:
args = sys.argv[1:] if argv is None else argv
project_root = os.getcwd()
ext_config = (
f"{project_root}/.specify/extensions/agent-context/agent-context-config.yml"
)
if not os.path.isfile(ext_config):
_err(f"agent-context: {ext_config} not found; nothing to do.")
return 0
try:
import yaml
except ImportError:
_err(
"agent-context: PyYAML is required to parse extension config but is "
"not available in the current Python environment.\n"
" To resolve: pip install pyyaml (or install it into the environment "
"used by python3).\n"
" Context file will not be updated until PyYAML is importable."
)
_err("agent-context: skipping update (see above for details).")
return 0
try:
with open(ext_config, "r", encoding="utf-8") as fh:
data = yaml.safe_load(fh)
except Exception as exc:
_err(
f"agent-context: unable to parse {ext_config} ({exc}); "
"cannot update context."
)
_err("agent-context: skipping update (see above for details).")
return 0
if not isinstance(data, dict):
data = {}
context_files = _collect_context_files(data, project_root)
if not context_files:
_err(
"agent-context: context_files/context_file not set in extension config; "
"nothing to do."
)
return 0
for context_file in context_files:
error = _validate_context_file(project_root, context_file)
if error:
_err(error)
return 1
marker_start = _get_str(data, "context_markers", "start") or DEFAULT_START
marker_end = _get_str(data, "context_markers", "end") or DEFAULT_END
plan_path = args[0] if args else ""
if not plan_path:
plan_path = _resolve_plan_path(project_root)
section = _build_section(marker_start, marker_end, plan_path)
for context_file in context_files:
ctx_path = os.path.join(project_root, context_file)
os.makedirs(os.path.dirname(ctx_path) or ".", exist_ok=True)
_upsert_section(ctx_path, marker_start, marker_end, section)
print(f"agent-context: updated {context_file}")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-08T00:00:00Z",
"updated_at": "2026-07-06T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -1180,39 +1180,6 @@
"created_at": "2026-03-13T00:00:00Z",
"updated_at": "2026-03-13T00:00:00Z"
},
"ears": {
"name": "EARS Requirements Syntax",
"id": "ears",
"description": "Author, lint, and convert requirements using EARS (Easy Approach to Requirements Syntax) - the five industry-standard sentence patterns for unambiguous, testable requirements.",
"author": "dhruv-15-03",
"version": "1.0.0",
"download_url": "https://github.com/dhruv-15-03/spec-kit-ears/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/dhruv-15-03/spec-kit-ears",
"homepage": "https://github.com/dhruv-15-03/spec-kit-ears",
"documentation": "https://github.com/dhruv-15-03/spec-kit-ears/blob/main/README.md",
"changelog": "https://github.com/dhruv-15-03/spec-kit-ears/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "docs",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.9.0"
},
"provides": {
"commands": 3,
"hooks": 0
},
"tags": [
"ears",
"requirements",
"specification",
"quality"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-08T00:00:00Z",
"updated_at": "2026-07-08T00:00:00Z"
},
"extensify": {
"name": "Extensify",
"id": "extensify",
@@ -1431,10 +1398,10 @@
"golden-demo": {
"name": "Golden Demo",
"id": "golden-demo",
"description": "Deterministic behavioral drift oracle. Extracts acceptance criteria, generates fuzz test vectors (seed=42), compares golden Python implementations against real code in any language. CI/CD gatekeeper with warn/strict modes.",
"description": "Extracts acceptance criteria from specs, builds test vectors, and produces a behavioral drift report — complementary to Architecture Guard and CDD.",
"author": "jasstt",
"version": "0.3.0",
"download_url": "https://github.com/jasstt/spec-kit-golden-demo/archive/refs/tags/v0.3.0.zip",
"version": "0.1.1",
"download_url": "https://github.com/jasstt/spec-kit-golden-demo/archive/refs/tags/v0.1.1.zip",
"repository": "https://github.com/jasstt/spec-kit-golden-demo",
"homepage": "https://github.com/jasstt/spec-kit-golden-demo",
"documentation": "https://github.com/jasstt/spec-kit-golden-demo",
@@ -1445,16 +1412,13 @@
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 3,
"commands": 2,
"hooks": 2
},
"tags": [
"testing",
"drift-detection",
"behavioral-oracle",
"fuzzing",
"ci-cd",
"cross-language",
"tdd",
"quality"
],
@@ -1462,7 +1426,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-06-24T00:00:00Z",
"updated_at": "2026-07-07T00:00:00Z"
"updated_at": "2026-06-24T00:00:00Z"
},
"harness": {
"name": "Research Harness",
@@ -4411,40 +4375,6 @@
"created_at": "2026-04-13T00:00:00Z",
"updated_at": "2026-04-13T00:00:00Z"
},
"wiki": {
"name": "LLM Wiki",
"id": "wiki",
"description": "LLM-maintained compounding project wiki: source ingestion, cited answers, and consistency linting",
"author": "formin",
"version": "1.0.0",
"download_url": "https://github.com/formin/spec-kit-wiki/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/formin/spec-kit-wiki",
"homepage": "https://github.com/formin/spec-kit-wiki",
"documentation": "https://github.com/formin/spec-kit-wiki/blob/main/README.md",
"changelog": "https://github.com/formin/spec-kit-wiki/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "docs",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.2.0"
},
"provides": {
"commands": 5,
"hooks": 2
},
"tags": [
"wiki",
"knowledge-base",
"docs",
"memory",
"context-management"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-06T00:00:00Z",
"updated_at": "2026-07-06T00:00:00Z"
},
"wireframe": {
"name": "Wireframe Visual Feedback Loop",
"id": "wireframe",

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.12.9.dev0"
version = "0.12.7"
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"

View File

@@ -97,26 +97,17 @@ read_feature_json_feature_directory() {
local fj="$repo_root/.specify/feature.json"
[[ -f "$fj" ]] || { printf '%s' ''; return 0; }
# Try parsers in order (jq -> python3 -> grep/sed), falling through on
# failure. Selection is by *parse success*, not mere availability: on
# Windows `python3` commonly resolves to the Microsoft Store App Execution
# Alias stub, which passes `command -v` but fails at runtime (exit 49), so
# an availability-gated `elif` would pick python3, swallow its failure, and
# never reach the grep/sed fallback -- leaving feature.json unreadable even
# though it is valid (issue #3304).
local _fd=''
if command -v jq >/dev/null 2>&1; then
if ! _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null); then
_fd=''
fi
fi
if [[ -z "$_fd" ]] && command -v python3 >/dev/null 2>&1; then
elif command -v python3 >/dev/null 2>&1; then
# Use Python so pretty-printed/multi-line JSON still parses correctly.
if ! _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get('feature_directory'); print(v if v else '')" "$fj" 2>/dev/null); then
_fd=''
fi
fi
if [[ -z "$_fd" ]]; then
else
# Last-resort single-line grep/sed fallback. The `|| true` guards against
# grep returning 1 (no match) aborting under `set -e` / `pipefail`.
_fd=$( { grep -E '"feature_directory"[[:space:]]*:' "$fj" 2>/dev/null || true; } \
@@ -244,29 +235,21 @@ get_invoke_separator() {
local integration_json="$repo_root/.specify/integration.json"
local separator="."
local parsed=0
local parsed_with_jq=0
if [[ -f "$integration_json" ]]; then
# Try parsers in order (jq -> python3 -> awk), falling through on
# failure. Selection is by *parse success*, not mere availability: on
# Windows `python3` commonly resolves to the Microsoft Store App
# Execution Alias stub, which passes `command -v` but fails at runtime
# (exit 49). An availability-gated branch would pick python3, swallow
# its failure, and — because this function historically had no text
# fallback — silently return "." even for `-`-separator integrations
# (e.g. forge, cline), yielding wrong command hints (issue #3304).
if command -v jq >/dev/null 2>&1; then
local jq_separator
if jq_separator=$(jq -r '(.default_integration // .integration // "") as $k | if $k == "" then "." else (.integration_settings[$k].invoke_separator // ".") end' "$integration_json" 2>/dev/null); then
parsed_with_jq=1
case "$jq_separator" in
"."|"-") separator="$jq_separator"; parsed=1 ;;
"."|"-") separator="$jq_separator" ;;
esac
fi
fi
if [[ "$parsed" -eq 0 ]] && command -v python3 >/dev/null 2>&1; then
local py_separator
if py_separator=$(python3 - "$integration_json" <<'PY' 2>/dev/null
if [[ "$parsed_with_jq" -eq 0 ]] && command -v python3 >/dev/null 2>&1; then
if separator=$(python3 - "$integration_json" <<'PY' 2>/dev/null
import json
import sys
@@ -282,64 +265,17 @@ try:
separator = entry["invoke_separator"]
print(separator)
except Exception:
sys.exit(1)
print(".")
PY
); then
case "$py_separator" in
"."|"-") separator="$py_separator"; parsed=1 ;;
case "$separator" in
"."|"-") ;;
*) separator="." ;;
esac
else
separator="."
fi
fi
if [[ "$parsed" -eq 0 ]]; then
# Last-resort text fallback for environments with neither jq nor a
# working python3 (e.g. stock Windows + Git Bash). Reads the active
# integration key (default_integration, else integration) and its
# invoke_separator from within the integration_settings object.
# Handles both pretty-printed (the written form) and compact JSON.
# Accumulate all lines into one buffer in END rather than using
# gawk-only whole-file slurp (RS="^$"), so this stays portable to
# the BSD awk on macOS.
local awk_separator
awk_separator=$(awk '
function keyval(d, name, v) {
if (match(d, "\"" name "\"[ \t\r\n]*:[ \t\r\n]*\"[^\"]*\"")) {
v=substr(d,RSTART,RLENGTH); sub(/^.*:[ \t\r\n]*"/,"",v); sub(/"$/,"",v); return v
}
return ""
}
{ doc = doc $0 "\n" }
END {
key=keyval(doc,"default_integration"); if (key=="") key=keyval(doc,"integration")
sep="."
if (key!="") {
settings=doc
if (match(doc, /"integration_settings"[ \t\r\n]*:[ \t\r\n]*[{]/)) {
settings=substr(doc, RSTART+RLENGTH-1)
}
if (match(settings, "\"" key "\"[ \t\r\n]*:[ \t\r\n]*[{]")) {
start=RSTART+RLENGTH-1
depth=0
obj=""
for (i=start; i<=length(settings); i++) {
c=substr(settings,i,1)
obj=obj c
if (c=="{") depth++
else if (c=="}") { depth--; if (depth==0) break }
}
if (match(obj, /"invoke_separator"[ \t\r\n]*:[ \t\r\n]*"[-.]"/)) {
tok=substr(obj,RSTART,RLENGTH); s=substr(tok,length(tok)-1,1)
if (s=="." || s=="-") sep=s
}
}
}
print sep
}
' "$integration_json" 2>/dev/null)
case "$awk_separator" in
"."|"-") separator="$awk_separator" ;;
esac
fi
fi
_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root"

View File

@@ -127,14 +127,7 @@ def resolve_github_release_asset_api_url(
if hostname == "github.com":
api_base = "https://api.github.com"
elif is_ghes:
# ``parsed.port`` raises ValueError on a malformed port (e.g.
# ``host:notaport``); the function's contract is to return None for
# anything it can't resolve, not to raise.
try:
port = parsed.port
except ValueError:
return None
authority = hostname if port is None else f"{hostname}:{port}"
authority = hostname if parsed.port is None else f"{hostname}:{parsed.port}"
api_base = f"{parsed.scheme}://{authority}/api/v3"
else:
return None

View File

@@ -1,60 +0,0 @@
"""Shared TOML string-escaping helpers.
Both TOML command renderers — ``TomlIntegration`` (gemini, tabnine) in
``specify_cli.integrations.base`` and ``CommandRegistrar.render_toml_command``
(extension/preset commands) in ``specify_cli.agents`` — need the same rules for
detecting characters TOML forbids literally and for emitting a fully-escaped
basic string. Keeping one implementation here avoids the two drifting apart if
the escaping rules change again.
"""
from __future__ import annotations
def has_illegal_toml_control(value: str) -> bool:
"""True when *value* contains a character TOML forbids literally.
TOML basic/literal strings (single- or multi-line) allow tab and, in the
multiline forms, newlines — but every other control character
(``U+0000````U+001F`` and ``U+007F``) must be ``\\u``-escaped, which only a
basic string can do. A bare carriage return counts too: a multiline basic
string treats ``\\r`` as a newline only when paired into ``\\r\\n``; a lone
``\\r`` is an illegal control character.
"""
length = len(value)
for i, ch in enumerate(value):
code = ord(ch)
if ch == "\r":
# Only a CR that is part of a CRLF newline is allowed literally.
if i + 1 < length and value[i + 1] == "\n":
continue
return True
if (code < 0x20 and ch not in ("\t", "\n")) or code == 0x7F:
return True
return False
def escape_toml_basic(value: str) -> str:
"""Render *value* as a single-line basic string, escaping everything.
Always valid TOML: backslash/quote are escaped, the common control chars
use their short escapes, and any remaining control character is emitted as
a ``\\uXXXX`` sequence.
"""
out: list[str] = []
for ch in value:
code = ord(ch)
if ch == "\\":
out.append("\\\\")
elif ch == '"':
out.append('\\"')
elif ch == "\n":
out.append("\\n")
elif ch == "\r":
out.append("\\r")
elif ch == "\t":
out.append("\\t")
elif code < 0x20 or code == 0x7F:
out.append(f"\\u{code:04x}")
else:
out.append(ch)
return '"' + "".join(out) + '"'

View File

@@ -16,8 +16,6 @@ from typing import Any, Dict, List, Optional
import yaml
from ._init_options import is_ai_skills_enabled, load_init_options
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 ._utils import relative_extension_path_violation
@@ -260,12 +258,7 @@ class CommandRegistrar:
# ``C:\\Users\\...`` whose ``\\U`` reads as an invalid unicode escape) would
# produce unparseable TOML — route those to the *literal* form ('''...'''),
# which does not process escapes, or to the escaped basic string.
# Control characters (U+0000U+001F except tab/newline, U+007F) and a bare
# CR are illegal in every TOML string form, so a body containing them must
# go to the escaped basic string regardless of which delimiters it uses.
if self._has_illegal_toml_control(body):
toml_lines.append(f"prompt = {self._render_basic_toml_string(body)}")
elif '"""' not in body and "\\" not in body:
if '"""' not in body and "\\" not in body:
toml_lines.append('prompt = """')
toml_lines.append(body)
toml_lines.append('"""')
@@ -278,11 +271,17 @@ class CommandRegistrar:
return "\n".join(toml_lines)
# Control-char detection and basic-string escaping are shared with the
# gemini/tabnine renderer in ``specify_cli.integrations.base`` via
# ``specify_cli._toml_string`` so the two never drift apart.
_has_illegal_toml_control = staticmethod(_has_illegal_toml_control)
_render_basic_toml_string = staticmethod(_escape_toml_basic)
@staticmethod
def _render_basic_toml_string(value: str) -> str:
"""Render *value* as a TOML basic string literal."""
escaped = (
value.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
)
return f'"{escaped}"'
def render_yaml_command(
self,
@@ -701,12 +700,6 @@ class CommandRegistrar:
)
output = self.render_toml_command(frontmatter, body, source_id)
elif agent_config["format"] == "yaml":
body = self.resolve_skill_placeholders(
agent_name, frontmatter, body, project_root
)
body = self._convert_argument_placeholder(
body, "$ARGUMENTS", agent_config["args"]
)
output = self.render_yaml_command(
frontmatter, body, source_id, cmd_name
)

View File

@@ -73,13 +73,6 @@ class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
self._redirect_validator = redirect_validator
def redirect_request(self, req, fp, code, msg, headers, newurl):
try:
new_parsed = urlparse(newurl)
except ValueError as exc:
# Malformed redirect target (e.g. unterminated IPv6 bracket).
# Surface as URLError so callers' download error handling applies.
raise urllib.error.URLError(f"malformed redirect URL: {exc}") from exc
if self._redirect_validator is not None:
self._redirect_validator(req.full_url, newurl)
@@ -90,6 +83,7 @@ class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
new_req = super().redirect_request(req, fp, code, msg, headers, newurl)
if new_req is not None:
old_scheme = urlparse(req.full_url).scheme
new_parsed = urlparse(newurl)
hostname = (new_parsed.hostname or "").lower()
is_https_downgrade = old_scheme == "https" and new_parsed.scheme != "https"
if _hostname_in_hosts(hostname, self._hosts) and not is_https_downgrade:

View File

@@ -33,13 +33,12 @@ DEFAULT_PRIORITY = 10
def _assert_pinned_version(
kind: str, component_id: str, pinned: str | None, advertised: object
) -> None:
"""Refuse to install when the resolved version differs from the manifest pin.
"""Refuse to install when the catalog version differs from the manifest pin.
Bundle manifests pin component versions for reproducibility; installing
whatever the resolved source (catalog *or* bundled asset) provides would
silently violate the pin. When the source advertises no version we cannot
enforce the pin, so installation proceeds (the source, not the bundler,
owns that gap).
whatever the active catalog currently serves would silently violate the
pin. When the catalog advertises no version we cannot enforce the pin, so
installation proceeds (the catalog, not the bundler, owns that gap).
"""
if not pinned or advertised is None:
return
@@ -55,35 +54,11 @@ def _assert_pinned_version(
if not matches:
raise BundlerError(
f"{kind} '{component_id}' is pinned to version {pinned} in the bundle "
f"manifest, but the resolved version is {actual}. Update the bundle's "
"pinned version or the source before installing."
f"manifest, but the active catalog serves {actual}. Update the bundle's "
"pinned version or the catalog before installing."
)
def _bundled_manifest_version(manifest_path: Path, root_key: str) -> str | None:
"""Best-effort read of a bundled asset's declared version from its manifest.
Returns ``None`` when the manifest is missing/unreadable/invalid, which
``_assert_pinned_version`` treats as "cannot enforce" (proceed) — matching
the catalog "advertises no version" escape hatch.
"""
try:
import yaml
data = yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
if isinstance(data, dict):
section = data.get(root_key)
if isinstance(section, dict):
version = section.get("version")
# Only a non-empty string is a usable version; anything else
# (missing / non-string / whitespace) means "cannot enforce".
if isinstance(version, str) and version.strip():
return version
except Exception: # noqa: BLE001 - unreadable/invalid manifest: skip pin
return None
return None
class _KindManager(Protocol):
def is_installed(self, component: ComponentRef) -> bool: ...
@@ -159,15 +134,6 @@ class _PresetKindManager:
bundled = _locate_bundled_preset(component.id)
if bundled is not None:
# Enforce the manifest pin against the bundled asset's own version,
# mirroring the catalog path below (the bundled path previously
# skipped the pin entirely).
_assert_pinned_version(
"Preset",
component.id,
component.version,
_bundled_manifest_version(bundled / "preset.yml", "preset"),
)
self._manager.install_from_directory(bundled, speckit_version, priority)
return
@@ -232,15 +198,6 @@ class _ExtensionKindManager:
bundled = _locate_bundled_extension(component.id)
if bundled is not None:
# Enforce the manifest pin against the bundled asset's own version,
# mirroring the catalog path below (the bundled path previously
# skipped the pin entirely).
_assert_pinned_version(
"Extension",
component.id,
component.version,
_bundled_manifest_version(bundled / "extension.yml", "extension"),
)
self._manager.install_from_directory(
bundled, speckit_version, priority=priority
)

View File

@@ -426,11 +426,7 @@ def extension_add(
if from_url and not dev:
from urllib.parse import urlparse
try:
parsed = urlparse(from_url)
except ValueError:
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}")
raise typer.Exit(1)
parsed = urlparse(from_url)
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):

View File

@@ -17,7 +17,6 @@ import os
import re
import shlex
import shutil
import subprocess
import sys
from abc import ABC
from dataclasses import dataclass
@@ -26,9 +25,6 @@ from typing import TYPE_CHECKING, Any
import yaml
from .._toml_string import escape_toml_basic as _escape_toml_basic
from .._toml_string import has_illegal_toml_control as _has_illegal_toml_control
if TYPE_CHECKING:
from .manifest import IntegrationManifest
@@ -55,18 +51,6 @@ _CORE_COMMAND_TEMPLATE_RANK = {
}
def yaml_quote(value: str) -> str:
"""Emit *value* as a double-quoted YAML scalar on a single line.
A hand-rolled quote cannot carry raw newlines (YAML folds them to
spaces) or control characters (the reader rejects them), so let the
YAML emitter produce the escapes.
"""
return yaml.safe_dump(
str(value), default_style='"', allow_unicode=True, width=sys.maxsize
).strip()
# ---------------------------------------------------------------------------
# IntegrationOption
# ---------------------------------------------------------------------------
@@ -605,42 +589,10 @@ class IntegrationBase(ABC):
if candidate.exists():
return relative
for name in ("python3", "python"):
found = shutil.which(name)
if not found:
continue
# On Windows, python3/python on PATH may be the Microsoft
# Store App Execution Alias stub: it exists but only prints
# an installer hint and exits non-zero, so existence is not
# enough (see #3304 for the same defect in the sh scripts).
if sys.platform == "win32" and not IntegrationBase._interpreter_runs(
found
):
continue
return name
if shutil.which(name):
return name
return sys.executable or "python3"
@staticmethod
def _interpreter_runs(path: str) -> bool:
"""Return True when *path* executes as a Python interpreter.
Runs isolated (``-I``) without ``site`` (``-S``) and discards
I/O so the probe is a fast liveness check that cannot trigger
``sitecustomize``/user startup hooks.
"""
try:
return (
subprocess.run(
[path, "-I", "-S", "-c", ""],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=15,
).returncode
== 0
)
except (OSError, subprocess.SubprocessError):
return False
@staticmethod
def process_template(
content: str,
@@ -1001,12 +953,6 @@ class TomlIntegration(IntegrationBase):
body = "".join(lines[frontmatter_end + 1 :])
return frontmatter, body
# Control-char detection and basic-string escaping are shared with the
# extension/preset renderer in ``specify_cli.agents`` via
# ``specify_cli._toml_string`` so the two never drift apart.
_has_illegal_toml_control = staticmethod(_has_illegal_toml_control)
_escape_toml_basic = staticmethod(_escape_toml_basic)
@staticmethod
def _render_toml_string(value: str) -> str:
"""Render *value* as a TOML string literal.
@@ -1016,12 +962,6 @@ class TomlIntegration(IntegrationBase):
literal string or escaped basic string when delimiters appear in
the content.
"""
# Control characters other than tab/newline (and a bare CR) cannot
# appear literally in any TOML string; route them to a fully-escaped
# basic string so the generated file stays parseable.
if TomlIntegration._has_illegal_toml_control(value):
return TomlIntegration._escape_toml_basic(value)
if "\n" not in value and "\r" not in value:
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
@@ -1034,7 +974,17 @@ class TomlIntegration(IntegrationBase):
if "'''" not in value and not value.endswith("'"):
return "'''\n" + value + "'''"
return TomlIntegration._escape_toml_basic(value)
return (
'"'
+ (
value.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
)
+ '"'
)
@staticmethod
def _render_toml(description: str, body: str) -> str:
@@ -1122,6 +1072,7 @@ class TomlIntegration(IntegrationBase):
# YamlIntegration — YAML-format agents (Goose)
# ---------------------------------------------------------------------------
class YamlIntegration(IntegrationBase):
"""Concrete base for integrations that use YAML recipe format.
@@ -1524,17 +1475,21 @@ class SkillsIntegration(IntegrationBase):
if not description:
description = f"Spec Kit: {command_name} workflow"
# Build SKILL.md with manually formatted frontmatter (stable
# double-quoted values). yaml_quote escapes newlines and control
# characters that a plain quoted f-string cannot carry.
# Build SKILL.md with manually formatted frontmatter to match
# the release packaging script output exactly (double-quoted
# values, no yaml.safe_dump quoting differences).
def _quote(v: str) -> str:
escaped = v.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
skill_content = (
f"---\n"
f"name: {yaml_quote(skill_name)}\n"
f"description: {yaml_quote(description)}\n"
f"compatibility: {yaml_quote('Requires spec-kit project structure with .specify/ directory')}\n"
f"name: {_quote(skill_name)}\n"
f"description: {_quote(description)}\n"
f"compatibility: {_quote('Requires spec-kit project structure with .specify/ directory')}\n"
f"metadata:\n"
f" author: {yaml_quote('github-spec-kit')}\n"
f" source: {yaml_quote('templates/commands/' + src_file.name)}\n"
f" author: {_quote('github-spec-kit')}\n"
f" source: {_quote('templates/commands/' + src_file.name)}\n"
f"---\n"
f"{processed_body}"
)

View File

@@ -18,7 +18,7 @@ from typing import Any
import yaml
from ..base import IntegrationOption, SkillsIntegration, yaml_quote
from ..base import IntegrationOption, SkillsIntegration
from ..manifest import IntegrationManifest
@@ -153,18 +153,20 @@ class HermesIntegration(SkillsIntegration):
if not description:
description = f"Spec Kit: {command_name} workflow"
# Build SKILL.md with manually formatted frontmatter. yaml_quote
# escapes newlines and control characters that a plain quoted
# f-string cannot carry.
# Build SKILL.md with manually formatted frontmatter
def _quote(v: str) -> str:
escaped = v.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
skill_content = (
f"---\n"
f"name: {yaml_quote(skill_name)}\n"
f"description: {yaml_quote(description)}\n"
f"name: {_quote(skill_name)}\n"
f"description: {_quote(description)}\n"
f"compatibility: "
f"{yaml_quote('Requires spec-kit project structure with .specify/ directory')}\n"
f"{_quote('Requires spec-kit project structure with .specify/ directory')}\n"
f"metadata:\n"
f" author: {yaml_quote('github-spec-kit')}\n"
f" source: {yaml_quote('templates/commands/' + src_file.name)}\n"
f" author: {_quote('github-spec-kit')}\n"
f" source: {_quote('templates/commands/' + src_file.name)}\n"
f"---\n"
f"{processed_body}"
)

View File

@@ -309,14 +309,7 @@ class IntegrationManifest:
if abs_path.is_symlink() or not abs_path.is_file():
modified.append(rel)
continue
try:
changed = _sha256(abs_path) != expected_hash
except OSError:
# Unreadable regular file (e.g. permission denied): treat as
# modified, consistent with the symlink / non-regular-file
# handling above, rather than letting the OSError escape.
changed = True
if changed:
if _sha256(abs_path) != expected_hash:
modified.append(rel)
return modified
@@ -365,17 +358,9 @@ class IntegrationManifest:
skipped.append(path)
continue
else:
if not force:
try:
matches = _sha256(path) == expected_hash
except OSError:
# Unreadable: can't verify it's ours, so preserve it
# (mirrors the path.unlink() OSError guard below).
skipped.append(path)
continue
if not matches:
skipped.append(path)
continue
if not force and _sha256(path) != expected_hash:
skipped.append(path)
continue
try:
path.unlink()
except OSError:

View File

@@ -104,13 +104,7 @@ def preset_add(
from ipaddress import ip_address
from urllib.parse import urlparse as _urlparse
try:
_parsed = _urlparse(from_url)
except ValueError:
from rich.markup import escape as _escape_markup
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}")
raise typer.Exit(1)
_parsed = _urlparse(from_url)
def _is_allowed_download_url(parsed_url):
host = parsed_url.hostname
@@ -141,9 +135,7 @@ def preset_add(
)
raise typer.Exit(1)
from rich.markup import escape as _esc
console.print(f"Installing preset from [cyan]{_esc(from_url)}[/cyan]...")
console.print(f"Installing preset from [cyan]{from_url}[/cyan]...")
import urllib.error
import tempfile
import shutil

View File

@@ -328,10 +328,7 @@ def refresh_shared_templates(
_ensure_safe_shared_destination(project_path, dst)
rel = dst.relative_to(project_path).as_posix()
if dst.exists() and not force:
if rel not in tracked_files or rel in modified or manifest.is_recovered(rel):
# Never overwrite a recovered (pre-existing user) file without
# --force, matching install_shared_infra's is_recovered gate
# (#2918). Without this, refresh clobbers user content.
if rel not in tracked_files or rel in modified:
skipped_files.append(rel)
continue
@@ -347,7 +344,7 @@ def refresh_shared_templates(
if skipped_files:
console.print(
f"[yellow]⚠[/yellow] {len(skipped_files)} modified, untracked, or preserved (recovered) shared template file(s) were not updated:"
f"[yellow]⚠[/yellow] {len(skipped_files)} modified or untracked shared template file(s) were not updated:"
)
for rel in skipped_files:
console.print(f" {rel}")

View File

@@ -631,11 +631,7 @@ def workflow_add(
from urllib.parse import urlparse
from specify_cli.authentication.http import open_url as _open_url
try:
parsed_src = urlparse(source)
except ValueError:
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(source)}")
raise typer.Exit(1)
parsed_src = urlparse(source)
src_host = parsed_src.hostname or ""
src_loopback = src_host == "localhost"
if not src_loopback:

View File

@@ -242,26 +242,6 @@ def _interpolate_expressions(template: str, namespace: dict[str, Any]) -> str:
return "".join(out)
def _split_top_level(text: str, sep: str) -> list[str]:
"""Split *text* on each occurrence of *sep* that lies outside any quoted
string or nested brackets.
Used to break a filter chain (``a | map('x') | join(',')``) into its
individual filter segments without splitting on a ``|`` that appears inside
a quoted argument. Each returned segment is a slice at a top-level
boundary, so the quote/bracket scan restarts cleanly on the remainder.
"""
parts: list[str] = []
start = 0
while True:
idx = _find_top_level(text[start:], sep)
if idx == -1:
parts.append(text[start:])
return parts
parts.append(text[start:start + idx])
start += idx + len(sep)
def _split_top_level_commas(text: str) -> list[str]:
"""Split *text* on commas that are not inside quotes or nested brackets.
@@ -325,68 +305,6 @@ def _find_top_level(text: str, token: str) -> int:
return -1
def _apply_filter(value: Any, filter_expr: str, namespace: dict[str, Any]) -> Any:
"""Apply a single pipe filter segment to *value*.
*filter_expr* is one link of a filter chain — the text between two
top-level ``|`` separators, already stripped (e.g. ``map('name')``,
``default('x')``, ``from_json``). Returns the filtered value so the caller
can feed it into the next link.
Raises ``ValueError`` on any mis-wired or unknown filter rather than
silently returning *value* unchanged: a passthrough would turn a mistyped
or unsupported filter into a wrong result with no signal.
"""
# `from_json` is strict: it takes no arguments and tolerates no trailing
# tokens. Match on the leading filter name and require the whole filter to
# be exactly `from_json`, so every mis-wired form (`from_json()`,
# `from_json('x')`, `from_json)`, `from_json extra`) fails loudly instead of
# silently falling through to the unknown-filter path.
leading = re.match(r"\w+", filter_expr)
if leading and leading.group(0) == "from_json":
if filter_expr != "from_json":
raise ValueError(
"from_json: expected '| from_json' with no arguments or "
f"trailing tokens, got '| {filter_expr}'"
)
return _filter_from_json(value)
# Parse filter name and argument
filter_match = re.match(r"(\w+)\((.+)\)", filter_expr)
if filter_match:
fname = filter_match.group(1)
farg = _evaluate_simple_expression(filter_match.group(2).strip(), namespace)
if fname == "default":
return _filter_default(value, farg)
if fname == "join":
return _filter_join(value, farg)
if fname == "map":
return _filter_map(value, farg)
if fname == "contains":
return _filter_contains(value, farg)
# Filter without args
if filter_expr == "default":
return _filter_default(value)
# No recognized filter matched. Fail loudly rather than silently returning
# the unfiltered value. Distinguish a *registered* filter used in an
# unsupported form (e.g. `| join` or `| map` with no argument) from a
# genuinely unknown filter name, so the message names the real problem
# instead of calling a known filter "unknown".
name = leading.group(0) if leading else filter_expr
expected = (
"expected one of default or default('x'), join('sep'), "
"map('attr'), contains('s'), or from_json"
)
if name in _REGISTERED_FILTERS:
raise ValueError(
f"filter '{name}' used in an unsupported form (got "
f"'| {filter_expr}'): {expected}"
)
raise ValueError(
f"unknown filter '{name}': {expected} (got '| {filter_expr}')"
)
def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
"""Evaluate a simple expression against the namespace.
@@ -411,17 +329,65 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
# Handle pipe filters. Detect the pipe at the top level only, so a literal
# '|' inside a quoted operand (e.g. `inputs.x == 'a|b'`) or nested brackets is
# not mistaken for a filter separator — mirroring the operator parsing below.
# Filters chain left-to-right: `list | map('name') | join(', ')` feeds each
# filter's result into the next, so `map` (which yields a list) can be
# rendered by `join`. Splitting only at the first pipe would hand the whole
# tail to one filter and mangle any later `|`.
pipe_idx = _find_top_level(expr, "|")
if pipe_idx != -1:
segments = _split_top_level(expr, "|")
value = _evaluate_simple_expression(segments[0].strip(), namespace)
for segment in segments[1:]:
value = _apply_filter(value, segment.strip(), namespace)
return value
value = _evaluate_simple_expression(expr[:pipe_idx].strip(), namespace)
filter_expr = expr[pipe_idx + 1:].strip()
# `from_json` is strict: it takes no arguments and tolerates no
# trailing tokens. Match on the leading filter name and require the
# whole filter to be exactly `from_json`, so every mis-wired form
# (`from_json()`, `from_json('x')`, `from_json)`, `from_json extra`)
# fails loudly instead of silently falling through to the
# unknown-filter path and returning the unparsed value. (filter_expr
# is already stripped above.)
leading = re.match(r"\w+", filter_expr)
if leading and leading.group(0) == "from_json":
if filter_expr != "from_json":
raise ValueError(
"from_json: expected '| from_json' with no arguments or "
f"trailing tokens, got '| {filter_expr}'"
)
return _filter_from_json(value)
# Parse filter name and argument
filter_match = re.match(r"(\w+)\((.+)\)", filter_expr)
if filter_match:
fname = filter_match.group(1)
farg = _evaluate_simple_expression(filter_match.group(2).strip(), namespace)
if fname == "default":
return _filter_default(value, farg)
if fname == "join":
return _filter_join(value, farg)
if fname == "map":
return _filter_map(value, farg)
if fname == "contains":
return _filter_contains(value, farg)
# Filter without args
filter_name = filter_expr.strip()
if filter_name == "default":
return _filter_default(value)
# No recognized filter matched. Fail loudly rather than silently
# returning the unfiltered value: a passthrough turns a mis-typed or
# unsupported filter into a wrong result with no signal. Mirrors the
# strict `from_json` handling above. Distinguish a *registered* filter
# used in an unsupported form (e.g. `| join` or `| map` with no
# argument) from a genuinely unknown filter name, so the message names
# the real problem instead of calling a known filter "unknown".
leading_name = re.match(r"\w+", filter_expr)
name = leading_name.group(0) if leading_name else filter_expr
expected = (
"expected one of default or default('x'), join('sep'), "
"map('attr'), contains('s'), or from_json"
)
if name in _REGISTERED_FILTERS:
raise ValueError(
f"filter '{name}' used in an unsupported form (got "
f"'| {filter_expr}'): {expected}"
)
raise ValueError(
f"unknown filter '{name}': {expected} (got '| {filter_expr}')"
)
# Boolean operators — parse 'or' first (lower precedence) so that
# 'a or b and c' is evaluated as 'a or (b and c)'. Splits are quote/bracket

View File

@@ -1,481 +0,0 @@
"""Parity tests: update_agent_context.py vs update-agent-context.sh/.ps1.
Each test prepares two identical project trees, runs the bash script in one
and the Python port in the other, then compares exit codes, output (with
project roots normalized) and the resulting context-file bytes. PowerShell
tests compare the resulting file content only and are skipped when ``pwsh``
is unavailable.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
import pytest
from tests.extensions.test_extension_agent_context import (
BASH,
EXT_DIR,
POWERSHELL,
_bundled_script_env,
)
PY_SCRIPT = EXT_DIR / "scripts" / "python" / "update_agent_context.py"
BASH_SCRIPT = EXT_DIR / "scripts" / "bash" / "update-agent-context.sh"
PS_SCRIPT = EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1"
requires_posix_bash = pytest.mark.skipif(
not BASH or os.name == "nt",
reason="POSIX bash required for side-by-side parity runs",
)
def run_bash(project_root: Path, *args: str) -> subprocess.CompletedProcess:
return subprocess.run(
[BASH, str(BASH_SCRIPT), *args],
cwd=project_root,
env=_bundled_script_env(project_root, for_bash=True),
capture_output=True,
text=True,
timeout=30,
)
def run_python(project_root: Path, *args: str) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, str(PY_SCRIPT), *args],
cwd=project_root,
env=_bundled_script_env(project_root),
capture_output=True,
text=True,
timeout=30,
)
def run_powershell(project_root: Path, *args: str) -> subprocess.CompletedProcess:
return subprocess.run(
[
POWERSHELL,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(PS_SCRIPT),
*args,
],
cwd=project_root,
env=_bundled_script_env(project_root),
capture_output=True,
text=True,
timeout=30,
)
def normalize(text: str, project_root: Path) -> str:
return text.replace(str(project_root.resolve()), "__ROOT__").replace(
str(project_root), "__ROOT__"
)
def write_config(project_root: Path, **overrides: object) -> None:
"""Write the extension config as JSON (valid YAML, PS-parseable too)."""
cfg: dict = {
"context_file": overrides.get("context_file", ""),
"context_files": overrides.get("context_files", []),
"context_markers": overrides.get(
"context_markers",
{"start": "<!-- SPECKIT START -->", "end": "<!-- SPECKIT END -->"},
),
}
cfg_dir = project_root / ".specify" / "extensions" / "agent-context"
cfg_dir.mkdir(parents=True, exist_ok=True)
(cfg_dir / "agent-context-config.yml").write_text(
json.dumps(cfg), encoding="utf-8"
)
def make_project(root: Path, **config: object) -> Path:
root.mkdir(parents=True, exist_ok=True)
write_config(root, **config)
return root
def add_plan(project_root: Path, feature_dir: str = "specs/001-demo") -> None:
plan = project_root / feature_dir / "plan.md"
plan.parent.mkdir(parents=True, exist_ok=True)
plan.write_text("# plan\n", encoding="utf-8")
(project_root / ".specify").mkdir(parents=True, exist_ok=True)
(project_root / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": feature_dir}), encoding="utf-8"
)
def twin_projects(tmp_path: Path, **config: object) -> tuple[Path, Path]:
return (
make_project(tmp_path / "proj-a", **config),
make_project(tmp_path / "proj-b", **config),
)
def assert_parity(
bash: subprocess.CompletedProcess,
py: subprocess.CompletedProcess,
repo_a: Path,
repo_b: Path,
) -> None:
assert py.returncode == bash.returncode, py.stderr + bash.stderr
assert normalize(py.stdout, repo_b) == normalize(bash.stdout, repo_a)
assert normalize(py.stderr, repo_b) == normalize(bash.stderr, repo_a)
# ── Fresh file and upsert behavior ───────────────────────────────────────────
@requires_posix_bash
def test_python_creates_fresh_context_file_matching_bash(tmp_path: Path) -> None:
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
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_a = (repo_a / "AGENTS.md").read_bytes()
content_b = (repo_b / "AGENTS.md").read_bytes()
assert content_a == content_b
assert b"at specs/001-demo/plan.md" in content_b
@requires_posix_bash
def test_python_replaces_existing_section_matching_bash(tmp_path: Path) -> None:
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
existing = (
"# My project\n\n"
"<!-- SPECKIT START -->\nstale section\n<!-- SPECKIT END -->\n"
"\nTrailing prose stays.\n"
)
for repo in (repo_a, repo_b):
add_plan(repo)
(repo / "AGENTS.md").write_text(existing, encoding="utf-8")
bash = run_bash(repo_a)
py = run_python(repo_b)
assert_parity(bash, py, repo_a, repo_b)
content = (repo_b / "AGENTS.md").read_text(encoding="utf-8")
assert content == (repo_a / "AGENTS.md").read_text(encoding="utf-8")
assert "stale section" not in content
assert content.startswith("# My project\n")
assert "Trailing prose stays." in content
@requires_posix_bash
@pytest.mark.parametrize(
"existing",
[
"# Doc\n<!-- SPECKIT START -->\ndangling start\n",
"dangling end\n<!-- SPECKIT END -->\nrest\n",
"no markers at all",
],
ids=["start-only", "end-only", "no-markers-no-newline"],
)
def test_python_handles_partial_markers_matching_bash(
tmp_path: Path, existing: str
) -> None:
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
for repo in (repo_a, repo_b):
add_plan(repo)
(repo / "AGENTS.md").write_text(existing, encoding="utf-8")
bash = run_bash(repo_a)
py = run_python(repo_b)
assert_parity(bash, py, repo_a, repo_b)
assert (repo_a / "AGENTS.md").read_bytes() == (repo_b / "AGENTS.md").read_bytes()
@requires_posix_bash
def test_python_custom_markers_matching_bash(tmp_path: Path) -> None:
markers = {"start": "<!-- CTX BEGIN -->", "end": "<!-- CTX FINISH -->"}
repo_a, repo_b = twin_projects(
tmp_path, context_file="AGENTS.md", context_markers=markers
)
existing = "intro\n<!-- CTX BEGIN -->\nold\n<!-- CTX FINISH -->\noutro\n"
for repo in (repo_a, repo_b):
add_plan(repo)
(repo / "AGENTS.md").write_text(existing, encoding="utf-8")
bash = run_bash(repo_a)
py = run_python(repo_b)
assert_parity(bash, py, repo_a, repo_b)
content = (repo_b / "AGENTS.md").read_text(encoding="utf-8")
assert content == (repo_a / "AGENTS.md").read_text(encoding="utf-8")
assert "<!-- CTX BEGIN -->" in content
assert "old" not 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"]
repo_a, repo_b = twin_projects(tmp_path, context_files=files)
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)
assert bash.stdout.count("agent-context: updated") == 2
for name in ("AGENTS.md", "docs/CONTEXT.md"):
assert (repo_a / name).read_bytes() == (repo_b / name).read_bytes()
@requires_posix_bash
def test_python_normalizes_crlf_matching_bash(tmp_path: Path) -> None:
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
existing = b"# Doc\r\n\r\n<!-- SPECKIT START -->\r\nold\r\n<!-- SPECKIT END -->\r\ntail\r\n"
for repo in (repo_a, repo_b):
add_plan(repo)
(repo / "AGENTS.md").write_bytes(existing)
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"\r" not in content
@requires_posix_bash
def test_python_mdc_frontmatter_repair_matching_bash(tmp_path: Path) -> None:
mdc = ".cursor/rules/specify-rules.mdc"
cases = {
"missing": "# Rules\n",
"false-value": "---\ndescription: rules\nalwaysApply: false\n---\n\n# Rules\n",
"no-key": "---\ndescription: rules\n---\n\n# Rules\n",
}
for name, existing in cases.items():
repo_a = make_project(tmp_path / f"a-{name}", context_file=mdc)
repo_b = make_project(tmp_path / f"b-{name}", context_file=mdc)
for repo in (repo_a, repo_b):
add_plan(repo)
target = repo / mdc
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(existing, encoding="utf-8")
bash = run_bash(repo_a)
py = run_python(repo_b)
assert_parity(bash, py, repo_a, repo_b)
content = (repo_b / mdc).read_text(encoding="utf-8")
assert content == (repo_a / mdc).read_text(encoding="utf-8"), name
assert "alwaysApply: true" in content, name
# ── Plan-path resolution ─────────────────────────────────────────────────────
@requires_posix_bash
def test_python_explicit_plan_argument_matching_bash(tmp_path: Path) -> None:
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
bash = run_bash(repo_a, "specs/009-explicit/plan.md")
py = run_python(repo_b, "specs/009-explicit/plan.md")
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/009-explicit/plan.md" in content
@requires_posix_bash
def test_python_mtime_fallback_matching_bash(tmp_path: Path) -> None:
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
now = time.time()
for repo in (repo_a, repo_b):
for feature, age in (("specs/000-old", 10), ("specs/001-new", 0)):
plan = repo / feature / "plan.md"
plan.parent.mkdir(parents=True, exist_ok=True)
plan.write_text("# plan\n", encoding="utf-8")
os.utime(plan, (now - age, now - age))
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"at specs/001-new/plan.md" in content
@requires_posix_bash
def test_python_prefers_feature_json_over_mtime_matching_bash(tmp_path: Path) -> None:
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
now = time.time()
for repo in (repo_a, repo_b):
add_plan(repo, "specs/001-active")
stale = repo / "specs" / "000-stale" / "plan.md"
stale.parent.mkdir(parents=True, exist_ok=True)
stale.write_text("# plan\n", encoding="utf-8")
os.utime(repo / "specs" / "001-active" / "plan.md", (now - 10, now - 10))
os.utime(stale, (now, now))
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"at specs/001-active/plan.md" in content
@requires_posix_bash
def test_python_no_plan_omits_at_line_matching_bash(tmp_path: Path) -> None:
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
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
# ── Config gates and path validation ─────────────────────────────────────────
@requires_posix_bash
def test_python_missing_config_matching_bash(tmp_path: Path) -> None:
repo_a = tmp_path / "proj-a"
repo_b = tmp_path / "proj-b"
repo_a.mkdir()
repo_b.mkdir()
bash = run_bash(repo_a)
py = run_python(repo_b)
assert_parity(bash, py, repo_a, repo_b)
assert py.returncode == 0
assert "not found; nothing to do." in py.stderr
@requires_posix_bash
def test_python_unparseable_config_matching_bash(tmp_path: Path) -> None:
repo_a = tmp_path / "proj-a"
repo_b = tmp_path / "proj-b"
for repo in (repo_a, repo_b):
cfg_dir = repo / ".specify" / "extensions" / "agent-context"
cfg_dir.mkdir(parents=True)
(cfg_dir / "agent-context-config.yml").write_text(
"context_file: [unclosed\n", encoding="utf-8"
)
bash = run_bash(repo_a)
py = run_python(repo_b)
assert_parity(bash, py, repo_a, repo_b)
assert py.returncode == 0
assert "cannot update context." in py.stderr
assert "agent-context: skipping update (see above for details)." in py.stderr
@requires_posix_bash
def test_python_empty_config_matching_bash(tmp_path: Path) -> None:
repo_a, repo_b = twin_projects(tmp_path)
bash = run_bash(repo_a)
py = run_python(repo_b)
assert_parity(bash, py, repo_a, repo_b)
assert py.returncode == 0
assert "context_files/context_file not set" in py.stderr
@requires_posix_bash
def test_python_self_seed_from_init_options_matching_bash(tmp_path: Path) -> None:
repo_a, repo_b = twin_projects(tmp_path)
for repo in (repo_a, repo_b):
add_plan(repo)
(repo / ".specify" / "init-options.json").write_text(
json.dumps({"integration": "claude"}), encoding="utf-8"
)
shutil.copy(
EXT_DIR / "agent-context-defaults.json",
repo
/ ".specify"
/ "extensions"
/ "agent-context"
/ "agent-context-defaults.json",
)
bash = run_bash(repo_a)
py = run_python(repo_b)
assert_parity(bash, py, repo_a, repo_b)
assert (repo_a / "CLAUDE.md").read_bytes() == (repo_b / "CLAUDE.md").read_bytes()
@requires_posix_bash
@pytest.mark.parametrize(
"bad_path",
["/etc/AGENTS.md", "docs\\AGENTS.md", "../outside.md", "nested/../../escape.md"],
ids=["absolute", "backslash", "dotdot", "nested-dotdot"],
)
def test_python_rejects_escaping_paths_matching_bash(
tmp_path: Path, bad_path: str
) -> None:
repo_a, repo_b = twin_projects(tmp_path, context_file=bad_path)
bash = run_bash(repo_a)
py = run_python(repo_b)
assert_parity(bash, py, repo_a, repo_b)
assert py.returncode == 1
assert not (repo_b / "AGENTS.md").exists()
# ── PowerShell parity (content only) ─────────────────────────────────────────
@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available")
def test_python_fresh_context_file_matches_powershell(tmp_path: Path) -> None:
repo_a = make_project(tmp_path / "proj-ps", context_file="AGENTS.md")
repo_b = make_project(tmp_path / "proj-py", context_file="AGENTS.md")
add_plan(repo_a)
add_plan(repo_b)
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()
@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")
repo_b = make_project(tmp_path / "proj-py", context_file="AGENTS.md")
existing = (
"# My project\n\n"
"<!-- SPECKIT START -->\nstale\n<!-- SPECKIT END -->\n"
"\ntail\n"
)
for repo in (repo_a, repo_b):
add_plan(repo)
(repo / "AGENTS.md").write_text(existing, 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()

View File

@@ -5,8 +5,10 @@ import pytest
from specify_cli.integrations.base import MarkdownIntegration
def _redirect_home(monkeypatch: pytest.MonkeyPatch, home) -> None:
"""Point HOME/USERPROFILE/XDG env vars at an isolated *home* directory."""
@pytest.fixture(autouse=True)
def _isolate_integration_home(monkeypatch: pytest.MonkeyPatch, tmp_path):
"""Keep integration tests from reading or writing the real user home."""
home = tmp_path / "home"
for path in (home, home / ".cache", home / ".config", home / ".local" / "share"):
path.mkdir(parents=True, exist_ok=True)
@@ -17,28 +19,6 @@ def _redirect_home(monkeypatch: pytest.MonkeyPatch, home) -> None:
monkeypatch.setenv("XDG_DATA_HOME", str(home / ".local" / "share"))
@pytest.fixture(scope="session", autouse=True)
def _isolate_integration_home_session(tmp_path_factory):
"""Isolate the user home for setup that runs outside a test function.
The per-test fixture below re-points HOME for each test, but function-scoped
fixtures do not apply to module-/session-scoped fixtures. Some of those (e.g.
the ``status_*_template`` fixtures in ``test_integration_subcommand.py``) run
``specify init`` during setup, before any per-test isolation takes effect.
A standalone ``MonkeyPatch`` gives them an isolated home too.
"""
monkeypatch = pytest.MonkeyPatch()
_redirect_home(monkeypatch, tmp_path_factory.mktemp("session-home"))
yield
monkeypatch.undo()
@pytest.fixture(autouse=True)
def _isolate_integration_home(monkeypatch: pytest.MonkeyPatch, tmp_path):
"""Keep integration tests from reading or writing the real user home."""
_redirect_home(monkeypatch, tmp_path / "home")
class StubIntegration(MarkdownIntegration):
"""Minimal concrete integration for testing."""

View File

@@ -306,12 +306,9 @@ class TestResolveCommandRefs:
class TestResolvePythonInterpreter:
def test_returns_python_on_path(self, monkeypatch):
# Positive: when python3 is on PATH it is preferred over python.
# Pin a POSIX platform so the Windows stub probe (tested separately
# below) does not reject the fake PATH entries on Windows CI.
def fake_which(name):
return f"/usr/bin/{name}" if name in ("python3", "python") else None
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "linux")
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which", fake_which
)
@@ -321,7 +318,6 @@ class TestResolvePythonInterpreter:
def fake_which(name):
return "/usr/bin/python" if name == "python" else None
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "linux")
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which", fake_which
)
@@ -373,79 +369,12 @@ class TestResolvePythonInterpreter:
def test_ignores_missing_venv(self, monkeypatch, tmp_path):
# Negative: no venv directory -> PATH resolution is used instead.
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "linux")
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which",
lambda name: "/usr/bin/python3" if name == "python3" else None,
)
assert IntegrationBase.resolve_python_interpreter(tmp_path) == "python3"
def test_windows_skips_store_alias_stub(self, monkeypatch):
# On Windows, python3 on PATH may be the Microsoft Store App
# Execution Alias stub: it exists but only prints an installer
# hint and exits non-zero. Existence is not enough; the
# interpreter must actually run (mirrors #3304 for the CLI).
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "win32")
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which",
lambda name: f"C:\\WindowsApps\\{name}.exe"
if name in ("python3", "python")
else None,
)
monkeypatch.setattr(
IntegrationBase, "_interpreter_runs", staticmethod(lambda path: False)
)
monkeypatch.setattr(
"specify_cli.integrations.base.sys.executable", "C:\\Python\\python.exe"
)
result = IntegrationBase.resolve_python_interpreter()
assert result == "C:\\Python\\python.exe"
def test_windows_keeps_working_interpreter(self, monkeypatch):
# Positive: a real python3 on Windows PATH passes the run check.
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "win32")
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which",
lambda name: f"C:\\Python\\{name}.exe" if name == "python3" else None,
)
monkeypatch.setattr(
IntegrationBase, "_interpreter_runs", staticmethod(lambda path: True)
)
assert IntegrationBase.resolve_python_interpreter() == "python3"
def test_windows_stub_python3_falls_through_to_working_python(self, monkeypatch):
# python3 is the stub but python is a real install: pick python.
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "win32")
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which",
lambda name: f"C:\\somewhere\\{name}.exe"
if name in ("python3", "python")
else None,
)
monkeypatch.setattr(
IntegrationBase,
"_interpreter_runs",
staticmethod(lambda path: path.endswith("python.exe")),
)
assert IntegrationBase.resolve_python_interpreter() == "python"
def test_posix_does_not_spawn_run_check(self, monkeypatch):
# Non-Windows platforms have no App Execution Alias; existence
# on PATH stays sufficient and no subprocess is spawned.
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "linux")
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which",
lambda name: "/usr/bin/python3" if name == "python3" else None,
)
def boom(path):
raise AssertionError("run check must not execute on POSIX")
monkeypatch.setattr(
IntegrationBase, "_interpreter_runs", staticmethod(boom)
)
assert IntegrationBase.resolve_python_interpreter() == "python3"
class TestProcessTemplatePyScriptType:
CONTENT = (
@@ -461,7 +390,6 @@ class TestProcessTemplatePyScriptType:
def test_py_prefixes_interpreter(self, monkeypatch):
# Positive: py script type prefixes a resolved interpreter and the
# script path is rewritten to the .specify location.
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "linux")
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which",
lambda name: "/usr/bin/python3" if name == "python3" else None,

View File

@@ -2073,44 +2073,3 @@ class TestIntegrationCatalogDiscoveryCLI:
assert listing.exit_code == 0, listing.output
assert "default" in listing.output
assert "community" in listing.output
def test_refresh_shared_templates_preserves_recovered_user_file(tmp_path):
"""refresh_shared_templates must not overwrite a recovered (pre-existing
user) template without --force, matching install_shared_infra's gate (#2918).
"""
from specify_cli.shared_infra import (
load_speckit_manifest,
refresh_shared_templates,
)
project = tmp_path / "proj"
templates_dir = project / ".specify" / "templates"
templates_dir.mkdir(parents=True)
user_file = templates_dir / "spec-template.md"
user_file.write_text("# USER CUSTOM CONTENT\n", encoding="utf-8")
# Record the pre-existing file as recovered (its hash was adopted, not written).
manifest = load_speckit_manifest(project, version="test", console=_NoopConsole())
rel = ".specify/templates/spec-template.md"
manifest.record_existing(rel, recovered=True)
manifest.save()
# Bundled source ships a different body for the same template.
core_pack = tmp_path / "core-pack"
src = core_pack / "templates"
src.mkdir(parents=True)
(src / "spec-template.md").write_text("# BUNDLED CONTENT v2\n", encoding="utf-8")
refresh_shared_templates(
project,
version="test",
core_pack=core_pack,
repo_root=tmp_path / "unused",
console=_NoopConsole(),
invoke_separator=".",
force=False,
)
# Recovered user content must survive (fail-before: replaced by bundled body).
assert user_file.read_text(encoding="utf-8") == "# USER CUSTOM CONTENT\n"

View File

@@ -15,10 +15,6 @@ def test_integration_tests_use_tmp_home(tmp_path: Path) -> None:
assert Path(os.environ["XDG_CONFIG_HOME"]) == home / ".config"
assert Path(os.environ["XDG_DATA_HOME"]) == home / ".local" / "share"
# Most integrations resolve the user home via Path.home() (e.g. Hermes,
# catalog), so the isolation has to reach that API, not just the env vars.
assert Path.home() == home
assert home.is_dir()
assert (home / ".cache").is_dir()
assert (home / ".config").is_dir()

View File

@@ -291,18 +291,6 @@ class TomlIntegrationTests:
"closing delimiter should be inline when body does not end with a quote"
)
def test_toml_string_escapes_control_characters(self):
"""A value with control chars / a bare CR must render as parseable TOML.
TOML forbids literal control characters (U+0000U+001F except tab and
newline, plus U+007F) in every string form, and a bare CR that is not
part of a CRLF pair. The renderer used to emit these raw into a basic or
``\"\"\"`` multiline string, producing a config file that fails to parse."""
value = "start\x00null\x01ctrl\x1besc\x7fdel\rlone-cr end"
rendered = TomlIntegration._render_toml_string(value)
parsed = tomllib.loads(f"prompt = {rendered}")
assert parsed["prompt"] == value
def test_toml_is_valid(self, tmp_path):
"""Every generated TOML file must parse without errors."""
i = get_integration(self.KEY)

View File

@@ -36,50 +36,3 @@ class TestGooseIntegration(YamlIntegrationTests):
param.get("key") == "args"
for param in data.get("parameters", [])
), f"{recipe_file} uses {{{{args}}}} but does not declare args"
class TestGooseCommandPlaceholderResolution:
"""register_commands must resolve skill placeholders for the yaml branch.
The yaml (Goose recipe) branch previously skipped
resolve_skill_placeholders / _convert_argument_placeholder that the
markdown and toml branches apply, so extension/preset command bodies
kept literal {SCRIPT} / __AGENT__ / repo-relative paths.
"""
def test_register_commands_resolves_placeholders_in_recipe(self, tmp_path):
from specify_cli.agents import CommandRegistrar
ext_dir = tmp_path / "extension"
cmd_dir = ext_dir / "commands"
cmd_dir.mkdir(parents=True)
cmd_file = cmd_dir / "example.md"
cmd_file.write_text(
"---\n"
"description: Placeholder command\n"
"scripts:\n"
" sh: scripts/bash/do.sh\n"
" ps: scripts/powershell/do.ps1\n"
"---\n\n"
"Run {SCRIPT} for agent __AGENT__ with $ARGUMENTS.\n",
encoding="utf-8",
)
registrar = CommandRegistrar()
commands = [{"name": "speckit.example", "file": "commands/example.md"}]
registrar.register_commands("goose", commands, "test-ext", ext_dir, tmp_path)
recipe = tmp_path / ".goose" / "recipes" / "speckit.example.yaml"
assert recipe.exists(), "goose recipe should be generated"
# Parse the recipe and assert the prompt actually got the correct
# replacements — not merely that the literal tokens are absent (which
# a wrong-but-token-free output could also satisfy).
data = yaml.safe_load(recipe.read_text(encoding="utf-8"))
prompt = data["prompt"]
assert ".specify/scripts/" in prompt # {SCRIPT} -> resolved script path
assert "agent goose" in prompt # __AGENT__ -> agent name
assert "{{args}}" in prompt # $ARGUMENTS -> goose args token
# And the raw placeholders must not survive.
assert "{SCRIPT}" not in prompt
assert "__AGENT__" not in prompt
assert "$ARGUMENTS" not in prompt

View File

@@ -481,40 +481,3 @@ class TestRecordExistingNewGuards:
m = IntegrationManifest("test", tmp_path)
with pytest.raises(ValueError, match=r"canonical|'\.\.' segments"):
m.record_existing("dir/../file.txt")
class TestManifestUnreadableFile:
"""A managed file that is unreadable (e.g. PermissionError) must not crash
check_modified()/uninstall() — the CLI handlers surfaced a raw traceback."""
def _mk(self, tmp_path):
m = IntegrationManifest("test", tmp_path)
m.record_file("sub/f.md", "content")
return m
def test_check_modified_treats_unreadable_as_modified(self, tmp_path, monkeypatch):
m = self._mk(tmp_path)
def raise_perm(_path):
raise PermissionError("unreadable")
monkeypatch.setattr(
"specify_cli.integrations.manifest._sha256", raise_perm
)
# Before the fix this raised PermissionError.
assert m.check_modified() == ["sub/f.md"]
def test_uninstall_preserves_unreadable_file(self, tmp_path, monkeypatch):
m = self._mk(tmp_path)
def raise_perm(_path):
raise PermissionError("unreadable")
monkeypatch.setattr(
"specify_cli.integrations.manifest._sha256", raise_perm
)
removed, skipped = m.uninstall(force=False)
# Can't verify ownership => preserve, don't crash and don't delete.
assert removed == []
assert (tmp_path / "sub" / "f.md") in skipped
assert (tmp_path / "sub" / "f.md").exists()

View File

@@ -1,111 +0,0 @@
"""Regression tests for SKILL.md frontmatter quoting (#3391).
The skills setup path builds SKILL.md frontmatter by hand with
double-quoted values. A double-quoted YAML scalar cannot carry a raw
newline (the parser folds it to a space) or a control character (the
reader rejects the document), so descriptions taken from template
frontmatter must be escaped by the YAML emitter.
"""
from pathlib import Path
import yaml
from specify_cli.integrations import get_integration
from specify_cli.integrations.base import yaml_quote
from specify_cli.integrations.manifest import IntegrationManifest
MULTILINE = "first line\nsecond line\n"
CONTROL = "ding\aling"
HOSTILE_TEMPLATE = """---
description: |
first line
second line
---
Body of the command.
"""
CONTROL_TEMPLATE = """---
description: "ding\\aling"
---
Body of the command.
"""
def _parse_frontmatter(skill_file: Path) -> dict:
content = skill_file.read_text(encoding="utf-8")
assert content.startswith("---\n")
return yaml.safe_load(content.split("---", 2)[1])
def _fake_templates(tmp_path: Path, body: str) -> Path:
templates = tmp_path / "templates"
templates.mkdir(exist_ok=True)
(templates / "plan.md").write_text(body, encoding="utf-8")
return templates
class TestYamlQuote:
def test_simple_value_keeps_plain_double_quoted_form(self):
assert yaml_quote("speckit-plan") == '"speckit-plan"'
assert yaml_quote('say "hi"') == '"say \\"hi\\""'
assert yaml_quote("back\\slash") == '"back\\\\slash"'
def test_multiline_value_round_trips(self):
quoted = yaml_quote(MULTILINE)
assert "\n" not in quoted
assert yaml.safe_load(quoted) == MULTILINE
def test_control_character_round_trips(self):
quoted = yaml_quote(CONTROL)
assert "\a" not in quoted
assert yaml.safe_load(quoted) == CONTROL
class TestSkillFrontmatterQuoting:
def _generate(self, tmp_path, monkeypatch, template: str) -> Path:
integration = get_integration("agy")
monkeypatch.setattr(
integration,
"shared_commands_dir",
lambda: _fake_templates(tmp_path, template),
)
manifest = IntegrationManifest("agy", 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
return skill_files[0]
def test_multiline_description_survives(self, tmp_path, monkeypatch):
skill_file = self._generate(tmp_path, monkeypatch, HOSTILE_TEMPLATE)
fm = _parse_frontmatter(skill_file)
assert fm["description"] == MULTILINE
def test_control_character_description_parses(self, tmp_path, monkeypatch):
skill_file = self._generate(tmp_path, monkeypatch, CONTROL_TEMPLATE)
fm = _parse_frontmatter(skill_file)
assert fm["description"] == CONTROL
class TestHermesSkillFrontmatterQuoting:
def test_multiline_description_survives(self, tmp_path, monkeypatch):
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, HOSTILE_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(skill_files[0])
assert fm["description"] == MULTILINE

View File

@@ -845,22 +845,6 @@ class TestRedirectStripping:
auth3 = req3.get_header("Authorization") or req3.unredirected_hdrs.get("Authorization")
assert auth3 == "Bearer tok"
def test_malformed_redirect_url_raises_urlerror_not_valueerror(self):
"""A redirect to a malformed URL (unterminated IPv6 bracket) surfaces
as URLError, which download paths already handle, rather than an
unhandled ValueError traceback."""
import urllib.error
from specify_cli.authentication.http import _StripAuthOnRedirect
from urllib.request import Request
import io
handler = _StripAuthOnRedirect(("github.com",))
req = Request("https://github.com/org/repo")
with pytest.raises(urllib.error.URLError):
handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
"https://[::1/asset")
# ---------------------------------------------------------------------------
# _fetch_latest_release_tag delegation

View File

@@ -1795,25 +1795,6 @@ $ARGUMENTS
assert parsed["description"] == "first line\nsecond line\n"
def test_render_toml_command_escapes_control_characters(self):
"""Control characters and a lone CR must be escaped so the TOML parses.
TOML forbids literal control characters (U+0000U+001F except tab and
newline, plus U+007F) in any string, and treats a bare CR outside a
CRLF pair as illegal. The renderer used to emit these raw — into a
basic string (single-line) or a ``\"\"\"`` multiline string (for a lone
CR) — producing a command file that fails to parse."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
registrar = AgentCommandRegistrar()
body = "start\x00null\x01ctrl\x1besc\x7fdel\rlone-cr end"
output = registrar.render_toml_command(
{"description": "d"}, body, "extension:test-ext"
)
parsed = tomllib.loads(output)
assert parsed["prompt"] == body
def test_render_toml_command_preserves_backslashes_in_body(self):
"""A backslash in the body (e.g. a Windows path) must not break TOML.
@@ -5460,29 +5441,6 @@ class TestExtensionAddCLI:
f"confirm must precede spinner, got: {call_order}"
assert result.exit_code == 0 # user declined → clean exit
def test_add_from_malformed_ipv6_url_exits_cleanly(self, tmp_path):
"""A malformed IPv6 URL must produce a clean error, not a ValueError traceback."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
project_dir = tmp_path / "test-project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app,
["extension", "add", "my-ext", "--from", "https://[::1/ext.zip"],
catch_exceptions=True,
)
assert result.exit_code == 1
assert result.exception is None or isinstance(result.exception, SystemExit)
plain = strip_ansi(result.output)
assert "Invalid URL" in plain
def test_add_status_escapes_extension_markup(self, tmp_path):
"""User-controlled extension names must not be parsed as Rich markup."""
from rich.markup import escape as escape_markup

View File

@@ -233,23 +233,6 @@ class TestResolveGitHubReleaseAssetApiUrl:
assert result is None
assert called == []
def test_returns_none_on_malformed_ghes_port(self):
"""A malformed port on an allowlisted GHES host returns None, not a
ValueError (contract: resolve or return None, never raise)."""
called = []
def open_never(url, timeout=None, extra_headers=None):
called.append(url)
raise AssertionError("open_url_fn must not be called")
result = resolve_github_release_asset_api_url(
"https://ghes.example:notaport/o/r/releases/download/v1/ext.zip",
open_never,
github_hosts=("ghes.example",),
)
assert result is None
assert called == []
def test_passthrough_for_unlisted_ghes_api_asset_url(self):
"""A direct GHES /api/v3 asset URL passes through even when the host is
not allowlisted: passthrough issues no API request, and the download

View File

@@ -4538,27 +4538,6 @@ class TestBundledPresetLocator:
assert "got https://" not in output
open_url.assert_not_called()
def test_preset_add_from_malformed_ipv6_url_exits_cleanly(self, project_dir):
"""A malformed IPv6 URL must produce a clean error, not a ValueError traceback."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("specify_cli.authentication.http.open_url") as open_url:
result = runner.invoke(
app,
["preset", "add", "--from", "https://[::1/preset.zip"],
catch_exceptions=True,
)
assert result.exit_code == 1
assert result.exception is None or isinstance(result.exception, SystemExit)
output = strip_ansi(result.output)
assert "Invalid URL" in output
open_url.assert_not_called()
def test_preset_add_from_url_redirect_error_describes_disallowed_url(self, project_dir, monkeypatch, capsys):
"""Redirect rejection message covers hostless HTTPS, not only non-HTTPS URLs."""
import typer

View File

@@ -126,71 +126,6 @@ def test_setup_plan_errors_without_feature_context(plan_repo: Path) -> None:
assert "Feature directory not found" in result.stderr
@requires_bash
def test_setup_plan_survives_broken_python3_stub(plan_repo: Path) -> None:
"""A `python3` on PATH that exists but fails at runtime must not defeat
feature.json parsing.
On Windows `python3` typically resolves to the Microsoft Store App Execution
Alias stub: it satisfies `command -v python3` yet exits non-zero at runtime.
The parser must fall through to the grep/sed fallback on that failure instead
of selecting python3 by mere availability and swallowing its error (#3304).
"""
subprocess.run(
["git", "checkout", "-q", "-b", "feature/my-feature-branch"],
cwd=plan_repo,
check=True,
)
feat = plan_repo / "specs" / "001-tiny-notes-app"
feat.mkdir(parents=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
_write_feature_json(plan_repo, "specs/001-tiny-notes-app")
# A stub python3 that mimics the Windows Store alias: on PATH, exits 49.
stub_dir = plan_repo / "_stubbin"
stub_dir.mkdir()
stub = stub_dir / "python3"
stub.write_text(
"#!/bin/sh\n"
'echo "Python was not found; run without arguments to install from the '
'Microsoft Store" >&2\n'
"exit 49\n",
encoding="utf-8",
)
stub.chmod(0o755)
# A stub jq that shadows any real jq on PATH and also fails, so the parser
# cannot short-circuit on jq and must reach the broken python3 stub and then
# fall through to grep/sed. Without this, a runner that has jq installed
# would parse feature.json via jq and never exercise the fallback this test
# is meant to cover.
jq_stub = stub_dir / "jq"
jq_stub.write_text(
"#!/bin/sh\n"
'echo "jq: simulated failure" >&2\n'
"exit 1\n",
encoding="utf-8",
)
jq_stub.chmod(0o755)
env = _clean_env()
# Prepend the stub dir so the failing jq and python3 stubs take precedence
# over any real ones; PATH still needs the real bash utilities for grep/sed.
env["PATH"] = f"{stub_dir}{os.pathsep}{env.get('PATH', '')}"
script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh"
result = subprocess.run(
["bash", str(script)],
cwd=plan_repo,
capture_output=True,
text=True,
check=False,
env=env,
)
assert result.returncode == 0, result.stderr + result.stdout
assert (feat / "plan.md").is_file()
@requires_bash
def test_setup_plan_numbered_branch_works_with_feature_json(
plan_repo: Path,

View File

@@ -466,68 +466,6 @@ def test_bash_command_hint_preserves_hyphens_inside_segments(tasks_repo: Path) -
assert result.stdout.strip() == "/speckit.jira.sync-status"
def _install_broken_json_tool_stubs(repo: Path) -> Path:
"""Create a bin dir with `jq` and `python3` stubs that exist but fail.
Mimics stock Windows + Git Bash, where a JSON tool may be missing or broken
and `python3` resolves to the Microsoft Store App Execution Alias stub: both
satisfy `command -v` yet fail at runtime (the alias exits 49). Prepending
this to PATH forces the invoke-separator parser past jq and python3 to its
awk text fallback (#3304).
"""
stub_dir = repo / "_broken_bin"
stub_dir.mkdir(exist_ok=True)
for name in ("jq", "python3"):
stub = stub_dir / name
stub.write_text(
"#!/bin/sh\n"
'echo "simulated broken interpreter/tool" >&2\n'
"exit 49\n",
encoding="utf-8",
newline="\n",
)
stub.chmod(0o755)
return stub_dir
@requires_bash
def test_bash_command_hint_falls_back_to_awk_when_jq_and_python3_broken(
tasks_repo: Path,
) -> None:
"""Separator resolution survives broken jq and python3 stubs (#3304).
`get_invoke_separator` historically selected python3 by availability and
had no text fallback, so a Windows Store python3 stub made it silently
return "." even for `-`-separator integrations (e.g. forge), yielding a
wrong hint like `/speckit.plan`. The awk fallback must recover `-`.
"""
_write_integration_state(tasks_repo, "forge", "-")
stub_dir = _install_broken_json_tool_stubs(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "bash" / "common.sh"
env = _clean_env()
env["PATH"] = f"{stub_dir}{os.pathsep}{env.get('PATH', '')}"
result = subprocess.run(
[
"bash",
"-c",
'source "$1"; format_speckit_command "$2" "$PWD"',
"bash",
str(script),
"plan",
],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=env,
)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "/speckit-plan"
@requires_bash
def test_bash_command_hint_caches_invoke_separator_per_process(tasks_repo: Path) -> None:
_write_integration_state(tasks_repo, "claude", "-")

View File

@@ -601,73 +601,6 @@ class TestExpressions:
):
evaluate_expression("{{ inputs.tags | map }}", ctx)
def test_chained_filters_apply_left_to_right(self):
# Filters chain: each filter's result feeds the next. `map` yields a
# list and `join` is the only filter that renders a list to a string,
# so `map('name') | join(', ')` is the canonical pairing — it must not
# raise. Previously the pipe parser split only at the first `|` and
# handed the whole tail (`map('name') | join(', ')`) to one filter,
# which the `name(arg)` regex mangled into a ValueError.
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(
inputs={
"rows": [{"name": "a"}, {"name": "b"}],
"tags": ["x", "y"],
"missing": None,
}
)
assert (
evaluate_expression(
"{{ inputs.rows | map('name') | join(', ') }}", ctx
)
== "a, b"
)
# A three-link chain: map -> join -> contains.
assert (
evaluate_expression(
"{{ inputs.rows | map('name') | join(', ') | contains('a') }}",
ctx,
)
is True
)
# default's fallback then flows into the next filter.
assert (
evaluate_expression(
"{{ inputs.missing | default('x') | contains('x') }}", ctx
)
is True
)
def test_chained_filter_error_in_later_link_raises(self):
# A mis-wired filter anywhere in the chain must fail loudly, not just
# the first link.
import pytest
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(inputs={"rows": [{"name": "a"}]})
with pytest.raises(ValueError, match="unknown filter 'bogus'"):
evaluate_expression(
"{{ inputs.rows | map('name') | bogus }}", ctx
)
def test_pipe_in_quoted_arg_is_not_a_filter_separator(self):
# A literal `|` inside a quoted operand or filter argument must not be
# mistaken for a filter-chain separator — the top-level split has to
# respect quotes.
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(inputs={"mode": "a|b", "tags": ["a|b", "c"]})
assert evaluate_expression("{{ inputs.mode == 'a|b' }}", ctx) is True
# `|` inside a filter argument stays part of the argument.
assert (
evaluate_expression("{{ inputs.tags | join(' | ') }}", ctx)
== "a|b | c"
)
def test_condition_evaluation(self):
from specify_cli.workflows.expressions import evaluate_condition
from specify_cli.workflows.base import StepContext
@@ -5660,23 +5593,6 @@ class TestWorkflowRemoveGuard:
class TestWorkflowAddSymlinkGuard:
def test_add_malformed_ipv6_url_exits_cleanly(self, temp_dir, monkeypatch):
"""A malformed IPv6 URL must produce a clean error, not a ValueError traceback."""
from typer.testing import CliRunner
from specify_cli import app
(temp_dir / ".specify").mkdir(exist_ok=True)
monkeypatch.chdir(temp_dir)
result = CliRunner().invoke(
app,
["workflow", "add", "https://[::1/wf.yaml"],
catch_exceptions=True,
)
assert result.exit_code == 1
assert result.exception is None or isinstance(result.exception, SystemExit)
assert "Invalid URL" in result.output
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
def test_add_refuses_symlinked_specify(self, temp_dir, monkeypatch):
"""workflow add must refuse a symlinked .specify (writes could escape root)."""

View File

@@ -131,87 +131,3 @@ def test_preset_install_preserves_explicit_zero_priority(tmp_path: Path, monkeyp
# An explicit priority of 0 must be passed through, not replaced by default.
assert calls["priority"] == 0
def _write_manifest(path: Path, root_key: str, version: str) -> Path:
path.mkdir(parents=True, exist_ok=True)
(path / f"{root_key}.yml").write_text(
f"{root_key}:\n id: x\n version: {version}\n", encoding="utf-8"
)
return path
def test_bundled_extension_pin_mismatch_refuses(tmp_path: Path, monkeypatch):
"""A bundled extension whose version != the manifest pin must be refused
(the bundled path previously skipped the pin the catalog path enforces)."""
import specify_cli._assets as assets
from specify_cli.extensions import ExtensionManager
bundled = _write_manifest(tmp_path / "ext", "extension", "1.0.0")
monkeypatch.setattr(assets, "_locate_bundled_extension", lambda cid: bundled)
called: list = []
monkeypatch.setattr(
ExtensionManager, "install_from_directory",
lambda self, *a, **k: called.append(a),
)
manager = primitive_manager("extensions", tmp_path, allow_network=False)
with pytest.raises(BundlerError, match="pinned to version 2.0.0"):
manager.install(ComponentRef(kind="extensions", id="my-ext", version="2.0.0"))
assert called == [] # install must not proceed
def test_bundled_extension_pin_match_installs(tmp_path: Path, monkeypatch):
import specify_cli._assets as assets
from specify_cli.extensions import ExtensionManager
bundled = _write_manifest(tmp_path / "ext", "extension", "1.0.0")
monkeypatch.setattr(assets, "_locate_bundled_extension", lambda cid: bundled)
called: list = []
monkeypatch.setattr(
ExtensionManager, "install_from_directory",
lambda self, *a, **k: called.append(a),
)
manager = primitive_manager("extensions", tmp_path, allow_network=False)
# matching pin, and unpinned, both install cleanly
manager.install(ComponentRef(kind="extensions", id="my-ext", version="1.0.0"))
manager.install(ComponentRef(kind="extensions", id="my-ext", version=None))
assert len(called) == 2
def test_bundled_preset_pin_mismatch_refuses(tmp_path: Path, monkeypatch):
import specify_cli._assets as assets
from specify_cli.presets import PresetManager
bundled = _write_manifest(tmp_path / "preset", "preset", "1.0.0")
monkeypatch.setattr(assets, "_locate_bundled_preset", lambda cid: bundled)
called: list = []
monkeypatch.setattr(
PresetManager, "install_from_directory",
lambda self, *a, **k: called.append(a),
)
manager = primitive_manager("presets", tmp_path, allow_network=False)
with pytest.raises(BundlerError, match="pinned to version 2.0.0"):
manager.install(ComponentRef(kind="presets", id="my-preset", version="2.0.0"))
assert called == []
def test_bundled_preset_pin_match_installs(tmp_path: Path, monkeypatch):
import specify_cli._assets as assets
from specify_cli.presets import PresetManager
bundled = _write_manifest(tmp_path / "preset", "preset", "1.0.0")
monkeypatch.setattr(assets, "_locate_bundled_preset", lambda cid: bundled)
called: list = []
monkeypatch.setattr(
PresetManager, "install_from_directory",
lambda self, *a, **k: called.append(a),
)
manager = primitive_manager("presets", tmp_path, allow_network=False)
# matching pin, and unpinned, both proceed to install
manager.install(ComponentRef(kind="presets", id="my-preset", version="1.0.0"))
manager.install(ComponentRef(kind="presets", id="my-preset", version=None))
assert len(called) == 2