Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
7511c8c24c chore: bump version to 0.12.4 2026-07-02 10:56:52 +00:00
68 changed files with 257 additions and 4415 deletions

View File

@@ -35,7 +35,7 @@ jobs:
fetch-depth: 0 # Fetch all history for git info
- name: Setup .NET
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
with:
dotnet-version: '8.x'

View File

@@ -2,66 +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
- fix(bundler): bundle update uninstalls components dropped by new version (#3353)
- fix(workflows): route run/resume errors to stderr under --json (#3352)
- fix(workflows): fan-in validate() rejects non-mapping output (#3349)
- fix(workflows): shell step validate() rejects non-string run (#3348)
- fix(integrations): agy honors SPECKIT_INTEGRATION_AGY_EXTRA_ARGS (#3347)
- Add Orchestration Task Context Management extension to community catalog (#3372)
- Update DocGuard — CDD Enforcement extension to v0.30.0 (#3371)
- Update Ripple extension to v1.1.0 (#3370)
- feat(integrations): generalize post-processing to all format types (#3311)
- chore: release 0.12.6, begin 0.12.7.dev0 development (#3393)
## [0.12.6] - 2026-07-07
### Changed
- fix(bundler): validate catalog URLs in `catalog add` (HTTPS-only, require host) (#3367)
- Update Ralph Loop extension to v1.2.1 (#3365)
- fix extension-local script path rewriting (#3364)
- Add Charter extension to community catalog (#3363)
- feat(scripts): add Python check-prerequisites PoC (#3302)
- test: reduce registry manifest test repetition (#3146)
- fix(integrations): hermes honors SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS (#3346)
- fix(extensions): coerce non-mapping YAML config roots to {} in ConfigManager (#3345)
- fix(yaml): pin goose recipe prompt block-scalar indentation (#3343)
- chore: release 0.12.5, begin 0.12.6.dev0 development (#3381)
## [0.12.5] - 2026-07-06
### Changed
- fix(workflows): match gate reject option case-insensitively (#3335)
- fix(bundler): reject host-less catalog URLs in adapters (use hostname, not netloc) (#3333)
- fix(bundler): resolve catalog search at highest-precedence source before filtering (#3331)
- fix(workflows): compare non-numeric strings lexicographically instead of returning False (#3323)
- fix(workflows): quote-aware interpolation so a literal }} in a filter arg doesn't break multi-expression templates (#3307)
- Support namespaced git feature branch templates (#3293)
- chore(deps): bump actions/setup-dotnet from 5.3.0 to 5.4.0 (#3315)
- fix(integrations): cursor-agent honors executable/extra-args env overrides (#3265)
- docs: drop stale kimi KIMI.md->AGENTS.md migration note (#3291)
- chore: release 0.12.4, begin 0.12.5.dev0 development (#3305)
## [0.12.4] - 2026-07-02
### 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

@@ -42,7 +42,6 @@ The following community-contributed extensions are available in [`catalog.commun
| Bugfix Workflow | Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically | `process` | Read+Write | [spec-kit-bugfix](https://github.com/Quratulain-bilal/spec-kit-bugfix) |
| Canon | Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation. | `process` | Read+Write | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon/tree/master/extension) |
| Catalog CI | Automated validation for spec-kit community catalog entries — structure, URLs, diffs, and linting | `process` | Read-only | [spec-kit-catalog-ci](https://github.com/Quratulain-bilal/spec-kit-catalog-ci) |
| Charter | Compose modular project constitutions from shared fragment registries. Centralize governance rules, select per-project fragments, track upstream changes, and keep multi-project setups consistent. | `process` | Read+Write | [spec-kit-charter](https://github.com/Fyloss/spec-kit-charter) |
| CI Guard | Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps | `process` | Read-only | [spec-kit-ci-guard](https://github.com/Quratulain-bilal/spec-kit-ci-guard) |
| Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) |
| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |
@@ -51,7 +50,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Confluence Extension | Create a doc in Confluence summarizing the specifications and planning files | `integration` | Read+Write | [spec-kit-confluence](https://github.com/aaronrsun/spec-kit-confluence) |
| 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) |
| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. One pinned runtime dependency; pure Node.js otherwise. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
| 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) |
@@ -67,7 +66,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) |
@@ -87,7 +85,6 @@ The following community-contributed extensions are available in [`catalog.commun
| .NET Framework to Modern .NET Migration | Orchestrate end-to-end .NET Framework to modern .NET migration across 7 phases, with SDD lifecycle integration | `process` | Read+Write | [spec-kit-fx-to-net](https://github.com/RogerBestMsft/spec-kit-FxToNet) |
| Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) |
| Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) |
| Orchestration Task Context Management | Adds subagent work-unit orchestration to generated Spec Kit task files | `process` | Read+Write | [spec-kit-orchestration-task-context-management](https://github.com/benizzio/spec-kit-orchestration-task-context-management) |
| OWASP LLM Threat Model | OWASP Top 10 for LLM Applications 2025 threat analysis on agent artifacts | `code` | Read-only | [spec-kit-threatmodel](https://github.com/NaviaSamal/spec-kit-threatmodel) |
| Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) |
| PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) |
@@ -108,7 +105,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Retro Extension | Sprint retrospective analysis with metrics, spec accuracy assessment, and improvement suggestions | `process` | Read+Write | [spec-kit-retro](https://github.com/arunt14/spec-kit-retro) |
| Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | `docs` | Read+Write | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) |
| Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) |
| Ripple | Detect side effects that tests can't catch after implementation — surface hidden ripple effects across 9 analysis categories | `code` | Read+Write | [spec-kit-ripple](https://github.com/chordpli/spec-kit-ripple) |
| Ripple | Detect side effects that tests can't catch after implementation — delta-anchored analysis across 9 domain-agnostic categories | `code` | Read+Write | [spec-kit-ripple](https://github.com/chordpli/spec-kit-ripple) |
| SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) |
| Security Review | Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews | `code` | Read+Write | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) |
| SFSpeckit | Enterprise Salesforce SDLC with 18 commands for the full SDD lifecycle. | `process` | Read+Write | [spec-kit-sf](https://github.com/ysumanth06/spec-kit-sf) |

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

@@ -24,7 +24,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent |
| [Junie](https://junie.jetbrains.com/) | `junie` | |
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | |
| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; installs into `.kimi-code/skills/`. `--migrate-legacy` moves old `.kimi/skills/` installs to the new paths |
| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; installs into `.kimi-code/skills/`. `--migrate-legacy` moves old `.kimi/skills/` installs to the new paths, and (when the `agent-context` extension is enabled) migrates `KIMI.md` context into `AGENTS.md` |
| [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Kiro CLI does not substitute `$ARGUMENTS` in file-based prompts, so Spec Kit ships a prose fallback at render time (see [Manage prompts](https://kiro.dev/docs/cli/chat/manage-prompts/) and issue [#1926](https://github.com/github/spec-kit/issues/1926)). Alias: `--integration kiro` |
| [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically |
| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | |
@@ -218,7 +218,7 @@ Some integrations accept additional options via `--integration-options`:
| Integration | Option | Description |
| ----------- | ------------------- | -------------------------------------------------------------- |
| `generic` | `--commands-dir` | Required. Directory for command files |
| `kimi` | `--migrate-legacy` | Migrate legacy `.kimi/skills/` installs to `.kimi-code/skills/` (including dotted→hyphenated skill naming, e.g. `speckit.xxx``speckit-xxx`) |
| `kimi` | `--migrate-legacy` | Migrate legacy `.kimi/skills/` installs to `.kimi-code/skills/` (including dotted→hyphenated directory names); when the `agent-context` extension is enabled, also migrates `KIMI.md` to `AGENTS.md` |
Example:

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-06T00:00:00Z",
"updated_at": "2026-07-01T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -670,40 +670,6 @@
"created_at": "2026-04-11T18:00:00Z",
"updated_at": "2026-04-11T18:00:00Z"
},
"charter": {
"name": "Charter",
"id": "charter",
"description": "Compose modular project constitutions from shared fragment registries. Centralize governance rules, select per-project fragments, track upstream changes, and keep multi-project setups consistent.",
"author": "Fyloss",
"version": "0.3.1",
"download_url": "https://github.com/Fyloss/spec-kit-charter/archive/refs/tags/v0.3.1.zip",
"repository": "https://github.com/Fyloss/spec-kit-charter",
"homepage": "https://github.com/Fyloss/spec-kit-charter",
"documentation": "https://github.com/Fyloss/spec-kit-charter/tree/master/docs",
"changelog": "https://github.com/Fyloss/spec-kit-charter/blob/master/CHANGELOG.md",
"license": "MIT",
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.11.9"
},
"provides": {
"commands": 5,
"hooks": 1
},
"tags": [
"constitution",
"governance",
"modular",
"fragments",
"registry"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-06T00:00:00Z",
"updated_at": "2026-07-06T00:00:00Z"
},
"ci-guard": {
"name": "CI Guard",
"id": "ci-guard",
@@ -1106,10 +1072,10 @@
"docguard": {
"name": "DocGuard — CDD Enforcement",
"id": "docguard",
"description": "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.",
"description": "Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. One pinned runtime dependency; pure Node.js otherwise.",
"author": "raccioly",
"version": "0.30.0",
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.30.0/spec-kit-docguard-v0.30.0.zip",
"version": "0.28.0",
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.28.0/spec-kit-docguard-v0.28.0.zip",
"repository": "https://github.com/raccioly/docguard",
"homepage": "https://www.npmjs.com/package/docguard-cli",
"documentation": "https://github.com/raccioly/docguard/blob/main/extensions/spec-kit-docguard/README.md",
@@ -1145,7 +1111,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-03-13T00:00:00Z",
"updated_at": "2026-07-06T00:00:00Z"
"updated_at": "2026-06-23T00:00:00Z"
},
"doctor": {
"name": "Project Health Check",
@@ -2396,39 +2362,6 @@
"created_at": "2026-04-03T00:00:00Z",
"updated_at": "2026-04-03T00:00:00Z"
},
"orchestration-task-context-management": {
"name": "Orchestration Task Context Management",
"id": "orchestration-task-context-management",
"description": "Adds subagent work-unit orchestration to generated Spec Kit task files",
"author": "Igor Benicio de Mesquita",
"version": "0.0.0",
"download_url": "https://github.com/benizzio/spec-kit-orchestration-task-context-management/archive/refs/tags/v0.0.0.zip",
"repository": "https://github.com/benizzio/spec-kit-orchestration-task-context-management",
"homepage": "https://github.com/benizzio/spec-kit-orchestration-task-context-management",
"documentation": "https://github.com/benizzio/spec-kit-orchestration-task-context-management/blob/main/README.md",
"changelog": "https://github.com/benizzio/spec-kit-orchestration-task-context-management/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.7.2"
},
"provides": {
"commands": 2,
"hooks": 2
},
"tags": [
"agent",
"orchestration",
"tasks",
"context"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-06T00:00:00Z",
"updated_at": "2026-07-06T00:00:00Z"
},
"orchestrator": {
"name": "Spec Orchestrator",
"id": "orchestrator",
@@ -2746,8 +2679,8 @@
"id": "ralph",
"description": "Autonomous implementation loop using AI agent CLI",
"author": "Rubiss",
"version": "1.2.1",
"download_url": "https://github.com/Rubiss-Projects/spec-kit-ralph/archive/refs/tags/v1.2.1.zip",
"version": "1.1.1",
"download_url": "https://github.com/Rubiss-Projects/spec-kit-ralph/archive/refs/tags/v1.1.1.zip",
"repository": "https://github.com/Rubiss-Projects/spec-kit-ralph",
"homepage": "https://github.com/Rubiss-Projects/spec-kit-ralph",
"documentation": "https://github.com/Rubiss-Projects/spec-kit-ralph/blob/main/README.md",
@@ -2756,7 +2689,7 @@
"category": "code",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.8.5",
"speckit_version": ">=0.1.0",
"tools": [
{
"name": "copilot",
@@ -2766,10 +2699,6 @@
"name": "codex",
"required": false
},
{
"name": "claude",
"required": false
},
{
"name": "git",
"required": true
@@ -2785,14 +2714,13 @@
"automation",
"loop",
"copilot",
"codex",
"claude"
"codex"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-03-09T00:00:00Z",
"updated_at": "2026-07-06T00:00:00Z"
"updated_at": "2026-06-05T03:11:06Z"
},
"reconcile": {
"name": "Reconcile Extension",
@@ -3119,10 +3047,10 @@
"ripple": {
"name": "Ripple",
"id": "ripple",
"description": "Detect side effects that tests can't catch after implementation — surface hidden ripple effects across 9 analysis categories",
"description": "Detect side effects that tests can't catch after implementation — delta-anchored analysis across 9 domain-agnostic categories with fix-induced side effect detection",
"author": "chordpli",
"version": "1.1.0",
"download_url": "https://github.com/chordpli/spec-kit-ripple/archive/refs/tags/v1.1.0.zip",
"version": "1.0.0",
"download_url": "https://github.com/chordpli/spec-kit-ripple/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/chordpli/spec-kit-ripple",
"homepage": "https://github.com/chordpli/spec-kit-ripple",
"documentation": "https://github.com/chordpli/spec-kit-ripple/blob/main/README.md",
@@ -3131,13 +3059,7 @@
"category": "code",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.2.0",
"tools": [
{
"name": "git",
"required": true
}
]
"speckit_version": ">=0.2.0"
},
"provides": {
"commands": 3,
@@ -3154,7 +3076,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-04-20T00:00:00Z",
"updated_at": "2026-07-06T00:00:00Z"
"updated_at": "2026-04-20T00:00:00Z"
},
"roadmap": {
"name": "Spec Roadmap",
@@ -4375,40 +4297,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

@@ -7,7 +7,7 @@ Git repository initialization, feature branch creation, numbering (sequential/ti
This extension provides Git operations as an optional, self-contained module. It manages:
- **Repository initialization** with configurable commit messages
- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering and optional templates for branch namespaces
- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering
- **Branch validation** to ensure branches follow naming conventions
- **Git remote detection** for GitHub integration (e.g., issue creation)
- **Auto-commit** after core commands (configurable per-command with custom messages)
@@ -53,16 +53,6 @@ Configuration is stored in `.specify/extensions/git/git-config.yml`:
# Branch numbering strategy: "sequential" or "timestamp"
branch_numbering: sequential
# Optional branch name template. Leave empty for the default "{number}-{slug}".
# Supported tokens: {author}, {app}, {number}, {slug}; {slug} must not appear
# before {number}, and the final path segment must start with {number}-.
# Example for monorepos: "{author}/{app}/{number}-{slug}"
branch_template: ""
# Optional shorthand namespace. Leave empty to use branch_template/default behavior.
# Example: "features/{app}" expands to "features/{app}/{number}-{slug}"
branch_prefix: ""
# Custom commit message for git init
init_commit_message: "[Spec Kit] Initial commit"
@@ -75,10 +65,6 @@ auto_commit:
message: "[Spec Kit] Add specification"
```
`{author}` is derived from Git config and sanitized for branch names. `{app}` is derived from the Spec Kit init directory name. Custom templates must not put `{slug}` before `{number}`, and must put `{number}-` at the start of the final path segment so generated names remain valid feature branches. For a monorepo project at `apps/web/.specify/`, a template such as `{author}/{app}/{number}-{slug}` produces branches like `jdoe/web/008-guided-tour`.
For simple namespace-only customization, `branch_prefix` is also accepted as a shorthand and expands to `<branch_prefix>/{number}-{slug}`.
## Installation
```bash

View File

@@ -19,7 +19,7 @@ You **MUST** consider the user input before proceeding (if not empty).
If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set:
- The script uses the exact value as the branch name, bypassing all prefix/suffix generation
- `--short-name`, `--number`, and `--timestamp` flags are ignored
- `FEATURE_NUM` is extracted when the final path segment starts with a numeric or timestamp feature marker (for example `042-name`, `feat/042-name`, or `jdoe/app/042-name`), otherwise set to the full branch name
- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name
## Prerequisites
@@ -35,19 +35,6 @@ Determine the branch numbering strategy by checking configuration in this order:
3. Check `.specify/init-options.json` for `branch_numbering` value (deprecated, backward compatibility — will be removed in a future release)
4. Default to `sequential` if none of the above exist
## Branch Name Template
Check `.specify/extensions/git/git-config.yml` for an optional `branch_template` value. If it is empty or missing, use the default branch shape `{number}-{slug}`. If it is set, `{slug}` must not appear before `{number}`, its final path segment must start with `{number}-`, and the script expands these tokens:
- `{author}`: sanitized Git config author (`user.name`, falling back to the email local part)
- `{app}`: sanitized Spec Kit init directory name
- `{number}`: sequential number or timestamp
- `{slug}`: generated short branch slug
For monorepos, a template such as `{author}/{app}/{number}-{slug}` creates names like `jdoe/web/008-guided-tour` while preserving per-project feature numbering.
The script also accepts `branch_prefix` as a shorthand for simple namespaces; it expands to `<branch_prefix>/{number}-{slug}`.
## Execution
Generate a concise short name (2-4 words) for the branch:
@@ -67,7 +54,6 @@ Run the appropriate script based on your platform:
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
- You must only ever run this script once per feature
- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM`
- Do not manually expand `branch_template`; the script reads the git extension config and applies it consistently
## Graceful Degradation
@@ -78,5 +64,5 @@ If Git is not installed or the current directory is not a Git repository:
## Output
The script outputs JSON with:
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth`, `20260319-143022-user-auth`, or `jdoe/web/003-user-auth`)
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
- `FEATURE_NUM`: The numeric or timestamp prefix used

View File

@@ -22,24 +22,24 @@ Get the current branch name:
git rev-parse --abbrev-ref HEAD
```
The branch name's final path segment must start with one of these feature markers:
The branch name must match one of these patterns:
1. **Sequential**: `[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`, `jdoe/web/008-guided-tour`)
2. **Timestamp**: `[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`, `jdoe/web/20260319-143022-feature-name`)
1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`)
2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`)
## Execution
If on a feature branch (matches either pattern):
- Output: `✓ On feature branch: <branch-name>`
- Check if the corresponding spec directory exists under `specs/`:
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion, regardless of branch namespace prefixes
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion, regardless of branch namespace prefixes
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion
- If spec directory exists: `✓ Spec directory found: <path>`
- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
If NOT on a feature branch:
- Output: `✗ Not on a feature branch. Current branch: <branch-name>`
- Output: `Feature branches should be named like: 001-feature-name, 20260319-143022-feature-name, or <namespace>/001-feature-name`
- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name`
## Graceful Degradation

View File

@@ -4,16 +4,6 @@
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
branch_numbering: sequential
# Optional branch name template. Leave empty for the default "{number}-{slug}".
# Supported tokens: {author}, {app}, {number}, {slug}
# {slug} must not appear before {number}; final path segment must start with {number}-.
# Example for monorepos: "{author}/{app}/{number}-{slug}"
branch_template: ""
# Optional shorthand namespace. Leave empty to use branch_template/default behavior.
# Example: "features/{app}" expands to "features/{app}/{number}-{slug}"
branch_prefix: ""
# Commit message used by `git commit` during repository initialization
init_commit_message: "[Spec Kit] Initial commit"

View File

@@ -4,7 +4,7 @@ extension:
id: git
name: "Git Branching Workflow"
version: "1.0.0"
description: "Feature branch creation, numbering (sequential/timestamp), templating, validation, and Git remote detection"
description: "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection"
author: spec-kit-core
repository: https://github.com/github/spec-kit
license: MIT
@@ -19,7 +19,7 @@ provides:
commands:
- name: speckit.git.feature
file: commands/speckit.git.feature.md
description: "Create a feature branch with sequential or timestamp numbering and optional templates"
description: "Create a feature branch with sequential or timestamp numbering"
- name: speckit.git.validate
file: commands/speckit.git.validate.md
description: "Validate current branch follows feature branch naming conventions"
@@ -137,6 +137,4 @@ tags:
config:
defaults:
branch_numbering: sequential
branch_template: ""
branch_prefix: ""
init_commit_message: "[Spec Kit] Initial commit"

View File

@@ -4,16 +4,6 @@
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
branch_numbering: sequential
# Optional branch name template. Leave empty for the default "{number}-{slug}".
# Supported tokens: {author}, {app}, {number}, {slug}
# {slug} must not appear before {number}; final path segment must start with {number}-.
# Example for monorepos: "{author}/{app}/{number}-{slug}"
branch_template: ""
# Optional shorthand namespace. Leave empty to use branch_template/default behavior.
# Example: "features/{app}" expands to "features/{app}/{number}-{slug}"
branch_prefix: ""
# Commit message used by `git commit` during repository initialization
init_commit_message: "[Spec Kit] Initial commit"

View File

@@ -75,10 +75,6 @@ while [ $i -le $# ]; do
echo "Environment variables:"
echo " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
echo ""
echo "Configuration:"
echo " branch_template Optional git-config.yml template with {author}, {app}, {number}, {slug}"
echo " branch_prefix Optional shorthand namespace expanded before {number}-{slug}"
echo ""
echo "Examples:"
echo " $0 'Add user authentication system' --short-name 'user-auth'"
echo " $0 'Implement OAuth2 integration for API' --number 5"
@@ -131,28 +127,16 @@ get_highest_from_specs() {
# Function to get highest number from git branches
get_highest_from_branches() {
local scope_prefix="${1:-}"
git branch -a 2>/dev/null | sed -E 's/^[+*][[:space:]]+//; s/^[[:space:]]+//; s|^remotes/[^/]*/||' | _extract_highest_number "$scope_prefix"
git branch -a 2>/dev/null | sed -E 's/^[+*][[:space:]]+//; s/^[[:space:]]+//; s|^remotes/[^/]*/||' | _extract_highest_number
}
# Extract the highest sequential feature number from a list of ref names (one per line).
_extract_highest_number() {
local scope_prefix="${1:-}"
local highest=0
while IFS= read -r name; do
[ -z "$name" ] && continue
if [ -n "$scope_prefix" ]; then
case "$name" in
"$scope_prefix"*) name="${name#"$scope_prefix"}" ;;
*) continue ;;
esac
fi
name="${name##*/}"
if echo "$name" | grep -Eq '^[0-9]{3,}-' \
&& ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-' \
&& ! echo "$name" | grep -Eq '^[0-9]{7}-[0-9]{6}-' \
&& ! echo "$name" | grep -Eq '^[0-9]{7,8}-[0-9]{6}$'; then
number=$(echo "$name" | grep -Eo '^[0-9]{3,}-' | sed -E 's/-$//' || echo "0")
if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0")
number=$((10#$number))
if [ "$number" -gt "$highest" ]; then
highest=$number
@@ -164,12 +148,11 @@ _extract_highest_number() {
# Function to get highest number from remote branches without fetching (side-effect-free)
get_highest_from_remote_refs() {
local scope_prefix="${1:-}"
local highest=0
for remote in $(git remote 2>/dev/null); do
local remote_highest
remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number "$scope_prefix")
remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number)
if [ "$remote_highest" -gt "$highest" ]; then
highest=$remote_highest
fi
@@ -182,17 +165,16 @@ get_highest_from_remote_refs() {
check_existing_branches() {
local specs_dir="$1"
local skip_fetch="${2:-false}"
local scope_prefix="${3:-}"
if [ "$skip_fetch" = true ]; then
local highest_remote=$(get_highest_from_remote_refs "$scope_prefix")
local highest_branch=$(get_highest_from_branches "$scope_prefix")
local highest_remote=$(get_highest_from_remote_refs)
local highest_branch=$(get_highest_from_branches)
if [ "$highest_remote" -gt "$highest_branch" ]; then
highest_branch=$highest_remote
fi
else
git fetch --all --prune >/dev/null 2>&1 || true
local highest_branch=$(get_highest_from_branches "$scope_prefix")
local highest_branch=$(get_highest_from_branches)
fi
local highest_spec=$(get_highest_from_specs "$specs_dir")
@@ -291,152 +273,6 @@ fi
cd "$REPO_ROOT"
SPECS_DIR="$REPO_ROOT/specs"
CONFIG_FILE="$REPO_ROOT/.specify/extensions/git/git-config.yml"
read_git_config_value() {
local key="$1"
[ -f "$CONFIG_FILE" ] || return 0
grep -E "^[[:space:]]*${key}:" "$CONFIG_FILE" 2>/dev/null \
| head -n 1 \
| sed -E "s/^[[:space:]]*${key}:[[:space:]]*//" \
| sed -E 's/[[:space:]]+#.*$//' \
| sed -E 's/^[[:space:]]+|[[:space:]]+$//g' \
| sed -E 's/^"//; s/"$//' \
| sed -E "s/^'//; s/'$//"
}
branch_token() {
local value="$1"
local fallback="$2"
local cleaned
cleaned=$(clean_branch_name "$value")
if [ -n "$cleaned" ]; then
printf '%s\n' "$cleaned"
else
printf '%s\n' "$fallback"
fi
}
get_author_token() {
local author=""
if command -v git >/dev/null 2>&1; then
author=$(git config user.name 2>/dev/null || true)
if [ -z "$author" ]; then
author=$(git config user.email 2>/dev/null | sed 's/@.*$//' || true)
fi
fi
if [ -z "$author" ]; then
author="${USER:-unknown}"
fi
branch_token "$author" "unknown"
}
get_app_token() {
branch_token "$(basename "$REPO_ROOT")" "app"
}
resolve_branch_template() {
local template
local prefix
template=$(read_git_config_value "branch_template")
if [ -n "$template" ]; then
printf '%s\n' "$template"
return
fi
prefix=$(read_git_config_value "branch_prefix")
if [ -z "$prefix" ]; then
printf '%s\n' ""
return
fi
case "$prefix" in
*/) printf '%s%s\n' "$prefix" "{number}-{slug}" ;;
*) printf '%s/%s\n' "$prefix" "{number}-{slug}" ;;
esac
}
render_branch_template() {
local template="$1"
local feature_num="$2"
local branch_suffix="$3"
local rendered="$template"
rendered=${rendered//\{author\}/$AUTHOR_TOKEN}
rendered=${rendered//\{app\}/$APP_TOKEN}
rendered=${rendered//\{number\}/$feature_num}
rendered=${rendered//\{slug\}/$branch_suffix}
printf '%s\n' "$rendered"
}
validate_branch_template() {
local template="$1"
[ -n "$template" ] || return 0
local feature_segment
feature_segment="${template##*/}"
case "$template" in
*"{number}"*) ;;
*)
>&2 echo "Error: branch_template must include the {number} token so generated branches remain valid feature branches."
exit 1
;;
esac
case "$template" in
*"{slug}"*"{number}"*)
>&2 echo "Error: branch_template must not place {slug} before {number}; use {slug} only in the final feature segment."
exit 1
;;
esac
case "$feature_segment" in
"{number}-"*) ;;
*)
>&2 echo "Error: branch_template must put {number}- at the start of the final path segment so generated branches remain valid feature branches."
exit 1
;;
esac
}
build_branch_name() {
local feature_num="$1"
local branch_suffix="$2"
if [ -n "$BRANCH_TEMPLATE" ]; then
render_branch_template "$BRANCH_TEMPLATE" "$feature_num" "$branch_suffix"
else
printf '%s-%s\n' "$feature_num" "$branch_suffix"
fi
}
branch_scope_prefix() {
local template="$1"
local prefix="$template"
[ -n "$prefix" ] || return 0
case "$prefix" in
*"{number}"*) prefix="${prefix%%\{number\}*}" ;;
*"{slug}"*) prefix="${prefix%%\{slug\}*}" ;;
*) return 0 ;;
esac
render_branch_template "$prefix" "" "$BRANCH_SUFFIX"
}
extract_feature_num_from_branch() {
local branch_name="$1"
local feature_segment="${branch_name##*/}"
local match
match=$(printf '%s\n' "$feature_segment" | grep -Eo '^[0-9]{8}-[0-9]{6}-' | head -n 1 || true)
if [ -n "$match" ]; then
printf '%s\n' "$match" | sed -E 's/-$//'
return
fi
match=$(printf '%s\n' "$feature_segment" | grep -Eo '^[0-9]+-' | head -n 1 || true)
if [ -n "$match" ]; then
printf '%s\n' "$match" | sed -E 's/-$//'
return
fi
printf '%s\n' "$branch_name"
}
AUTHOR_TOKEN=$(get_author_token)
APP_TOKEN=$(get_app_token)
BRANCH_TEMPLATE=$(resolve_branch_template)
validate_branch_template "$BRANCH_TEMPLATE"
# Function to generate branch name with stop word filtering
generate_branch_name() {
@@ -482,8 +318,18 @@ generate_branch_name() {
# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
if [ -n "${GIT_BRANCH_NAME:-}" ]; then
BRANCH_NAME="$GIT_BRANCH_NAME"
FEATURE_NUM=$(extract_feature_num_from_branch "$BRANCH_NAME")
BRANCH_SUFFIX="$BRANCH_NAME"
# Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
# Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^[0-9]+ pattern
if echo "$BRANCH_NAME" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]{8}-[0-9]{6}')
BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
elif echo "$BRANCH_NAME" | grep -Eq '^[0-9]+-'; then
FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]+')
BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
else
FEATURE_NUM="$BRANCH_NAME"
BRANCH_SUFFIX="$BRANCH_NAME"
fi
else
# Generate branch name
if [ -n "$SHORT_NAME" ]; then
@@ -501,17 +347,16 @@ else
# Determine branch prefix
if [ "$USE_TIMESTAMP" = true ]; then
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
else
BRANCH_SCOPE_PREFIX=$(branch_scope_prefix "$BRANCH_TEMPLATE")
if [ -z "$BRANCH_NUMBER" ]; then
if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true "$BRANCH_SCOPE_PREFIX")
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
elif [ "$DRY_RUN" = true ]; then
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$((HIGHEST + 1))
elif [ "$HAS_GIT" = true ]; then
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" false "$BRANCH_SCOPE_PREFIX")
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
else
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$((HIGHEST + 1))
@@ -519,7 +364,7 @@ else
fi
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
fi
fi
@@ -531,23 +376,18 @@ if [ -n "${GIT_BRANCH_NAME:-}" ] && [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH
>&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is ${BRANCH_BYTE_LEN} bytes."
exit 1
elif [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
TRUNCATED_SUFFIX="$BRANCH_SUFFIX"
while [ "$(_byte_length "$BRANCH_NAME")" -gt "$MAX_BRANCH_LENGTH" ] && [ -n "$TRUNCATED_SUFFIX" ]; do
TRUNCATED_SUFFIX="${TRUNCATED_SUFFIX%?}"
TRUNCATED_SUFFIX="${TRUNCATED_SUFFIX%-}"
BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$TRUNCATED_SUFFIX")
done
if [ "$(_byte_length "$BRANCH_NAME")" -gt "$MAX_BRANCH_LENGTH" ]; then
>&2 echo "Error: Branch template prefix exceeds GitHub's 244-byte branch name limit."
exit 1
fi
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
ORIGINAL_BRANCH_BYTE_LEN=$(_byte_length "$ORIGINAL_BRANCH_NAME")
TRUNCATED_BRANCH_BYTE_LEN=$(_byte_length "$BRANCH_NAME")
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${ORIGINAL_BRANCH_BYTE_LEN} bytes)"
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${TRUNCATED_BRANCH_BYTE_LEN} bytes)"
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
fi
if [ "$DRY_RUN" != true ]; then

View File

@@ -23,9 +23,8 @@ spec_kit_effective_branch_name() {
}
# Validate that a branch name matches the expected feature branch pattern.
# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats,
# either at the start of the branch or after path-style namespace prefixes.
# Logic aligned with the git extension's PowerShell Test-FeatureBranch twin.
# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats.
# Logic aligned with scripts/bash/common.sh check_feature_branch after effective-name normalization.
check_feature_branch() {
local raw="$1"
local has_git_repo="$2"
@@ -38,17 +37,16 @@ check_feature_branch() {
local branch
branch=$(spec_kit_effective_branch_name "$raw")
local feature_segment="${branch##*/}"
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
local is_sequential=false
if [[ "$feature_segment" =~ ^[0-9]{3,}- ]] && [[ ! "$feature_segment" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$feature_segment" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
is_sequential=true
fi
if [[ "$is_sequential" != "true" ]] && [[ ! "$feature_segment" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
echo "ERROR: Not on a feature branch. Current branch: $raw" >&2
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, 20260319-143022-feature-name, or <prefix>/001-feature-name" >&2
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2
return 1
fi

View File

@@ -34,10 +34,6 @@ if ($Help) {
Write-Host "Environment variables:"
Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
Write-Host ""
Write-Host "Configuration:"
Write-Host " branch_template Optional git-config.yml template with {author}, {app}, {number}, {slug}"
Write-Host " branch_prefix Optional shorthand namespace expanded before {number}-{slug}"
Write-Host ""
exit 0
}
@@ -71,23 +67,11 @@ function Get-HighestNumberFromSpecs {
}
function Get-HighestNumberFromNames {
param(
[string[]]$Names,
[string]$ScopePrefix = ''
)
param([string[]]$Names)
[long]$highest = 0
foreach ($name in $Names) {
if ($ScopePrefix -and -not $name.StartsWith($ScopePrefix, [System.StringComparison]::Ordinal)) {
continue
}
if ($ScopePrefix) {
$name = $name.Substring($ScopePrefix.Length)
}
$name = ($name -split '/')[-1]
$hasTimestampPrefix = $name -match '^\d{8}-\d{6}-'
$hasMalformedTimestamp = ($name -match '^\d{7}-\d{6}-') -or ($name -match '^(?:\d{7}|\d{8})-\d{6}$')
if ($name -match '^(\d{3,})-' -and -not $hasTimestampPrefix -and -not $hasMalformedTimestamp) {
if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') {
[long]$num = 0
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
$highest = $num
@@ -98,7 +82,7 @@ function Get-HighestNumberFromNames {
}
function Get-HighestNumberFromBranches {
param([string]$ScopePrefix = '')
param()
try {
$branches = git branch -a 2>$null
@@ -106,7 +90,7 @@ function Get-HighestNumberFromBranches {
$cleanNames = $branches | ForEach-Object {
$_.Trim() -replace '^[+*]?\s+', '' -replace '^remotes/[^/]+/', ''
}
return Get-HighestNumberFromNames -Names $cleanNames -ScopePrefix $ScopePrefix
return Get-HighestNumberFromNames -Names $cleanNames
}
} catch {
Write-Verbose "Could not check Git branches: $_"
@@ -115,8 +99,6 @@ function Get-HighestNumberFromBranches {
}
function Get-HighestNumberFromRemoteRefs {
param([string]$ScopePrefix = '')
[long]$highest = 0
try {
$remotes = git remote 2>$null
@@ -129,7 +111,7 @@ function Get-HighestNumberFromRemoteRefs {
$refNames = $refs | ForEach-Object {
if ($_ -match 'refs/heads/(.+)$') { $matches[1] }
} | Where-Object { $_ }
$remoteHighest = Get-HighestNumberFromNames -Names $refNames -ScopePrefix $ScopePrefix
$remoteHighest = Get-HighestNumberFromNames -Names $refNames
if ($remoteHighest -gt $highest) { $highest = $remoteHighest }
}
}
@@ -143,19 +125,18 @@ function Get-HighestNumberFromRemoteRefs {
function Get-NextBranchNumber {
param(
[string]$SpecsDir,
[switch]$SkipFetch,
[string]$ScopePrefix = ''
[switch]$SkipFetch
)
if ($SkipFetch) {
$highestBranch = Get-HighestNumberFromBranches -ScopePrefix $ScopePrefix
$highestRemote = Get-HighestNumberFromRemoteRefs -ScopePrefix $ScopePrefix
$highestBranch = Get-HighestNumberFromBranches
$highestRemote = Get-HighestNumberFromRemoteRefs
$highestBranch = [Math]::Max($highestBranch, $highestRemote)
} else {
try {
git fetch --all --prune 2>$null | Out-Null
} catch { }
$highestBranch = Get-HighestNumberFromBranches -ScopePrefix $ScopePrefix
$highestBranch = Get-HighestNumberFromBranches
}
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
@@ -251,145 +232,6 @@ if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) {
Set-Location $repoRoot
$specsDir = Join-Path $repoRoot 'specs'
$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
function Read-GitConfigValue {
param([string]$Key)
if (-not (Test-Path -LiteralPath $configFile -PathType Leaf)) { return '' }
$escapedKey = [regex]::Escape($Key)
foreach ($line in Get-Content -LiteralPath $configFile) {
if ($line -match "^\s*$escapedKey\s*:\s*(.*)$") {
$val = ($matches[1] -replace '\s+#.*$', '').Trim()
$val = $val -replace '^["'']', '' -replace '["'']$', ''
return $val
}
}
return ''
}
function ConvertTo-BranchToken {
param(
[string]$Value,
[string]$Fallback
)
$cleaned = ConvertTo-CleanBranchName -Name $Value
if ($cleaned) { return $cleaned }
return $Fallback
}
function Get-GitAuthorToken {
$author = ''
if (Get-Command git -ErrorAction SilentlyContinue) {
try { $author = (git config user.name 2>$null | Out-String).Trim() } catch {}
if (-not $author) {
try {
$email = (git config user.email 2>$null | Out-String).Trim()
if ($email) { $author = ($email -split '@')[0] }
} catch {}
}
}
if (-not $author) { $author = if ($env:USER) { $env:USER } elseif ($env:USERNAME) { $env:USERNAME } else { 'unknown' } }
return ConvertTo-BranchToken -Value $author -Fallback 'unknown'
}
function Get-AppToken {
return ConvertTo-BranchToken -Value (Split-Path $repoRoot -Leaf) -Fallback 'app'
}
function Resolve-BranchTemplate {
$template = Read-GitConfigValue -Key 'branch_template'
if ($template) { return $template }
$prefix = Read-GitConfigValue -Key 'branch_prefix'
if (-not $prefix) { return '' }
if ($prefix.EndsWith('/')) { return "${prefix}{number}-{slug}" }
return "$prefix/{number}-{slug}"
}
function Expand-BranchTemplate {
param(
[string]$Template,
[string]$FeatureNum,
[string]$BranchSuffix
)
$rendered = $Template.Replace('{author}', $authorToken)
$rendered = $rendered.Replace('{app}', $appToken)
$rendered = $rendered.Replace('{number}', $FeatureNum)
$rendered = $rendered.Replace('{slug}', $BranchSuffix)
return $rendered
}
function Assert-BranchTemplateValid {
param([string]$Template)
if ($Template -and -not $Template.Contains('{number}')) {
throw "branch_template must include the {number} token so generated branches remain valid feature branches."
}
if ($Template) {
$numberIndex = $Template.IndexOf('{number}', [System.StringComparison]::Ordinal)
$slugIndex = $Template.IndexOf('{slug}', [System.StringComparison]::Ordinal)
if ($slugIndex -ge 0 -and $slugIndex -lt $numberIndex) {
throw "branch_template must not place {slug} before {number}; use {slug} only in the final feature segment."
}
$featureSegment = ($Template -split '/')[-1]
if (-not $featureSegment.StartsWith('{number}-', [System.StringComparison]::Ordinal)) {
throw "branch_template must put {number}- at the start of the final path segment so generated branches remain valid feature branches."
}
}
}
function New-BranchName {
param(
[string]$FeatureNum,
[string]$BranchSuffix
)
if ($branchTemplate) {
return Expand-BranchTemplate -Template $branchTemplate -FeatureNum $FeatureNum -BranchSuffix $BranchSuffix
}
return "$FeatureNum-$BranchSuffix"
}
function Get-BranchScopePrefix {
param(
[string]$Template,
[string]$BranchSuffix
)
if (-not $Template) { return '' }
$numberIndex = $Template.IndexOf('{number}', [System.StringComparison]::Ordinal)
$slugIndex = $Template.IndexOf('{slug}', [System.StringComparison]::Ordinal)
$indexes = @($numberIndex, $slugIndex) | Where-Object { $_ -ge 0 } | Sort-Object
if (-not $indexes) { return '' }
$prefix = $Template.Substring(0, $indexes[0])
return Expand-BranchTemplate -Template $prefix -FeatureNum '' -BranchSuffix $BranchSuffix
}
function Get-FeatureNumberFromBranchName {
param([string]$BranchName)
$featureSegment = ($BranchName -split '/')[-1]
if ($featureSegment -match '^(\d{8}-\d{6})-') {
return $matches[1]
}
if ($featureSegment -match '^(\d+)-') {
return $matches[1]
}
return $BranchName
}
function Get-Utf8ByteCount {
param([string]$Value)
return [System.Text.Encoding]::UTF8.GetByteCount($Value)
}
$authorToken = Get-GitAuthorToken
$appToken = Get-AppToken
$branchTemplate = Resolve-BranchTemplate
Assert-BranchTemplateValid -Template $branchTemplate
function Get-BranchName {
param([string]$Description)
@@ -434,11 +276,19 @@ function Get-BranchName {
if ($env:GIT_BRANCH_NAME) {
$branchName = $env:GIT_BRANCH_NAME
# Check 244-byte limit (UTF-8) for override names
$branchNameUtf8ByteCount = Get-Utf8ByteCount -Value $branchName
$branchNameUtf8ByteCount = [System.Text.Encoding]::UTF8.GetByteCount($branchName)
if ($branchNameUtf8ByteCount -gt 244) {
throw "GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is $branchNameUtf8ByteCount bytes; please supply a shorter override branch name."
}
$featureNum = Get-FeatureNumberFromBranchName -BranchName $branchName
# Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
# Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^\d+ pattern
if ($branchName -match '^(\d{8}-\d{6})-') {
$featureNum = $matches[1]
} elseif ($branchName -match '^(\d+)-') {
$featureNum = $matches[1]
} else {
$featureNum = $branchName
}
} else {
if ($ShortName) {
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
@@ -453,41 +303,39 @@ if ($env:GIT_BRANCH_NAME) {
if ($Timestamp) {
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
$branchName = "$featureNum-$branchSuffix"
} else {
$branchScopePrefix = Get-BranchScopePrefix -Template $branchTemplate -BranchSuffix $branchSuffix
if ($Number -eq 0) {
if ($DryRun -and $hasGit) {
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch -ScopePrefix $branchScopePrefix
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch
} elseif ($DryRun) {
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
} elseif ($hasGit) {
$Number = Get-NextBranchNumber -SpecsDir $specsDir -ScopePrefix $branchScopePrefix
$Number = Get-NextBranchNumber -SpecsDir $specsDir
} else {
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
}
}
$featureNum = ('{0:000}' -f $Number)
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
$branchName = "$featureNum-$branchSuffix"
}
}
$maxBranchLength = 244
if ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength) {
if ($branchName.Length -gt $maxBranchLength) {
$prefixLength = $featureNum.Length + 1
$maxSuffixLength = $maxBranchLength - $prefixLength
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
$originalBranchName = $branchName
$truncatedSuffix = $branchSuffix
while ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength -and $truncatedSuffix.Length -gt 0) {
$truncatedSuffix = $truncatedSuffix.Substring(0, $truncatedSuffix.Length - 1) -replace '-$', ''
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $truncatedSuffix
}
if ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength) {
throw "Branch template prefix exceeds GitHub's 244-byte branch name limit."
}
$branchName = "$featureNum-$truncatedSuffix"
Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit"
Write-Warning "[specify] Original: $originalBranchName ($(Get-Utf8ByteCount -Value $originalBranchName) bytes)"
Write-Warning "[specify] Truncated to: $branchName ($(Get-Utf8ByteCount -Value $branchName) bytes)"
Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)"
Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)"
}
if (-not $DryRun) {

View File

@@ -37,15 +37,14 @@ function Test-FeatureBranch {
$raw = $Branch
$Branch = Get-SpecKitEffectiveBranchName $raw
$featureSegment = ($Branch -split '/')[-1]
# Accept sequential prefix (3+ digits), at the start or after namespace
# segments, but exclude malformed timestamps.
$hasMalformedTimestamp = ($featureSegment -match '^[0-9]{7}-[0-9]{6}-') -or ($featureSegment -match '^(?:\d{7}|\d{8})-\d{6}$')
$isSequential = ($featureSegment -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
if (-not $isSequential -and $featureSegment -notmatch '^\d{8}-\d{6}-') {
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
$hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
$isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') {
[Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw")
[Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, 20260319-143022-feature-name, or <prefix>/001-feature-name")
[Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name")
return $false
}
return $true

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.12.8"
version = "0.12.4"
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; } \

View File

@@ -1,207 +0,0 @@
#!/usr/bin/env python3
"""Consolidated prerequisite checking script."""
from __future__ import annotations
import json
import sys
from dataclasses import dataclass
from pathlib import Path
try:
from common import FeaturePaths, format_speckit_command, get_feature_paths
except ImportError: # pragma: no cover - direct execution from unusual cwd
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import FeaturePaths, format_speckit_command, get_feature_paths
def _json_line(payload: object) -> str:
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
HELP_TEXT = """Usage: check_prerequisites.py [OPTIONS]
Consolidated prerequisite checking for Spec-Driven Development workflow.
OPTIONS:
--json Output in JSON format
--require-tasks Require tasks.md to exist (for implementation phase)
--include-tasks Include tasks.md in AVAILABLE_DOCS list
--paths-only Only output path variables (no prerequisite validation)
--help, -h Show this help message
EXAMPLES:
# Check task prerequisites (plan.md required)
./check_prerequisites.py --json
# Check implementation prerequisites (plan.md + tasks.md required)
./check_prerequisites.py --json --require-tasks --include-tasks
# Get feature paths only (no validation)
./check_prerequisites.py --paths-only
"""
@dataclass(frozen=True)
class Args:
json_mode: bool = False
require_tasks: bool = False
include_tasks: bool = False
paths_only: bool = False
def _parse_args(argv: list[str]) -> Args:
json_mode = False
require_tasks = False
include_tasks = False
paths_only = False
for arg in argv:
if arg == "--json":
json_mode = True
elif arg == "--require-tasks":
require_tasks = True
elif arg == "--include-tasks":
include_tasks = True
elif arg == "--paths-only":
paths_only = True
elif arg in {"--help", "-h"}:
sys.stdout.write(HELP_TEXT)
raise SystemExit(0)
else:
print(
f"ERROR: Unknown option '{arg}'. Use --help for usage information.",
file=sys.stderr,
)
raise SystemExit(1)
return Args(
json_mode=json_mode,
require_tasks=require_tasks,
include_tasks=include_tasks,
paths_only=paths_only,
)
def _dir_has_entries(path: Path) -> bool:
try:
return path.is_dir() and any(path.iterdir())
except OSError:
return False
def _available_docs(paths: FeaturePaths, include_tasks: bool) -> list[str]:
docs: list[str] = []
if paths.research.is_file():
docs.append("research.md")
if paths.data_model.is_file():
docs.append("data-model.md")
if _dir_has_entries(paths.contracts_dir):
docs.append("contracts/")
if paths.quickstart.is_file():
docs.append("quickstart.md")
if include_tasks and paths.tasks.is_file():
docs.append("tasks.md")
return docs
def _print_paths_only(paths: FeaturePaths, json_mode: bool) -> None:
if json_mode:
sys.stdout.write(
_json_line(
{
"REPO_ROOT": str(paths.repo_root),
"BRANCH": paths.current_branch,
"FEATURE_DIR": str(paths.feature_dir),
"FEATURE_SPEC": str(paths.feature_spec),
"IMPL_PLAN": str(paths.impl_plan),
"TASKS": str(paths.tasks),
}
)
)
return
print(f"REPO_ROOT: {paths.repo_root}")
print(f"BRANCH: {paths.current_branch}")
print(f"FEATURE_DIR: {paths.feature_dir}")
print(f"FEATURE_SPEC: {paths.feature_spec}")
print(f"IMPL_PLAN: {paths.impl_plan}")
print(f"TASKS: {paths.tasks}")
def _check_file(path: Path, description: str) -> None:
marker = "" if path.is_file() else ""
print(f" {marker} {description}")
def _check_dir(path: Path, description: str) -> None:
marker = "" if _dir_has_entries(path) else ""
print(f" {marker} {description}")
def _print_text_results(paths: FeaturePaths, include_tasks: bool) -> None:
print(f"FEATURE_DIR:{paths.feature_dir}")
print("AVAILABLE_DOCS:")
_check_file(paths.research, "research.md")
_check_file(paths.data_model, "data-model.md")
_check_dir(paths.contracts_dir, "contracts/")
_check_file(paths.quickstart, "quickstart.md")
if include_tasks:
_check_file(paths.tasks, "tasks.md")
def main(argv: list[str] | None = None) -> int:
args = _parse_args(list(argv if argv is not None else sys.argv[1:]))
try:
paths = get_feature_paths(
no_persist=args.paths_only,
script_file=Path(__file__),
)
except SystemExit as exc:
if exc.code == 0:
return 0
print("ERROR: Failed to resolve feature paths", file=sys.stderr)
return int(exc.code) if isinstance(exc.code, int) else 1
if args.paths_only:
_print_paths_only(paths, args.json_mode)
return 0
if not paths.feature_dir.is_dir():
print(f"ERROR: Feature directory not found: {paths.feature_dir}", file=sys.stderr)
print(
f"Run {format_speckit_command('specify', paths.repo_root)} first to create the feature structure.",
file=sys.stderr,
)
return 1
if not paths.impl_plan.is_file():
print(f"ERROR: plan.md not found in {paths.feature_dir}", file=sys.stderr)
print(
f"Run {format_speckit_command('plan', paths.repo_root)} first to create the implementation plan.",
file=sys.stderr,
)
return 1
if args.require_tasks and not paths.tasks.is_file():
print(f"ERROR: tasks.md not found in {paths.feature_dir}", file=sys.stderr)
print(
f"Run {format_speckit_command('tasks', paths.repo_root)} first to create the task list.",
file=sys.stderr,
)
return 1
docs = _available_docs(paths, args.include_tasks)
if args.json_mode:
sys.stdout.write(
_json_line({"FEATURE_DIR": str(paths.feature_dir), "AVAILABLE_DOCS": docs})
)
else:
_print_text_results(paths, args.include_tasks)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,210 +0,0 @@
"""Shared helpers for Spec Kit Python scripts."""
from __future__ import annotations
import json
import os
import sys
from dataclasses import dataclass
from pathlib import Path
def _trim_trailing_separators(value: Path) -> str:
text = str(value)
while len(text) > 1 and text.endswith((os.sep, "/")):
text = text[:-1]
return text
def find_specify_root(start_dir: Path | None = None) -> Path | None:
current = (start_dir or Path.cwd()).resolve()
while True:
if (current / ".specify").is_dir():
return current
parent = current.parent
if parent == current:
return None
current = parent
def resolve_specify_init_dir() -> Path:
raw = os.environ.get("SPECIFY_INIT_DIR", "")
candidate = Path(raw)
if not candidate.is_absolute():
candidate = Path.cwd() / candidate
try:
init_root = candidate.resolve(strict=True)
except OSError:
print(
f"ERROR: SPECIFY_INIT_DIR does not point to an existing directory: {raw}",
file=sys.stderr,
)
raise SystemExit(1)
if not init_root.is_dir():
print(
f"ERROR: SPECIFY_INIT_DIR does not point to an existing directory: {raw}",
file=sys.stderr,
)
raise SystemExit(1)
if not (init_root / ".specify").is_dir():
print(
"ERROR: SPECIFY_INIT_DIR is not a Spec Kit project "
f"(no .specify/ directory): {init_root}",
file=sys.stderr,
)
raise SystemExit(1)
return init_root
def get_repo_root(script_file: Path | None = None) -> Path:
if os.environ.get("SPECIFY_INIT_DIR"):
return resolve_specify_init_dir()
specify_root = find_specify_root()
if specify_root is not None:
return specify_root
if script_file is not None:
script_root = find_specify_root(script_file.resolve().parent)
if script_root is not None:
return script_root
# Installed scripts live at .specify/scripts/python/<script>.py.
return script_file.resolve().parents[3]
return Path.cwd().resolve()
def get_current_branch() -> str:
return os.environ.get("SPECIFY_FEATURE", "")
def read_feature_json_feature_directory(repo_root: Path) -> str:
feature_json = repo_root / ".specify" / "feature.json"
if not feature_json.is_file():
return ""
try:
data = json.loads(feature_json.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return ""
value = data.get("feature_directory") if isinstance(data, dict) else None
return value if isinstance(value, str) else ""
def _json_dump(data: dict[str, str]) -> str:
return json.dumps(data, ensure_ascii=False, separators=(",", ":")) + "\n"
def persist_feature_json(repo_root: Path, feature_dir_value: str) -> None:
value = feature_dir_value
try:
relative = Path(value)
if relative.is_absolute():
try:
value = relative.resolve().relative_to(repo_root.resolve()).as_posix()
except ValueError:
value = str(relative)
except OSError:
pass
current = read_feature_json_feature_directory(repo_root)
if current == value:
return
specify_dir = repo_root / ".specify"
specify_dir.mkdir(parents=True, exist_ok=True)
(specify_dir / "feature.json").write_text(
_json_dump({"feature_directory": value}),
encoding="utf-8",
)
@dataclass(frozen=True)
class FeaturePaths:
repo_root: Path
current_branch: str
feature_dir: Path
feature_spec: Path
impl_plan: Path
tasks: Path
research: Path
data_model: Path
quickstart: Path
contracts_dir: Path
def get_feature_paths(
*, no_persist: bool = False, script_file: Path | None = None
) -> FeaturePaths:
repo_root = get_repo_root(script_file)
current_branch = get_current_branch()
feature_dir_raw = os.environ.get("SPECIFY_FEATURE_DIRECTORY", "")
if feature_dir_raw:
feature_dir = Path(feature_dir_raw)
if not feature_dir.is_absolute():
feature_dir = repo_root / feature_dir
if not no_persist:
persist_feature_json(repo_root, feature_dir_raw)
elif (repo_root / ".specify" / "feature.json").is_file():
stored = read_feature_json_feature_directory(repo_root)
if not stored:
print(
"ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY "
"or ensure .specify/feature.json contains feature_directory.",
file=sys.stderr,
)
raise SystemExit(1)
feature_dir = Path(stored)
if not feature_dir.is_absolute():
feature_dir = repo_root / feature_dir
else:
print(
"ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY "
"or run the specify command to create .specify/feature.json.",
file=sys.stderr,
)
raise SystemExit(1)
if not current_branch:
current_branch = Path(_trim_trailing_separators(feature_dir)).name
return FeaturePaths(
repo_root=repo_root,
current_branch=current_branch,
feature_dir=feature_dir,
feature_spec=feature_dir / "spec.md",
impl_plan=feature_dir / "plan.md",
tasks=feature_dir / "tasks.md",
research=feature_dir / "research.md",
data_model=feature_dir / "data-model.md",
quickstart=feature_dir / "quickstart.md",
contracts_dir=feature_dir / "contracts",
)
def get_invoke_separator(repo_root: Path) -> str:
integration_json = repo_root / ".specify" / "integration.json"
if not integration_json.is_file():
return "."
try:
state = json.loads(integration_json.read_text(encoding="utf-8"))
key = state.get("default_integration") or state.get("integration") or ""
settings = state.get("integration_settings")
if isinstance(key, str) and isinstance(settings, dict):
entry = settings.get(key)
if isinstance(entry, dict) and entry.get("invoke_separator") in {".", "-"}:
return entry["invoke_separator"]
except (OSError, json.JSONDecodeError):
pass
return "."
def format_speckit_command(command_name: str, repo_root: Path) -> str:
separator = get_invoke_separator(repo_root)
name = command_name.lstrip("/")
if name.startswith("speckit."):
name = name[len("speckit.") :]
elif name.startswith("speckit-"):
name = name[len("speckit-") :]
name = name.replace(".", separator)
return f"/speckit{separator}{name}"

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
@@ -150,9 +148,7 @@ class CommandRegistrar:
)
return f"---\n{yaml_str}---\n"
def _adjust_script_paths(
self, frontmatter: dict, extension_id: Optional[str] = None
) -> dict:
def _adjust_script_paths(self, frontmatter: dict) -> dict:
"""Normalize script paths in frontmatter to generated project locations.
Rewrites known repo-relative and top-level script paths under the
@@ -162,7 +158,6 @@ class CommandRegistrar:
Args:
frontmatter: Frontmatter dictionary
extension_id: Extension id when rendering extension-owned commands.
Returns:
Modified frontmatter with normalized project paths
@@ -173,15 +168,11 @@ class CommandRegistrar:
if isinstance(scripts, dict):
for key, script_path in scripts.items():
if isinstance(script_path, str):
scripts[key] = self.rewrite_project_relative_paths(
script_path, extension_id=extension_id
)
scripts[key] = self.rewrite_project_relative_paths(script_path)
return frontmatter
@staticmethod
def rewrite_project_relative_paths(
text: str, extension_id: Optional[str] = None
) -> str:
def rewrite_project_relative_paths(text: str) -> str:
"""Rewrite repo-relative paths to their generated project locations."""
if not isinstance(text, str) or not text:
return text
@@ -193,18 +184,10 @@ class CommandRegistrar:
):
text = text.replace(old, new)
# Only rewrite top-level style references so existing generated paths
# like ".specify/extensions/<ext>/scripts/..." remain intact. When
# rendering extension commands, top-level "scripts/" is extension-local.
scripts_replacement = (
f".specify/extensions/{extension_id}/scripts/"
if extension_id
else ".specify/scripts/"
)
# Only rewrite top-level style references so extension-local paths like
# ".specify/extensions/<ext>/scripts/..." remain intact.
text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?memory/', r"\1.specify/memory/", text)
text = re.sub(
r'(^|[\s`"\'(])(?:\.?/)?scripts/', rf"\1{scripts_replacement}", text
)
text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?scripts/', r"\1.specify/scripts/", text)
text = re.sub(
r'(^|[\s`"\'(])(?:\.?/)?templates/', r"\1.specify/templates/", text
)
@@ -260,12 +243,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 +256,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,
@@ -328,7 +312,6 @@ class CommandRegistrar:
source_id: str,
source_file: str,
project_root: Path,
extension_id: Optional[str] = None,
) -> str:
"""Render a command override as a SKILL.md file.
@@ -348,7 +331,7 @@ class CommandRegistrar:
agent_config = self.AGENT_CONFIGS.get(agent_name, {})
if agent_config.get("extension") == "/SKILL.md":
body = self.resolve_skill_placeholders(
agent_name, frontmatter, body, project_root, extension_id=extension_id
agent_name, frontmatter, body, project_root
)
description = frontmatter.get(
@@ -410,11 +393,7 @@ class CommandRegistrar:
@staticmethod
def resolve_skill_placeholders(
agent_name: str,
frontmatter: dict,
body: str,
project_root: Path,
extension_id: Optional[str] = None,
agent_name: str, frontmatter: dict, body: str, project_root: Path
) -> str:
"""Resolve script placeholders for skills-backed agents."""
if not isinstance(frontmatter, dict):
@@ -454,9 +433,7 @@ class CommandRegistrar:
body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name)
return CommandRegistrar.rewrite_project_relative_paths(
body, extension_id=extension_id
)
return CommandRegistrar.rewrite_project_relative_paths(body)
def _convert_argument_placeholder(
self, content: str, from_placeholder: str, to_placeholder: str
@@ -551,7 +528,6 @@ class CommandRegistrar:
context_note: str = None,
_resolved_dir: Path = None,
link_outputs: bool = False,
extension_id: Optional[str] = None,
) -> List[str]:
"""Register commands for a specific agent.
@@ -569,7 +545,6 @@ class CommandRegistrar:
link_outputs: If True, write rendered output to a source-local
dev cache and symlink the agent command file to it. Falls back
to a normal file write when symlinks are unavailable.
extension_id: Extension id when rendering extension-owned commands.
Returns:
List of registered command names
@@ -639,9 +614,7 @@ class CommandRegistrar:
frontmatter[key] = core_frontmatter[key]
frontmatter.pop("strategy", None)
frontmatter = self._adjust_script_paths(
frontmatter, extension_id=extension_id
)
frontmatter = self._adjust_script_paths(frontmatter)
for key in agent_config.get("strip_frontmatter_keys", []):
frontmatter.pop(key, None)
@@ -680,11 +653,10 @@ class CommandRegistrar:
source_id,
cmd_file,
project_root,
extension_id=extension_id,
)
elif agent_config["format"] == "markdown":
body = self.resolve_skill_placeholders(
agent_name, frontmatter, body, project_root, extension_id=extension_id
agent_name, frontmatter, body, project_root
)
body = self._convert_argument_placeholder(
body, "$ARGUMENTS", agent_config["args"]
@@ -694,7 +666,7 @@ class CommandRegistrar:
)
elif agent_config["format"] == "toml":
body = self.resolve_skill_placeholders(
agent_name, frontmatter, body, project_root, extension_id=extension_id
agent_name, frontmatter, body, project_root
)
body = self._convert_argument_placeholder(
body, "$ARGUMENTS", agent_config["args"]
@@ -707,17 +679,6 @@ class CommandRegistrar:
else:
raise ValueError(f"Unsupported format: {agent_config['format']}")
# -- Post-process for non-skills agents -----------------------
_integration = None
if agent_config["extension"] != "/SKILL.md":
from specify_cli.integrations import ( # noqa: PLC0415
get_integration,
)
_integration = get_integration(agent_name)
if _integration is not None:
output = _integration.post_process_command_content(output)
dest_file = commands_dir / f"{output_name}{agent_config['extension']}"
self._ensure_inside(dest_file, commands_dir)
dest_file.parent.mkdir(parents=True, exist_ok=True)
@@ -760,7 +721,6 @@ class CommandRegistrar:
source_id,
cmd_file,
project_root,
extension_id=extension_id,
)
elif agent_config["format"] == "markdown":
alias_output = self.render_markdown_command(
@@ -778,9 +738,6 @@ class CommandRegistrar:
raise ValueError(
f"Unsupported format: {agent_config['format']}"
)
if agent_config["extension"] != "/SKILL.md" and _integration is not None:
alias_output = _integration.post_process_command_content(alias_output)
else:
# For other agents, reuse the primary output
alias_output = output
@@ -793,7 +750,6 @@ class CommandRegistrar:
source_id,
cmd_file,
project_root,
extension_id=extension_id,
)
alias_file = (
@@ -925,7 +881,6 @@ class CommandRegistrar:
context_note: str = None,
link_outputs: bool = False,
create_missing_active_skills_dir: bool = False,
extension_id: Optional[str] = None,
) -> Dict[str, List[str]]:
"""Register commands for all detected agents in the project.
@@ -942,7 +897,6 @@ class CommandRegistrar:
Recovery requires active skills mode (or Kimi's existing native
skills directory) and is skipped when safe resolution or
creation fails.
extension_id: Extension id when rendering extension-owned commands.
Returns:
Dictionary mapping agent names to list of registered commands
@@ -1045,7 +999,6 @@ class CommandRegistrar:
context_note=context_note,
_resolved_dir=agent_dir,
link_outputs=link_outputs,
extension_id=extension_id,
)
if registered:
results[agent_name] = registered
@@ -1070,7 +1023,6 @@ class CommandRegistrar:
project_root: Path,
context_note: Optional[str] = None,
link_outputs: bool = False,
extension_id: Optional[str] = None,
) -> Dict[str, List[str]]:
"""Register commands for all non-skill agents in the project.
@@ -1086,7 +1038,6 @@ class CommandRegistrar:
context_note: Custom context comment for markdown output
link_outputs: If True, create dev-mode symlinks for rendered
command files when supported by the OS.
extension_id: Extension id when rendering extension-owned commands.
Returns:
Dictionary mapping agent names to list of registered commands
@@ -1115,7 +1066,6 @@ class CommandRegistrar:
context_note=context_note,
_resolved_dir=agent_dir,
link_outputs=link_outputs,
extension_id=extension_id,
)
if registered:
results[agent_name] = registered

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

@@ -95,11 +95,7 @@ def _is_local_path(url: str) -> bool:
"""True when *url* denotes a local filesystem path rather than a URL."""
if _WINDOWS_DRIVE_RE.match(url):
return True
try:
scheme = urlparse(url).scheme.lower()
except ValueError:
# Malformed URLs (e.g. an unclosed IPv6 bracket) are not local paths.
return False
scheme = urlparse(url).scheme.lower()
return scheme not in _REMOTE_SCHEMES
@@ -141,10 +137,7 @@ def add_source(
url = url.strip()
if not url:
raise BundlerError("A catalog url is required.")
try:
parsed = urlparse(url)
except ValueError as exc:
raise BundlerError(f"Invalid catalog url: '{url}'.") from exc
parsed = urlparse(url)
if not (parsed.scheme or parsed.path):
raise BundlerError(f"Invalid catalog url: '{url}'.")
# Reject unsupported URL schemes (e.g. ssh://, ftp://) up front so they are
@@ -155,20 +148,6 @@ def add_source(
f"Unsupported catalog url scheme '{parsed.scheme}://' in '{url}'. "
"Use http(s)://, file://, builtin://, or a local path."
)
if parsed.scheme.lower() in {"http", "https"}:
# Mirror specify_cli.catalogs._validate_catalog_url (#3209/#3210):
# HTTPS only (HTTP just for localhost), and check hostname, not
# netloc — netloc is truthy for host-less URLs like "https://:8080"
# or "https://user@". Validating here keeps junk out of
# bundle-catalogs.yml instead of failing later at fetch time.
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme.lower() != "https" and not is_localhost:
raise BundlerError(
f"Catalog url must use HTTPS (got {parsed.scheme}://). "
"HTTP is only allowed for localhost."
)
if not parsed.hostname:
raise BundlerError(f"Catalog url must be a valid URL with a host: {url}")
url = _canonicalize_url(url)
install_policy = InstallPolicy.parse(policy)

View File

@@ -75,11 +75,7 @@ def _validate_remote_url(source_id: str, url: str) -> None:
f"Catalog '{source_id}' URL must use HTTPS (got {parsed.scheme}://). "
"HTTP is only allowed for localhost."
)
# Check hostname, not netloc: netloc is truthy for host-less URLs like
# "https://:8080" or "https://user@...", so requiring netloc would let
# those through even though they carry no host. hostname is None in those
# cases. Mirrors the fix in ``specify_cli.catalogs`` (#3210).
if not parsed.hostname:
if not parsed.netloc:
raise BundlerError(
f"Catalog '{source_id}' URL must be a valid URL with a host: {url}"
)

View File

@@ -88,25 +88,17 @@ class CatalogStack:
Results are sorted by bundle id for deterministic output.
"""
needle = query.strip().lower()
# Resolve each id to its highest-precedence entry FIRST, then filter by
# the query. Claiming an id only when it matches would let a lower-
# precedence entry with the same id surface when the highest-precedence
# one doesn't match the query — but that shadowed entry is not what
# `resolve()`/install would use, so search would advertise a bundle
# (name, version, author) the user can never actually get.
resolved: dict[str, ResolvedBundle] = {}
seen: dict[str, ResolvedBundle] = {}
for source in self._sources:
for bundle_id, entry in self._entries_for(source).items():
if bundle_id in resolved:
if bundle_id in seen:
continue
resolved[bundle_id] = ResolvedBundle(
if needle and not _matches(entry, needle):
continue
seen[bundle_id] = ResolvedBundle(
entry=entry.with_provenance(source), source=source
)
return [
resolved[k]
for k in sorted(resolved)
if not needle or _matches(resolved[k].entry, needle)
]
return [seen[k] for k in sorted(seen)]
def _matches(entry: CatalogEntry, needle: str) -> bool:

View File

@@ -130,28 +130,6 @@ def install_bundle(
done.append(component)
result.installed.append(component)
contributed.append(component)
# On update (refresh), uninstall components this bundle used to own
# that the new version no longer ships. Otherwise they are dropped
# from the record below (contributed only holds plan.components) yet
# left on disk — permanently orphaned, since no bundle record can
# ever remove them. A stale component still owned by another bundle
# is kept installed and simply de-attributed here (it stays in that
# bundle's record). Mirrors remove_bundle's refcount logic.
if refresh and existing is not None:
planned = {(c.kind, c.id) for c in plan.components}
still_needed = components_still_needed(
records, exclude_bundle_id=plan.bundle_id
)
for component in existing.contributed_components:
key = (component.kind, component.id)
if key in planned:
continue
if key in still_needed:
continue
if installer.is_installed(project_root, component):
installer.remove(project_root, component)
result.uninstalled.append(component)
except BundlerError:
_rollback(project_root, installer, done)
raise

View File

@@ -1075,11 +1075,9 @@ class ExtensionManager:
pass # best-effort cleanup
continue
frontmatter, body = registrar.parse_frontmatter(content)
frontmatter = registrar._adjust_script_paths(
frontmatter, extension_id=manifest.id
)
frontmatter = registrar._adjust_script_paths(frontmatter)
body = registrar.resolve_skill_placeholders(
selected_ai, frontmatter, body, self.project_root, extension_id=manifest.id
selected_ai, frontmatter, body, self.project_root
)
original_desc = frontmatter.get("description", "")
@@ -1960,7 +1958,6 @@ class CommandRegistrar:
project_root,
context_note=context_note,
link_outputs=link_outputs,
extension_id=manifest.id,
)
def register_commands_for_all_agents(
@@ -1981,7 +1978,6 @@ class CommandRegistrar:
context_note=context_note,
link_outputs=link_outputs,
create_missing_active_skills_dir=create_missing_active_skills_dir,
extension_id=manifest.id,
)
def unregister_commands(
@@ -2692,12 +2688,7 @@ class ConfigManager:
return {}
try:
data = yaml.safe_load(file_path.read_text(encoding="utf-8"))
# Coerce a non-mapping root (list/scalar, or None for an empty
# file) to {} so callers that iterate/merge the result — e.g.
# _merge_configs' .items() — never crash. Mirrors the same
# non-dict-root guard in get_project_config().
return data if isinstance(data, dict) else {}
return yaml.safe_load(file_path.read_text(encoding="utf-8")) or {}
except (yaml.YAMLError, OSError, UnicodeError):
return {}

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

@@ -89,11 +89,7 @@ class AgyIntegration(SkillsIntegration):
output_json: bool = True,
) -> list[str] | None:
# agy does not support --model or JSON output; both params are ignored
args = [self._resolve_executable(), "--print", prompt]
# Honor SPECKIT_INTEGRATION_AGY_EXTRA_ARGS (operator-supplied flags),
# appended after the positional prompt like the devin integration.
self._apply_extra_args_env_var(args)
return args
return [self._resolve_executable(), "--print", prompt]
def setup(
self,

View File

@@ -25,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
@@ -126,19 +123,6 @@ class IntegrationBase(ABC):
integration that sets this flag.
"""
def post_process_command_content(self, content: str) -> str:
"""Transform command content after format rendering.
Called by ``register_commands()`` for non-skills format types
(Markdown, TOML, YAML) after the command has been rendered into
its target format and before writing to disk. Skills-format
agents use ``post_process_skill_content()`` instead.
Subclasses may override to inject agent-specific content.
The default implementation returns *content* unchanged.
"""
return content
# -- Public API -------------------------------------------------------
@classmethod
@@ -956,12 +940,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.
@@ -971,12 +949,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}"'
@@ -989,7 +961,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:
@@ -1196,18 +1178,12 @@ class YamlIntegration(IntegrationBase):
default_flow_style=False,
).strip()
# Indent the body for YAML block scalar. Use an explicit indentation
# indicator ("|2") rather than a bare "|": YAML infers a plain block
# scalar's indentation from its first non-empty line, so a body whose
# first line is itself indented (e.g. a markdown code block or a nested
# list item) would make the parser expect that deeper indent for the
# whole block and reject the later, less-indented lines. Pinning the
# indent to 2 keeps the recipe parseable whatever the body looks like.
# Indent the body for YAML block scalar
indented = "\n".join(f" {line}" for line in body.split("\n"))
lines = [
header_yaml,
"prompt: |2",
"prompt: |",
indented,
"",
f"# Source: {source_id}",

View File

@@ -75,15 +75,7 @@ class CursorAgentIntegration(SkillsIntegration):
either drops tool calls or exits non-zero on the first approval
prompt.
"""
args = [
self._resolve_executable(),
"-p",
"--trust",
"--approve-mcps",
"--force",
prompt,
]
self._apply_extra_args_env_var(args)
args = [self.key, "-p", "--trust", "--approve-mcps", "--force", prompt]
if model:
args.extend(["--model", model])
if output_json:

View File

@@ -253,11 +253,6 @@ class HermesIntegration(SkillsIntegration):
"""
args = [self._resolve_executable(), "chat", "-Q"]
# Operator-supplied SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS go here —
# after the base command but before Spec Kit's canonical -m/--json/-s/-q
# flags — so they can't displace or clobber them (mirrors opencode).
self._apply_extra_args_env_var(args)
if model:
args.extend(["-m", model])
if output_json:

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

@@ -50,17 +50,7 @@ workflow_step_catalog_app = typer.Typer(
workflow_step_app.add_typer(workflow_step_catalog_app, name="catalog")
def _error_console(json_output: bool):
"""Console for error text: stderr under ``--json`` so the JSON stdout
stream stays parseable, the normal console otherwise. Mirrors the
stderr-only error routing already used by ``specify bundle``.
"""
return err_console if json_output else console
def _parse_input_values(
input_values: list[str] | None, *, json_output: bool = False
) -> dict[str, Any]:
def _parse_input_values(input_values: list[str] | None) -> dict[str, Any]:
"""Parse repeated ``key=value`` CLI inputs into a dict.
Shared by ``workflow run`` and ``workflow resume``. Exits with an error
@@ -69,9 +59,7 @@ def _parse_input_values(
inputs: dict[str, Any] = {}
for kv in input_values or []:
if "=" not in kv:
_error_console(json_output).print(
f"[red]Error:[/red] Invalid input format: {kv!r} (expected key=value)"
)
console.print(f"[red]Error:[/red] Invalid input format: {kv!r} (expected key=value)")
raise typer.Exit(1)
key, _, value = kv.partition("=")
inputs[key.strip()] = value.strip()
@@ -347,26 +335,25 @@ def workflow_run(
if not json_output:
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
err = _error_console(json_output)
try:
definition = engine.load_workflow(source_path if is_file_source else source)
except FileNotFoundError:
err.print(f"[red]Error:[/red] Workflow not found: {source}")
console.print(f"[red]Error:[/red] Workflow not found: {source}")
raise typer.Exit(1)
except ValueError as exc:
err.print(f"[red]Error:[/red] Invalid workflow: {exc}")
console.print(f"[red]Error:[/red] Invalid workflow: {exc}")
raise typer.Exit(1)
# Validate
errors = engine.validate(definition)
if errors:
err.print("[red]Workflow validation failed:[/red]")
for verr in errors:
err.print(f"{verr}")
console.print("[red]Workflow validation failed:[/red]")
for err in errors:
console.print(f"{err}")
raise typer.Exit(1)
# Parse inputs
inputs = _parse_input_values(input_values, json_output=json_output)
inputs = _parse_input_values(input_values)
if not json_output:
console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})")
@@ -376,10 +363,10 @@ def workflow_run(
with _stdout_to_stderr_when(json_output):
state = engine.execute(definition, inputs)
except ValueError as exc:
err.print(f"[red]Error:[/red] {exc}")
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
except Exception as exc:
err.print(f"[red]Workflow failed:[/red] {exc}")
console.print(f"[red]Workflow failed:[/red] {exc}")
raise typer.Exit(1)
if json_output:
@@ -424,20 +411,19 @@ def workflow_resume(
if not json_output:
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
inputs = _parse_input_values(input_values, json_output=json_output)
err = _error_console(json_output)
inputs = _parse_input_values(input_values)
try:
with _stdout_to_stderr_when(json_output):
state = engine.resume(run_id, inputs or None)
except FileNotFoundError:
err.print(f"[red]Error:[/red] Run not found: {run_id}")
console.print(f"[red]Error:[/red] Run not found: {run_id}")
raise typer.Exit(1)
except ValueError as exc:
err.print(f"[red]Error:[/red] {exc}")
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
except Exception as exc:
err.print(f"[red]Resume failed:[/red] {exc}")
console.print(f"[red]Resume failed:[/red] {exc}")
raise typer.Exit(1)
if json_output:
@@ -631,11 +617,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

@@ -183,65 +183,6 @@ def _is_single_expression(stripped: str) -> bool:
return True
def _interpolate_expressions(template: str, namespace: dict[str, Any]) -> str:
"""Substitute every top-level ``{{ ... }}`` block in *template*, quote-aware.
Walks the template and, for each block, finds the closing ``}}`` that lies
outside string literals -- the same quote-scanning used by
``_is_single_expression``. This keeps a literal ``}}`` inside a string
argument (e.g. ``| default('}}')``) from prematurely closing a block.
``_EXPR_PATTERN.sub`` cannot do this: its non-greedy body stops at the first
``}}`` regardless of quoting, so in a multi-expression template any block
whose argument contains a literal ``}}`` is captured truncated and mis-parsed
(raising ``ValueError`` from the filter parser). #3208/#3228 fixed exactly
this for the single-expression fast path but left the interpolation path on
the old regex.
"""
out: list[str] = []
i = 0
n = len(template)
while i < n:
start = template.find("{{", i)
if start == -1:
out.append(template[i:])
break
out.append(template[i:start])
# Scan for the block-closing ``}}`` that is outside any string literal.
j = start + 2
quote: str | None = None
close = -1
while j < n:
ch = template[j]
if quote is not None:
if ch == quote:
quote = None
elif ch in ("'", '"'):
quote = ch
elif ch == "}" and j + 1 < n and template[j + 1] == "}":
close = j
break
j += 1
if close == -1:
# No quote-aware close. Two sub-cases, both kept identical to the old
# regex so a malformed template is never silently hidden:
# * a raw ``}}`` still exists in the tail (e.g. an unbalanced quote
# in a filter arg swallowed the real delimiter) -- fall back to
# that first raw ``}}`` and evaluate, letting the parser surface
# a ValueError just as ``_EXPR_PATTERN.sub`` would have.
# * no ``}}`` at all -- a genuinely unterminated ``{{``; leave the
# tail verbatim, again matching the regex (which cannot match).
raw_close = template.find("}}", start + 2)
if raw_close == -1:
out.append(template[start:])
break
close = raw_close
val = _evaluate_simple_expression(template[start + 2:close].strip(), namespace)
out.append(str(val) if val is not None else "")
i = close + 2
return "".join(out)
def _split_top_level_commas(text: str) -> list[str]:
"""Split *text* on commas that are not inside quotes or nested brackets.
@@ -467,34 +408,15 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
return _resolve_dot_path(namespace, expr)
def _coerce_number(value: Any) -> Any:
"""Return *value* as int/float if it is a numeric string, else unchanged."""
if isinstance(value, str):
try:
return float(value) if "." in value else int(value)
except ValueError:
return value
return value
def _safe_compare(left: Any, right: Any, op: str) -> bool:
"""Compare two values for ordering, coercing numeric strings when possible.
Numeric coercion is applied only when *both* operands look numeric, so a
pair like ``"10"`` and ``"9"`` compares as numbers (10 > 9). When either
side is a non-numeric string, both fall back to their original values and
are compared directly -- so ordinary strings (dates, semver-ish tags,
names) compare lexicographically the way Python does, instead of every
such comparison silently returning ``False`` after a failed int()/float()
coercion. A genuinely incomparable pair (e.g. number vs non-numeric string)
raises ``TypeError`` and yields ``False``.
"""
cl, cr = _coerce_number(left), _coerce_number(right)
# Only use the coerced numbers when both converted; otherwise a numeric
# string paired with a plain string would become an int-vs-str mismatch
# (always False) rather than a lexicographic string comparison.
if isinstance(cl, (int, float)) and isinstance(cr, (int, float)):
left, right = cl, cr
"""Safely compare two values, coercing types when possible."""
try:
if isinstance(left, str):
left = float(left) if "." in left else int(left)
if isinstance(right, str):
right = float(right) if "." in right else int(right)
except (ValueError, TypeError):
return False
try:
if op == ">":
return left > right # type: ignore[operator]
@@ -550,11 +472,12 @@ def evaluate_expression(template: str, context: Any) -> Any:
if _is_single_expression(stripped):
return _evaluate_simple_expression(stripped[2:-2].strip(), namespace)
# Multi-expression: interpolate each block inline. Uses a quote-aware scan
# (not ``_EXPR_PATTERN.sub``) so a literal ``}}`` inside a string argument
# in any block does not close that block early -- matching the handling the
# single-expression path above already got in #3208/#3228.
return _interpolate_expressions(template, namespace)
# Multi-expression: string interpolation
def _replacer(m: re.Match[str]) -> str:
val = _evaluate_simple_expression(m.group(1).strip(), namespace)
return str(val) if val is not None else ""
return _EXPR_PATTERN.sub(_replacer, template)
def evaluate_condition(condition: str, context: Any) -> bool:

View File

@@ -58,13 +58,4 @@ class FanInStep(StepBase):
f"Fan-in step {config.get('id', '?')!r}: "
f"'wait_for' must be a non-empty list of step IDs."
)
output = config.get("output")
if output is not None and not isinstance(output, dict):
# execute() silently coerces a non-mapping output to {}, so the
# author's declared aggregation keys would vanish with no error.
# Reject at validation, mirroring the command-step (#3262) fix.
errors.append(
f"Fan-in step {config.get('id', '?')!r}: 'output' must be a "
f"mapping of key -> expression, got {type(output).__name__}."
)
return errors

View File

@@ -73,14 +73,7 @@ class GateStep(StepBase):
choice = self._prompt(self._compose_prompt(message, show_file), options)
output["choice"] = choice
# Match rejection case-insensitively. ``_prompt`` echoes the option's
# original casing, and ``validate`` accepts a reject option
# case-insensitively (``o.lower() in {"reject", "abort"}``), so a gate
# authored as ``options: [Approve, Reject]`` passes validation. Comparing
# ``choice`` case-sensitively here would then treat a ``Reject`` pick as
# approval and silently skip the abort — the reject path must agree with
# the check that let the option through.
if choice.lower() in ("reject", "abort"):
if choice in ("reject", "abort"):
if on_reject == "abort":
output["aborted"] = True
return StepResult(

View File

@@ -90,16 +90,6 @@ class ShellStep(StepBase):
errors.append(
f"Shell step {config.get('id', '?')!r} is missing 'run' field."
)
elif not isinstance(config["run"], str):
# execute() str()-coerces run and invokes it under shell=True, so a
# null or list 'run' would run the Python repr ('None', "['echo']")
# as a command. Reject non-strings at validation, mirroring the
# command-step input/options and gate options type checks. An
# expression like "{{ ... }}" is still a str, so it stays valid.
errors.append(
f"Shell step {config.get('id', '?')!r}: 'run' must be a string, "
f"got {type(config['run']).__name__}."
)
output_format = config.get("output_format")
if output_format is not None and output_format != "json":
errors.append(

View File

@@ -122,10 +122,10 @@ def _run_bash(script_name: str, cwd: Path, *args: str, env_extra: dict | None =
)
def _run_pwsh(script_name: str, cwd: Path, *args: str, env_extra: dict | None = None) -> subprocess.CompletedProcess:
def _run_pwsh(script_name: str, cwd: Path, *args: str) -> subprocess.CompletedProcess:
"""Run an extension PowerShell script."""
script = cwd / ".specify" / "extensions" / "git" / "scripts" / "powershell" / script_name
env = {**os.environ, **_GIT_ENV, **(env_extra or {})}
env = {**os.environ, **_GIT_ENV}
return subprocess.run(
["pwsh", "-NoProfile", "-File", str(script), *args],
cwd=cwd,
@@ -320,15 +320,6 @@ class TestCreateFeatureBash:
assert rt.returncode == 0, rt.stderr
assert "HAS_GIT" not in rt.stdout
def test_help_documents_branch_prefix(self, tmp_path: Path):
"""--help documents both template config knobs."""
project = _setup_project(tmp_path)
result = _run_bash("create-new-feature-branch.sh", project, "--help")
assert result.returncode == 0
assert "branch_template" in result.stdout
assert "branch_prefix" in result.stdout
def test_branch_name_short_word_case_sensitivity(self, tmp_path: Path):
"""A short word is dropped from the derived branch name unless it appears
as an acronym in UPPERCASE in the description (case-sensitive, must match the
@@ -372,183 +363,6 @@ class TestCreateFeatureBash:
data = json.loads(result.stdout)
assert data["FEATURE_NUM"] == "003"
def test_branch_template_adds_author_and_app_namespace(self, tmp_path: Path):
"""branch_template namespaces generated branch names for monorepos."""
project = _setup_project(tmp_path / "app-a")
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "--short-name", "guided-tour", "Add guided tour",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/app-a/001-guided-tour"
assert data["FEATURE_NUM"] == "001"
def test_branch_prefix_shorthand_adds_namespace(self, tmp_path: Path):
"""branch_prefix expands to a namespace before the default branch shape."""
project = _setup_project(tmp_path / "app-a")
_write_config(project, 'branch_prefix: "features/{app}"\n')
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "--short-name", "guided-tour", "Add guided tour",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "features/app-a/001-guided-tour"
assert data["FEATURE_NUM"] == "001"
def test_branch_template_scopes_number_after_numeric_app_namespace(self, tmp_path: Path):
"""Numeric-looking namespace segments must not be parsed as feature numbers."""
project = _setup_project(tmp_path / "2026-app")
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
subprocess.run(["git", "branch", "jdoe/2026-app/007-existing"], cwd=project, check=True)
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "--dry-run", "--short-name", "next", "Next feature",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/2026-app/008-next"
assert data["FEATURE_NUM"] == "008"
def test_branch_template_ignores_malformed_timestamp_branches_when_numbering(self, tmp_path: Path):
"""Malformed timestamp-looking branches must not inflate sequential numbering."""
project = _setup_project(tmp_path / "app-a")
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
subprocess.run(["git", "branch", "jdoe/app-a/007-existing"], cwd=project, check=True)
subprocess.run(["git", "branch", "jdoe/app-a/2026031-143022-invalid"], cwd=project, check=True)
subprocess.run(["git", "branch", "jdoe/app-a/20260319-143022"], cwd=project, check=True)
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "--dry-run", "--short-name", "next", "Next feature",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/app-a/008-next"
assert data["FEATURE_NUM"] == "008"
def test_branch_template_scopes_existing_branch_numbers(self, tmp_path: Path):
"""Templated branch numbering ignores branches outside the current namespace."""
project = _setup_project(tmp_path / "app-a")
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
subprocess.run(["git", "branch", "jdoe/app-a/007-existing"], cwd=project, check=True)
subprocess.run(["git", "branch", "jdoe/app-b/010-other-app"], cwd=project, check=True)
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "--dry-run", "--short-name", "next", "Next feature",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/app-a/008-next"
assert data["FEATURE_NUM"] == "008"
def test_branch_template_requires_number_token(self, tmp_path: Path):
"""Configured templates must include {number} so generated branches validate."""
project = _setup_project(tmp_path / "app-a")
_write_config(project, 'branch_template: "{author}/{app}/{slug}"\n')
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "--dry-run", "--short-name", "guided-tour", "Add guided tour",
)
assert result.returncode != 0
assert "branch_template" in result.stderr
assert "{number}" in result.stderr
def test_branch_template_requires_feature_segment_to_start_with_number(self, tmp_path: Path):
"""Templates must render a final path segment that validation accepts."""
project = _setup_project(tmp_path / "app-a")
_write_config(project, 'branch_template: "{author}/{app}/feature-{number}"\n')
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "--dry-run", "--short-name", "guided-tour", "Add guided tour",
)
assert result.returncode != 0
assert "branch_template" in result.stderr
assert "{number}-" in result.stderr
def test_branch_template_rejects_slug_before_number(self, tmp_path: Path):
"""{slug} before {number} would make branch-number scanning slug-specific."""
project = _setup_project(tmp_path / "app-a")
_write_config(project, 'branch_template: "{author}/{slug}/{number}-{slug}"\n')
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "--dry-run", "--short-name", "guided-tour", "Add guided tour",
)
assert result.returncode != 0
assert "branch_template" in result.stderr
assert "{slug}" in result.stderr
assert "{number}" in result.stderr
def test_git_branch_name_override_extracts_number_after_namespace(self, tmp_path: Path):
"""GIT_BRANCH_NAME extracts FEATURE_NUM from a namespaced branch."""
project = _setup_project(tmp_path / "app-a")
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "Ignored description",
env_extra={"GIT_BRANCH_NAME": "jdoe/app-a/042-custom-branch"},
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/app-a/042-custom-branch"
assert data["FEATURE_NUM"] == "042"
def test_git_branch_name_override_ignores_numeric_namespace_segments(self, tmp_path: Path):
"""GIT_BRANCH_NAME uses the feature segment, not numeric namespace segments."""
project = _setup_project(tmp_path / "2026-app")
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "Ignored description",
env_extra={"GIT_BRANCH_NAME": "jdoe/2026-app/042-custom-branch"},
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/2026-app/042-custom-branch"
assert data["FEATURE_NUM"] == "042"
def test_git_branch_name_override_without_feature_marker_preserves_full_name(self, tmp_path: Path):
"""GIT_BRANCH_NAME without a feature marker keeps the historical FEATURE_NUM."""
project = _setup_project(tmp_path / "app-a")
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "Ignored description",
env_extra={"GIT_BRANCH_NAME": "jdoe/app-a/custom-branch"},
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/app-a/custom-branch"
assert data["FEATURE_NUM"] == "jdoe/app-a/custom-branch"
def test_truncation_warning_reports_utf8_bytes(self):
"""Bash truncation warnings should use the same byte counter as enforcement."""
source = (EXT_BASH / "create-new-feature-branch.sh").read_text(encoding="utf-8")
assert '_byte_length "$ORIGINAL_BRANCH_NAME"' in source
assert '_byte_length "$BRANCH_NAME"' in source
def test_dry_run_counts_branches_checked_out_in_worktrees(self, tmp_path: Path):
"""Branches checked out in sibling worktrees still reserve their prefix."""
project = _setup_project(tmp_path / "project")
@@ -670,15 +484,6 @@ class TestCreateFeaturePowerShell:
assert rt.returncode == 0, rt.stderr
assert "HAS_GIT" not in rt.stdout
def test_help_documents_branch_prefix(self, tmp_path: Path):
"""-Help documents both template config knobs."""
project = _setup_project(tmp_path)
result = _run_pwsh("create-new-feature-branch.ps1", project, "-Help")
assert result.returncode == 0
assert "branch_template" in result.stdout
assert "branch_prefix" in result.stdout
def test_branch_name_short_word_case_sensitivity(self, tmp_path: Path):
"""PowerShell must match the bash twin: a short word is dropped unless it
appears as an acronym in UPPERCASE (case-sensitive -cmatch, not -match)."""
@@ -720,176 +525,6 @@ class TestCreateFeaturePowerShell:
data = json.loads(result.stdout)
assert re.match(r"^\d{8}-\d{6}-feat$", data["BRANCH_NAME"])
def test_branch_template_adds_author_and_app_namespace(self, tmp_path: Path):
"""PowerShell supports branch_template namespaces."""
project = _setup_project(tmp_path / "app-a")
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "-ShortName", "guided-tour", "Add guided tour",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/app-a/001-guided-tour"
assert data["FEATURE_NUM"] == "001"
def test_branch_prefix_shorthand_adds_namespace(self, tmp_path: Path):
"""PowerShell supports branch_prefix shorthand namespaces."""
project = _setup_project(tmp_path / "app-a")
_write_config(project, 'branch_prefix: "features/{app}"\n')
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "-ShortName", "guided-tour", "Add guided tour",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "features/app-a/001-guided-tour"
assert data["FEATURE_NUM"] == "001"
def test_branch_template_scopes_number_after_numeric_app_namespace(self, tmp_path: Path):
"""PowerShell ignores numeric-looking namespace segments when numbering."""
project = _setup_project(tmp_path / "2026-app")
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
subprocess.run(["git", "branch", "jdoe/2026-app/007-existing"], cwd=project, check=True)
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "-DryRun", "-ShortName", "next", "Next feature",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/2026-app/008-next"
assert data["FEATURE_NUM"] == "008"
def test_branch_template_ignores_malformed_timestamp_branches_when_numbering(self, tmp_path: Path):
"""PowerShell skips malformed timestamp-looking refs during sequential numbering."""
project = _setup_project(tmp_path / "app-a")
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
subprocess.run(["git", "branch", "jdoe/app-a/007-existing"], cwd=project, check=True)
subprocess.run(["git", "branch", "jdoe/app-a/2026031-143022-invalid"], cwd=project, check=True)
subprocess.run(["git", "branch", "jdoe/app-a/20260319-143022"], cwd=project, check=True)
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "-DryRun", "-ShortName", "next", "Next feature",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/app-a/008-next"
assert data["FEATURE_NUM"] == "008"
def test_branch_template_scopes_existing_branch_numbers(self, tmp_path: Path):
"""PowerShell templated numbering ignores branches outside the namespace."""
project = _setup_project(tmp_path / "app-a")
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
subprocess.run(["git", "branch", "jdoe/app-a/007-existing"], cwd=project, check=True)
subprocess.run(["git", "branch", "jdoe/app-b/010-other-app"], cwd=project, check=True)
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "-DryRun", "-ShortName", "next", "Next feature",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/app-a/008-next"
assert data["FEATURE_NUM"] == "008"
def test_branch_template_requires_number_token(self, tmp_path: Path):
"""PowerShell rejects templates without {number}."""
project = _setup_project(tmp_path / "app-a")
_write_config(project, 'branch_template: "{author}/{app}/{slug}"\n')
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "-DryRun", "-ShortName", "guided-tour", "Add guided tour",
)
assert result.returncode != 0
assert "branch_template" in result.stderr
assert "{number}" in result.stderr
def test_branch_template_requires_feature_segment_to_start_with_number(self, tmp_path: Path):
"""PowerShell rejects templates whose final segment cannot validate."""
project = _setup_project(tmp_path / "app-a")
_write_config(project, 'branch_template: "{author}/{app}/feature-{number}"\n')
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "-DryRun", "-ShortName", "guided-tour", "Add guided tour",
)
assert result.returncode != 0
assert "branch_template" in result.stderr
assert "{number}-" in result.stderr
def test_branch_template_rejects_slug_before_number(self, tmp_path: Path):
"""PowerShell rejects templates where {slug} scopes number scanning."""
project = _setup_project(tmp_path / "app-a")
_write_config(project, 'branch_template: "{author}/{slug}/{number}-{slug}"\n')
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "-DryRun", "-ShortName", "guided-tour", "Add guided tour",
)
assert result.returncode != 0
assert "branch_template" in result.stderr
assert "{slug}" in result.stderr
assert "{number}" in result.stderr
def test_git_branch_name_override_extracts_number_after_namespace(self, tmp_path: Path):
"""PowerShell GIT_BRANCH_NAME extracts FEATURE_NUM from a namespaced branch."""
project = _setup_project(tmp_path / "app-a")
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "Ignored description",
env_extra={"GIT_BRANCH_NAME": "jdoe/app-a/042-custom-branch"},
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/app-a/042-custom-branch"
assert data["FEATURE_NUM"] == "042"
def test_git_branch_name_override_ignores_numeric_namespace_segments(self, tmp_path: Path):
"""PowerShell GIT_BRANCH_NAME ignores numeric namespace segments."""
project = _setup_project(tmp_path / "2026-app")
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "Ignored description",
env_extra={"GIT_BRANCH_NAME": "jdoe/2026-app/042-custom-branch"},
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/2026-app/042-custom-branch"
assert data["FEATURE_NUM"] == "042"
def test_git_branch_name_override_without_feature_marker_preserves_full_name(self, tmp_path: Path):
"""PowerShell keeps the full override name when no feature marker exists."""
project = _setup_project(tmp_path / "app-a")
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "Ignored description",
env_extra={"GIT_BRANCH_NAME": "jdoe/app-a/custom-branch"},
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/app-a/custom-branch"
assert data["FEATURE_NUM"] == "jdoe/app-a/custom-branch"
def test_no_git_graceful_degradation(self, tmp_path: Path):
"""create-new-feature-branch.ps1 works without git."""
project = _setup_project(tmp_path, git=False)
@@ -1376,31 +1011,13 @@ class TestGitCommonBash:
)
assert result.returncode == 0
def test_check_feature_branch_accepts_nested_prefix(self, tmp_path: Path):
def test_check_feature_branch_rejects_nested_prefix(self, tmp_path: Path):
project = _setup_project(tmp_path)
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
result = subprocess.run(
["bash", "-c", f'source "{script}" && check_feature_branch "feat/fix/001-x" "true"'],
capture_output=True, text=True,
)
assert result.returncode == 0
def test_check_feature_branch_rejects_nested_prefix_without_number(self, tmp_path: Path):
project = _setup_project(tmp_path)
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
result = subprocess.run(
["bash", "-c", f'source "{script}" && check_feature_branch "feat/fix/no-number" "true"'],
capture_output=True, text=True,
)
assert result.returncode != 0
def test_check_feature_branch_rejects_numeric_namespace_without_feature_number(self, tmp_path: Path):
project = _setup_project(tmp_path)
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
result = subprocess.run(
["bash", "-c", f'source "{script}" && check_feature_branch "jdoe/2026-app/no-number" "true"'],
capture_output=True, text=True,
)
assert result.returncode != 0
@@ -1420,33 +1037,3 @@ class TestGitCommonPowerShell:
text=True,
)
assert result.returncode == 0
def test_test_feature_branch_accepts_nested_prefix(self, tmp_path: Path):
project = _setup_project(tmp_path)
script = project / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1"
result = subprocess.run(
[
"pwsh",
"-NoProfile",
"-Command",
f'. "{script}"; if (Test-FeatureBranch -Branch "jdoe/app-a/001-x" -HasGit $true) {{ exit 0 }} else {{ exit 1 }}',
],
capture_output=True,
text=True,
)
assert result.returncode == 0
def test_test_feature_branch_rejects_numeric_namespace_without_feature_number(self, tmp_path: Path):
project = _setup_project(tmp_path)
script = project / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1"
result = subprocess.run(
[
"pwsh",
"-NoProfile",
"-Command",
f'. "{script}"; if (Test-FeatureBranch -Branch "jdoe/2026-app/no-number" -HasGit $true) {{ exit 0 }} else {{ exit 1 }}',
],
capture_output=True,
text=True,
)
assert result.returncode != 0

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

@@ -71,47 +71,6 @@ def test_search_dedupes_by_precedence_and_filters():
assert [r.entry.id for r in qa_only] == ["beta"]
def test_search_does_not_surface_a_shadowed_lower_precedence_entry():
"""Search must resolve each id at its highest-precedence source, then
filter — never fall through to a shadowed lower-precedence entry the query
happens to match.
If the query matched only the lower-precedence copy of an id, search used
to return that copy, even though `resolve()`/install always use the
higher-precedence one. That advertised a bundle (name/version/source) the
user could never actually get.
"""
sources = [_source("high", 1, "install-allowed"), _source("low", 2, "install-allowed")]
payloads = {
# Highest-precedence entry for 'shared' does NOT match "widget".
"high": catalog_payload({
"shared": catalog_entry_dict(
"shared", name="Alpha Tool", role="developer",
description="nothing relevant", version="2.0.0",
),
}),
# Lower-precedence entry for the same id DOES match "widget".
"low": catalog_payload({
"shared": catalog_entry_dict(
"shared", name="Searchable Widget", version="1.0.0",
),
}),
}
stack = _stack(sources, payloads)
# resolve() uses the high-precedence entry.
assert stack.resolve("shared").source.id == "high"
# A query that only the shadowed low-precedence entry matches returns
# nothing — search agrees with resolve().
assert stack.search("widget") == []
# And a query the high-precedence entry matches returns it (from 'high').
alpha = stack.search("alpha tool")
assert [r.entry.id for r in alpha] == ["shared"]
assert alpha[0].source.id == "high"
def test_unreachable_source_raises_named_error():
def fetcher(src):
raise RuntimeError("boom")

View File

@@ -220,68 +220,3 @@ def test_pre_existing_component_is_not_attributed_or_removed(tmp_path: Path):
remove_bundle(tmp_path, "demo-bundle", installer)
assert ("extensions", "ext-a") in installer.installed
def _bundle(manifest_id, ext_ids, *, version="1.0.0"):
data = valid_manifest_dict()
data["bundle"]["id"] = manifest_id
data["bundle"]["version"] = version
data["provides"] = {
"extensions": [{"id": e, "version": version} for e in ext_ids]
}
return BundleManifest.from_dict(data)
def test_update_uninstalls_components_dropped_by_new_version(tmp_path: Path):
"""`bundle update` must uninstall components the new version no longer
ships, instead of orphaning them (installed on disk, tracked by nothing)."""
make_project(tmp_path)
installer = FakeInstaller()
man_v1 = _bundle("demo", ["ext-a", "ext-b"])
install_bundle(tmp_path, _plan(man_v1), installer, manifest=man_v1)
assert ("extensions", "ext-b") in installer.installed
man_v2 = _bundle("demo", ["ext-a"], version="2.0.0")
result = install_bundle(
tmp_path, _plan(man_v2), installer, manifest=man_v2, refresh=True
)
# ext-b was dropped by v2 -> uninstalled and reported.
assert ("extensions", "ext-b") in installer.remove_calls
assert ("extensions", "ext-b") in {(c.kind, c.id) for c in result.uninstalled}
assert ("extensions", "ext-b") not in installer.installed
assert ("extensions", "ext-a") in installer.installed
# The saved record lists only ext-a.
rec = next(r for r in load_records(tmp_path) if r.bundle_id == "demo")
keys = {(c.kind, c.id) for c in rec.contributed_components}
assert ("extensions", "ext-a") in keys
assert ("extensions", "ext-b") not in keys
def test_update_keeps_component_still_needed_by_sibling_bundle(tmp_path: Path):
"""A dropped component still owned by another bundle stays installed."""
make_project(tmp_path)
installer = FakeInstaller()
man_sib = _bundle("sibling", ["ext-b"])
install_bundle(tmp_path, _plan(man_sib), installer, manifest=man_sib)
man_v1 = _bundle("demo", ["ext-a", "ext-b"])
install_bundle(tmp_path, _plan(man_v1), installer, manifest=man_v1)
man_v2 = _bundle("demo", ["ext-a"], version="2.0.0")
install_bundle(
tmp_path, _plan(man_v2), installer, manifest=man_v2, refresh=True
)
# ext-b is still needed by 'sibling' -> not removed, stays installed.
assert ("extensions", "ext-b") not in installer.remove_calls
assert ("extensions", "ext-b") in installer.installed
# But demo's record no longer attributes it.
rec = next(r for r in load_records(tmp_path) if r.bundle_id == "demo")
assert ("extensions", "ext-b") not in {
(c.kind, c.id) for c in rec.contributed_components
}

View File

@@ -1,24 +1,8 @@
"""Shared test helpers for integration tests."""
import pytest
from specify_cli.integrations.base import MarkdownIntegration
@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)
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("USERPROFILE", str(home))
monkeypatch.setenv("XDG_CACHE_HOME", str(home / ".cache"))
monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config"))
monkeypatch.setenv("XDG_DATA_HOME", str(home / ".local" / "share"))
class StubIntegration(MarkdownIntegration):
"""Minimal concrete integration for testing."""

View File

@@ -1,21 +0,0 @@
"""Regression tests for integration-test environment isolation."""
from __future__ import annotations
import os
from pathlib import Path
def test_integration_tests_use_tmp_home(tmp_path: Path) -> None:
home = tmp_path / "home"
assert Path(os.environ["HOME"]) == home
assert Path(os.environ["USERPROFILE"]) == home
assert Path(os.environ["XDG_CACHE_HOME"]) == home / ".cache"
assert Path(os.environ["XDG_CONFIG_HOME"]) == home / ".config"
assert Path(os.environ["XDG_DATA_HOME"]) == home / ".local" / "share"
assert home.is_dir()
assert (home / ".cache").is_dir()
assert (home / ".config").is_dir()
assert (home / ".local" / "share").is_dir()

View File

@@ -81,26 +81,6 @@ class TestAgyBuildExecArgs:
result = i.build_exec_args("my prompt", output_json=False)
assert result == ["agy", "--print", "my prompt"]
def test_build_exec_args_honors_extra_args(self, monkeypatch):
"""SPECKIT_INTEGRATION_AGY_EXTRA_ARGS must be appended after the prompt.
agy previously skipped _apply_extra_args_env_var entirely, so the
documented per-integration extra-args hook was silently ignored
(same class as the merged cursor-agent fix #3265).
"""
from specify_cli.integrations import get_integration
monkeypatch.setenv("SPECKIT_INTEGRATION_AGY_EXTRA_ARGS", "--verbose")
i = get_integration("agy")
assert i.build_exec_args("my prompt") == [
"agy", "--print", "my prompt", "--verbose",
]
def test_build_exec_args_honors_executable_override(self, monkeypatch):
from specify_cli.integrations import get_integration
monkeypatch.setenv("SPECKIT_INTEGRATION_AGY_EXECUTABLE", "/custom/agy")
i = get_integration("agy")
assert i.build_exec_args("my prompt")[0] == "/custom/agy"
class TestAgyHookCommandNote:
"""Verify dot-to-hyphen normalization note is injected into hook sections."""

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

@@ -184,23 +184,6 @@ class YamlIntegrationTests:
assert "scripts:" not in parsed["prompt"]
assert "---" not in parsed["prompt"]
def test_yaml_prompt_with_indented_first_line_stays_valid(self):
"""A body whose first line is indented must still parse.
A bare ``|`` block scalar infers its indentation from the first
non-empty line, so a body starting with an indented line (e.g. a
markdown code block or nested list item) made the parser expect that
deeper indent for the whole block and reject the later, shallower
lines. The explicit ``|2`` indicator pins the indent so it parses."""
body = " indented first line\nback to normal\n indented again"
rendered = YamlIntegration._render_yaml("Title", "Desc", body, "src")
yaml_lines = [
ln for ln in rendered.split("\n") if not ln.startswith("# Source:")
]
parsed = yaml.safe_load("\n".join(yaml_lines))
assert parsed["prompt"].rstrip("\n") == body
def test_plan_command_has_no_context_placeholder(self, tmp_path):
"""The generated plan command must not carry a context-file placeholder.

View File

@@ -125,55 +125,6 @@ class TestCursorAgentCliDispatch:
assert argv is not None
assert argv[0] == "cursor-agent"
def test_build_exec_args_honors_executable_override(self, monkeypatch):
"""``SPECKIT_INTEGRATION_CURSOR_AGENT_EXECUTABLE`` overrides argv[0].
Every other CLI-dispatch integration (codex, devin, ...) routes
argv[0] through ``_resolve_executable()`` so operators can pin a
binary path (issue #2596). cursor-agent hardcoded ``self.key`` and
silently ignored the documented override.
"""
monkeypatch.setenv(
"SPECKIT_INTEGRATION_CURSOR_AGENT_EXECUTABLE", "/custom/cursor"
)
i = get_integration("cursor-agent")
args = i.build_exec_args("/speckit-plan", output_json=False)
assert args[0] == "/custom/cursor"
# The mandatory headless flags must still be present.
for flag in ("-p", "--trust", "--approve-mcps", "--force"):
assert flag in args
def test_build_exec_args_honors_extra_args_override(self, monkeypatch):
"""``SPECKIT_INTEGRATION_CURSOR_AGENT_EXTRA_ARGS`` flags are injected
*before* Spec Kit's canonical ``--model`` / ``--output-format`` flags.
The ``_apply_extra_args_env_var()`` hook (issue #2595) was never
invoked by cursor-agent, so operator-supplied flags were dropped.
Insertion order is the real contract: extra args must land after the
mandatory headless flags but before ``--model`` / ``--output-format``,
so they cannot clobber, displace, or reorder Spec Kit's canonical
trailing flags. Exercise with both a model and JSON output so both
canonical flags are present to pin against.
"""
monkeypatch.setenv(
"SPECKIT_INTEGRATION_CURSOR_AGENT_EXTRA_ARGS", "--foo bar"
)
i = get_integration("cursor-agent")
args = i.build_exec_args(
"/speckit-plan", model="sonnet-4-thinking", output_json=True
)
assert "--foo" in args
assert "bar" in args
# "bar" is the value of "--foo": the tokens stay adjacent and in order.
assert args.index("bar") == args.index("--foo") + 1
# Extra args are inserted before the canonical flags, so they cannot
# clobber or reorder them (the behavioral contract this test guards).
assert args.index("--foo") < args.index("--model")
assert args.index("--foo") < args.index("--output-format")
# The canonical flags themselves remain intact and correctly paired.
assert args[args.index("--model") + 1] == "sonnet-4-thinking"
assert args[args.index("--output-format") + 1] == "json"
def test_build_command_invocation_uses_hyphenated_skill_name(self):
"""SkillsIntegration: /speckit-plan (not /speckit.plan)."""
i = get_integration("cursor-agent")

View File

@@ -353,38 +353,3 @@ class TestHermesInitFlow:
if "agent-context" not in d.name
]
assert local_skills == [], f"Local skills dir should be empty, got: {local_skills}"
class TestHermesBuildExecArgs:
"""CLI dispatch argv, including the operator extra-args env hook."""
def test_build_exec_args_default_shape(self):
i = get_integration("hermes")
assert i.build_exec_args("/speckit-plan hi", output_json=True) == [
"hermes", "chat", "-Q", "--json", "-s", "speckit-plan", "-q", "hi",
]
def test_build_exec_args_honors_extra_args(self, monkeypatch):
"""SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS is injected before the
canonical -m/--json/-s/-q flags (same env hook as codex/opencode/
devin; hermes previously skipped _apply_extra_args_env_var entirely).
"""
monkeypatch.setenv(
"SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS", "--temperature 0.2"
)
i = get_integration("hermes")
args = i.build_exec_args("/speckit-plan hi", output_json=True)
assert args == [
"hermes", "chat", "-Q", "--temperature", "0.2",
"--json", "-s", "speckit-plan", "-q", "hi",
]
# Injected before the canonical flags so it can't displace them.
assert args.index("--temperature") < args.index("--json")
assert args.index("--temperature") < args.index("-s")
def test_build_exec_args_honors_executable_override(self, monkeypatch):
monkeypatch.setenv(
"SPECKIT_INTEGRATION_HERMES_EXECUTABLE", "/custom/hermes"
)
i = get_integration("hermes")
assert i.build_exec_args("/speckit-plan hi")[0] == "/custom/hermes"

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

@@ -48,19 +48,6 @@ def _multi_install_safe_pairs() -> list[tuple[str, str]]:
]
def _multi_install_safe_orders() -> list[list[str]]:
safe_keys = _multi_install_safe_keys()
if len(safe_keys) < 2:
return [safe_keys]
return [safe_keys[index:] + safe_keys[:index] for index in range(len(safe_keys))]
def _multi_install_safe_order_id(ordered_keys: list[str]) -> str:
if not ordered_keys:
return "no-safe-integrations"
return f"init-{ordered_keys[0]}"
def _posix_path(value: str | None) -> str | None:
if not value:
return None
@@ -100,6 +87,16 @@ def _paths_overlap(first: str | None, second: str | None) -> bool:
return False
def _path_is_inside(path: str | None, directory: str | None) -> bool:
if not path or not directory:
return False
try:
PurePosixPath(path).relative_to(PurePosixPath(directory))
return True
except ValueError:
return False
class TestRegistry:
def test_registry_is_dict(self):
assert isinstance(INTEGRATION_REGISTRY, dict)
@@ -165,15 +162,6 @@ class TestRegistrarKeyAlignment:
class TestMultiInstallSafeContracts:
"""Declared safe integrations must stay isolated from each other."""
def test_safe_install_orders_rotate_each_integration_through_init(self):
safe_keys = _multi_install_safe_keys()
orders = _multi_install_safe_orders()
assert len(safe_keys) >= 2
assert [order[0] for order in orders] == safe_keys
assert len({tuple(order) for order in orders}) == len(safe_keys)
assert all(sorted(order) == safe_keys for order in orders)
@pytest.mark.parametrize("key", _multi_install_safe_keys())
def test_safe_integrations_have_static_isolated_paths(self, key):
assert _integration_root_dir(key), (
@@ -199,77 +187,62 @@ class TestMultiInstallSafeContracts:
f"{_integration_commands_dir(second)!r}"
)
@pytest.mark.parametrize(
"ordered_keys",
_multi_install_safe_orders(),
ids=_multi_install_safe_order_id,
)
@pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs())
def test_safe_integrations_have_disjoint_manifests(
self,
tmp_path,
ordered_keys,
first,
second,
):
# The pairwise disjointness contract is only meaningful with at least
# two safe integrations. Guard so a shrunken registry fails loudly here
# rather than passing vacuously (or tripping over ordered_keys[0] below).
assert len(ordered_keys) >= 2, (
f"expected at least two multi-install-safe integrations, got {ordered_keys}"
)
for initial, additional in ((first, second), (second, first)):
project_root = tmp_path / f"project-{initial}-{additional}"
project_root.mkdir()
runner = CliRunner()
project_root = tmp_path / "project"
project_root.mkdir()
runner = CliRunner()
original_cwd = os.getcwd()
try:
os.chdir(project_root)
init_result = runner.invoke(
app,
[
"init",
"--here",
"--integration",
initial,
"--script",
"sh",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
assert init_result.exit_code == 0, init_result.output
# Install every safe integration once into a single project, then assert
# pairwise manifest isolation. Each safe integration writes only to its
# own (disjoint) directories and always records what it writes, so a
# manifest's contents are independent of install order and of which other
# integrations are co-installed. The parametrized rotations keep the
# aggregate setup while placing each safe integration first once, so each
# one still exercises the `specify init --integration ...` path.
original_cwd = os.getcwd()
try:
os.chdir(project_root)
init_result = runner.invoke(
app,
[
"init",
"--here",
"--integration",
ordered_keys[0],
"--script",
"sh",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
assert init_result.exit_code == 0, init_result.output
for key in ordered_keys[1:]:
install_result = runner.invoke(
app,
["integration", "install", key, "--script", "sh"],
["integration", "install", additional, "--script", "sh"],
catch_exceptions=False,
)
assert install_result.exit_code == 0, install_result.output
finally:
os.chdir(original_cwd)
finally:
os.chdir(original_cwd)
integrations_dir = project_root / ".specify" / "integrations"
manifests = {}
for key in ordered_keys:
manifest = json.loads(
(integrations_dir / f"{key}.manifest.json").read_text(encoding="utf-8")
initial_manifest = json.loads(
(
project_root / ".specify" / "integrations" / f"{initial}.manifest.json"
).read_text(encoding="utf-8")
)
additional_manifest = json.loads(
(
project_root / ".specify" / "integrations" / f"{additional}.manifest.json"
).read_text(encoding="utf-8")
)
files = manifest.get("files", {})
assert isinstance(files, dict), f"{key} manifest files must be an object"
manifests[key] = set(files.keys())
for first, second in _multi_install_safe_pairs():
overlap = manifests[first] & manifests[second]
assert not overlap, (
f"{first} and {second} are declared multi-install safe but both manage "
f"these files: {sorted(overlap)}"
initial_files = set(initial_manifest.get("files", {}))
additional_files = set(additional_manifest.get("files", {}))
assert initial_files.isdisjoint(additional_files), (
f"{initial} and {additional} are declared multi-install safe but both manage "
f"these files: {sorted(initial_files & additional_files)}"
)

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

@@ -1,339 +0,0 @@
"""Parity tests for the Python check-prerequisites PoC."""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
from tests.conftest import requires_bash
PROJECT_ROOT = Path(__file__).resolve().parent.parent
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
CHECK_PREREQS_SH = PROJECT_ROOT / "scripts" / "bash" / "check-prerequisites.sh"
COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
CHECK_PREREQS_PS = PROJECT_ROOT / "scripts" / "powershell" / "check-prerequisites.ps1"
COMMON_PY = PROJECT_ROOT / "scripts" / "python" / "common.py"
CHECK_PREREQS_PY = PROJECT_ROOT / "scripts" / "python" / "check_prerequisites.py"
HAS_PWSH = shutil.which("pwsh") is not None
_WINDOWS_POWERSHELL = (
shutil.which("powershell.exe") or shutil.which("powershell")
) if os.name == "nt" else None
def _install_scripts(repo: Path) -> None:
bash_dir = repo / ".specify" / "scripts" / "bash"
bash_dir.mkdir(parents=True, exist_ok=True)
shutil.copy(COMMON_SH, bash_dir / "common.sh")
shutil.copy(CHECK_PREREQS_SH, bash_dir / "check-prerequisites.sh")
ps_dir = repo / ".specify" / "scripts" / "powershell"
ps_dir.mkdir(parents=True, exist_ok=True)
shutil.copy(COMMON_PS, ps_dir / "common.ps1")
shutil.copy(CHECK_PREREQS_PS, ps_dir / "check-prerequisites.ps1")
py_dir = repo / ".specify" / "scripts" / "python"
py_dir.mkdir(parents=True, exist_ok=True)
shutil.copy(COMMON_PY, py_dir / "common.py")
shutil.copy(CHECK_PREREQS_PY, py_dir / "check_prerequisites.py")
def _write_feature_json(
repo: Path, feature_directory: str = "specs/001-my-feature"
) -> None:
(repo / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": feature_directory}, separators=(",", ":"))
+ "\n",
encoding="utf-8",
)
def _clean_env() -> dict[str, str]:
env = os.environ.copy()
for key in list(env):
if key.startswith("SPECIFY_"):
env.pop(key)
return env
def _git_init(repo: Path) -> None:
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
subprocess.run(
["git", "config", "user.email", "test@example.com"], cwd=repo, check=True
)
subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True)
subprocess.run(
["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=repo, check=True
)
@pytest.fixture
def prereq_repo(tmp_path: Path) -> Path:
repo = tmp_path / "proj"
repo.mkdir()
_git_init(repo)
(repo / ".specify").mkdir()
_install_scripts(repo)
return repo
def _py_cmd(repo: Path, *args: str) -> list[str]:
script = repo / ".specify" / "scripts" / "python" / "check_prerequisites.py"
return [sys.executable, str(script), *args]
def _repo_copy_py_cmd(repo: Path, *args: str) -> list[str]:
script = repo / "scripts" / "python" / "check_prerequisites.py"
return [sys.executable, str(script), *args]
def _bash_cmd(repo: Path, *args: str) -> list[str]:
script = repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
return ["bash", str(script), *args]
def _ps_cmd(repo: Path, *args: str) -> list[str]:
script = repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
return [exe, "-NoProfile", "-File", str(script), *args]
def _run(
cmd: list[str], repo: Path, env: dict[str, str] | None = None
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
cmd,
cwd=repo,
capture_output=True,
text=True,
check=False,
env=env or _clean_env(),
)
def _json_stdout(result: subprocess.CompletedProcess[str]) -> object:
return json.loads(result.stdout)
def _normalize_status_text(text: str) -> str:
return (
text.replace("", " [OK] ")
.replace("", " [FAIL] ")
.replace("\r\n", "\n")
)
def _normalize_help_text(text: str) -> str:
normalized = text.replace("\r\n", "\n").replace(
"check-prerequisites.sh", "check_prerequisites.py"
)
return "\n".join("" if not line.strip() else line for line in normalized.split("\n"))
@requires_bash
@pytest.mark.parametrize(
"args",
[
("--json",),
("--json", "--include-tasks"),
("--json", "--require-tasks", "--include-tasks"),
("--json", "--paths-only"),
],
)
def test_python_json_output_matches_bash(prereq_repo: Path, args: tuple[str, ...]) -> None:
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True)
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
(feat / "tasks.md").write_text("# tasks\n", encoding="utf-8")
(feat / "research.md").write_text("# research\n", encoding="utf-8")
(feat / "data-model.md").write_text("# model\n", encoding="utf-8")
(feat / "quickstart.md").write_text("# quickstart\n", encoding="utf-8")
(feat / "contracts" / "v1").mkdir(parents=True)
_write_feature_json(prereq_repo)
bash = _run(_bash_cmd(prereq_repo, *args), prereq_repo)
py = _run(_py_cmd(prereq_repo, *args), prereq_repo)
assert py.returncode == bash.returncode == 0
assert py.stderr == bash.stderr == ""
assert _json_stdout(py) == _json_stdout(bash)
@requires_bash
def test_python_text_output_matches_bash(prereq_repo: Path) -> None:
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True)
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
(feat / "contracts").mkdir()
_write_feature_json(prereq_repo)
bash = _run(_bash_cmd(prereq_repo, "--include-tasks"), prereq_repo)
py = _run(_py_cmd(prereq_repo, "--include-tasks"), prereq_repo)
assert py.returncode == bash.returncode == 0
assert py.stderr == bash.stderr == ""
assert _normalize_status_text(py.stdout) == _normalize_status_text(bash.stdout)
@requires_bash
def test_python_help_output_matches_bash(prereq_repo: Path) -> None:
bash = _run(_bash_cmd(prereq_repo, "--help"), prereq_repo)
py = _run(_py_cmd(prereq_repo, "--help"), prereq_repo)
assert py.returncode == bash.returncode == 0
assert py.stderr == bash.stderr == ""
assert _normalize_help_text(py.stdout) == _normalize_help_text(bash.stdout)
@requires_bash
def test_python_unknown_option_matches_bash_error_shape(prereq_repo: Path) -> None:
bash = _run(_bash_cmd(prereq_repo, "--bogus"), prereq_repo)
py = _run(_py_cmd(prereq_repo, "--bogus"), prereq_repo)
assert py.returncode == bash.returncode == 1
assert py.stdout == bash.stdout == ""
assert py.stderr == bash.stderr
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
@pytest.mark.parametrize(
("py_args", "ps_args"),
[
(("--json",), ("-Json",)),
(("--json", "--include-tasks"), ("-Json", "-IncludeTasks")),
(
("--json", "--require-tasks", "--include-tasks"),
("-Json", "-RequireTasks", "-IncludeTasks"),
),
(("--json", "--paths-only"), ("-Json", "-PathsOnly")),
],
ids=[
"json",
"json_include_tasks",
"json_require_tasks_include_tasks",
"json_paths_only",
],
)
def test_python_json_output_matches_powershell(
prereq_repo: Path, py_args: tuple[str, ...], ps_args: tuple[str, ...]
) -> None:
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True)
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
(feat / "tasks.md").write_text("# tasks\n", encoding="utf-8")
(feat / "research.md").write_text("# research\n", encoding="utf-8")
(feat / "data-model.md").write_text("# model\n", encoding="utf-8")
(feat / "quickstart.md").write_text("# quickstart\n", encoding="utf-8")
(feat / "contracts" / "v1").mkdir(parents=True)
_write_feature_json(prereq_repo)
ps = _run(_ps_cmd(prereq_repo, *ps_args), prereq_repo)
py = _run(_py_cmd(prereq_repo, *py_args), prereq_repo)
assert py.returncode == ps.returncode == 0
assert py.stderr == ps.stderr == ""
assert _json_stdout(py) == _json_stdout(ps)
def test_python_repo_copy_script_file_fallback_finds_repo_root(tmp_path: Path) -> None:
repo = tmp_path / "proj"
outside = tmp_path / "outside"
repo.mkdir()
outside.mkdir()
_git_init(repo)
(repo / ".specify").mkdir()
_write_feature_json(repo)
(repo / "specs" / "001-my-feature").mkdir(parents=True)
py_dir = repo / "scripts" / "python"
py_dir.mkdir(parents=True)
shutil.copy(COMMON_PY, py_dir / "common.py")
shutil.copy(CHECK_PREREQS_PY, py_dir / "check_prerequisites.py")
py = _run(_repo_copy_py_cmd(repo, "--json", "--paths-only"), outside)
assert py.returncode == 0, py.stderr
assert Path(_json_stdout(py)["REPO_ROOT"]) == repo
def test_python_paths_only_does_not_persist_feature_json(prereq_repo: Path) -> None:
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True)
(prereq_repo / "specs" / "002-other").mkdir(parents=True)
_write_feature_json(prereq_repo, "specs/001-my-feature")
feature_json = prereq_repo / ".specify" / "feature.json"
before = feature_json.read_text(encoding="utf-8")
env = _clean_env()
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other"
py = _run(_py_cmd(prereq_repo, "--json", "--paths-only"), prereq_repo, env=env)
assert py.returncode == 0, py.stderr
assert "002-other" in _json_stdout(py)["FEATURE_DIR"]
assert feature_json.read_text(encoding="utf-8") == before
def test_python_normal_mode_persists_feature_json(prereq_repo: Path) -> None:
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True)
feat = prereq_repo / "specs" / "002-other"
feat.mkdir(parents=True)
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
_write_feature_json(prereq_repo, "specs/001-my-feature")
env = _clean_env()
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other"
py = _run(_py_cmd(prereq_repo, "--json"), prereq_repo, env=env)
assert py.returncode == 0, py.stderr
data = json.loads(
(prereq_repo / ".specify" / "feature.json").read_text(encoding="utf-8")
)
assert data["feature_directory"] == "specs/002-other"
@pytest.mark.parametrize(
("args", "expected"),
[
(("--json",), "Feature directory not found"),
(("--json",), "plan.md not found"),
(("--json", "--require-tasks"), "tasks.md not found"),
],
ids=["missing_feature_context", "missing_plan", "missing_tasks"],
)
def test_python_negative_errors_are_stderr_only(
tmp_path: Path, args: tuple[str, ...], expected: str
) -> None:
repo = tmp_path / "proj"
repo.mkdir()
_git_init(repo)
(repo / ".specify").mkdir()
_install_scripts(repo)
if expected in {"plan.md not found", "tasks.md not found"}:
feat = repo / "specs" / "001-my-feature"
feat.mkdir(parents=True)
_write_feature_json(repo)
if expected == "tasks.md not found":
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
py = _run(_py_cmd(repo, *args), repo)
assert py.returncode != 0
assert expected in py.stderr
assert expected not in py.stdout
assert py.stdout.strip() == ""
def test_python_branch_falls_back_to_feature_dir_basename(prereq_repo: Path) -> None:
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True)
_write_feature_json(prereq_repo)
py = _run(_py_cmd(prereq_repo, "--json", "--paths-only"), prereq_repo)
assert py.returncode == 0, py.stderr
assert _json_stdout(py)["BRANCH"] == "001-my-feature"

View File

@@ -850,67 +850,6 @@ class TestExtensionSkillRegistration:
assert ".specify/templates/checklist.md" in content
assert ".specify/memory/constitution.md" in content
def test_skill_registration_uses_extension_local_script_paths(self, project_dir, temp_dir):
"""Auto-registered skills should not rewrite extension scripts into core scripts."""
_create_init_options(project_dir, ai="claude", ai_skills=True)
skills_dir = _create_skills_dir(project_dir, ai="claude")
ext_dir = temp_dir / "scripted-ext"
ext_dir.mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "scripted-ext",
"name": "Scripted Extension",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.scripted-ext.check",
"file": "commands/check.md",
"description": "Scripted check command",
}
]
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.safe_dump(manifest_data, f)
(ext_dir / "commands").mkdir()
(ext_dir / "scripts" / "bash").mkdir(parents=True)
(ext_dir / "scripts" / "bash" / "resolve-skill.sh").write_text(
"#!/usr/bin/env bash\n"
)
(ext_dir / "scripts" / "bash" / "ensure-skills.sh").write_text(
"#!/usr/bin/env bash\n"
)
(ext_dir / "commands" / "check.md").write_text(
"---\n"
"description: Scripted check command\n"
"scripts:\n"
' sh: scripts/bash/resolve-skill.sh "{ARGS}"\n'
"---\n\n"
"Run {SCRIPT}\n"
"Then run scripts/bash/ensure-skills.sh.\n"
)
manager = ExtensionManager(project_dir)
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
content = (skills_dir / "speckit-scripted-ext-check" / "SKILL.md").read_text()
assert "{SCRIPT}" not in content
assert "{ARGS}" not in content
assert (
'.specify/extensions/scripted-ext/scripts/bash/resolve-skill.sh "$ARGUMENTS"'
in content
)
assert ".specify/extensions/scripted-ext/scripts/bash/ensure-skills.sh" in content
assert ".specify/scripts/bash/resolve-skill.sh" not in content
assert ".specify/scripts/bash/ensure-skills.sh" not in content
def test_missing_command_file_skipped(self, skills_project, temp_dir):
"""Commands with missing source files should be skipped gracefully."""
project_dir, skills_dir = skills_project

View File

@@ -31,7 +31,6 @@ from specify_cli.extensions import (
ExtensionRegistry,
ExtensionManager,
CommandRegistrar,
ConfigManager,
HookExecutor,
ExtensionCatalog,
ExtensionError,
@@ -1147,12 +1146,10 @@ class TestExtensionManager:
context_note=None,
link_outputs=False,
create_missing_active_skills_dir=False,
extension_id=None,
):
captured["create_missing_active_skills_dir"] = (
create_missing_active_skills_dir
)
captured["extension_id"] = extension_id
return {}
monkeypatch.setattr(
@@ -1166,7 +1163,6 @@ class TestExtensionManager:
registrar.register_commands_for_all_agents(manifest, extension_dir, project_dir)
assert captured["create_missing_active_skills_dir"] is False
assert captured["extension_id"] == manifest.id
def test_install_duplicate(self, extension_dir, project_dir):
"""Test installing already installed extension."""
@@ -1698,29 +1694,6 @@ $ARGUMENTS
assert adjusted["scripts"]["sh"] == ".specify/extensions/test-ext/scripts/setup.sh {ARGS}"
assert adjusted["scripts"]["ps"] == ".specify/scripts/powershell/setup-plan.ps1 {ARGS}"
def test_adjust_script_paths_rewrites_extension_top_level_scripts(self):
"""Extension command-local scripts should resolve under the installed extension."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
registrar = AgentCommandRegistrar()
original = {
"scripts": {
"sh": "scripts/bash/resolve-skill.sh {ARGS}",
"ps": "../../scripts/powershell/setup-plan.ps1 -Json",
}
}
adjusted = registrar._adjust_script_paths(original, extension_id="test-ext")
assert (
adjusted["scripts"]["sh"]
== ".specify/extensions/test-ext/scripts/bash/resolve-skill.sh {ARGS}"
)
assert (
adjusted["scripts"]["ps"]
== ".specify/scripts/powershell/setup-plan.ps1 -Json"
)
def test_rewrite_project_relative_paths_preserves_extension_local_body_paths(self):
"""Body rewrites should preserve extension-local assets while fixing top-level refs."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
@@ -1735,24 +1708,6 @@ $ARGUMENTS
assert ".specify/extensions/test-ext/templates/spec.md" in rewritten
assert ".specify/scripts/bash/setup-plan.sh" in rewritten
def test_rewrite_project_relative_paths_uses_extension_context_for_scripts(self):
"""Extension source bodies treat top-level scripts/ as extension-local."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
body = (
"Run scripts/bash/ensure-skills.sh\n"
"Fallback ../../scripts/bash/setup-plan.sh\n"
"Read templates/checklist.md\n"
)
rewritten = AgentCommandRegistrar.rewrite_project_relative_paths(
body, extension_id="test-ext"
)
assert ".specify/extensions/test-ext/scripts/bash/ensure-skills.sh" in rewritten
assert ".specify/scripts/bash/setup-plan.sh" in rewritten
assert ".specify/templates/checklist.md" in rewritten
def test_render_toml_command_handles_embedded_triple_double_quotes(self):
"""TOML renderer should stay valid when body includes triple double-quotes."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
@@ -1795,25 +1750,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 +5396,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
@@ -7579,52 +7492,3 @@ def test_extension_wrapper_resolves_ghes_asset_when_host_configured(tmp_path, mo
)
assert resolved == "https://ghes.example/api/v3/repos/o/r/releases/assets/7"
assert captured == ["https://ghes.example/api/v3/repos/o/r/releases/tags/v1"]
class TestConfigManagerNonMappingYaml:
"""A non-mapping YAML config root must not crash config/hook resolution."""
def _make(self, tmp_path, body: str):
ext_dir = tmp_path / ".specify" / "extensions" / "jira"
ext_dir.mkdir(parents=True)
(ext_dir / "jira-config.yml").write_text(body, encoding="utf-8")
return ConfigManager(tmp_path, "jira")
def test_get_config_coerces_list_root(self, tmp_path):
"""A YAML list root previously raised AttributeError in _merge_configs."""
cm = self._make(tmp_path, "- foo\n- bar\n")
assert cm.get_config() == {}
def test_get_config_coerces_scalar_root(self, tmp_path):
cm = self._make(tmp_path, "just a string\n")
assert cm.get_config() == {}
def test_has_value_and_get_value_do_not_raise(self, tmp_path):
cm = self._make(tmp_path, "- foo\n")
assert cm.has_value("anything") is False
assert cm.get_value("anything") is None
def test_valid_local_config_layers_over_list_root_project_config(self, tmp_path):
"""A malformed project config must not block a valid local config."""
ext_dir = tmp_path / ".specify" / "extensions" / "jira"
ext_dir.mkdir(parents=True)
(ext_dir / "jira-config.yml").write_text("- foo\n- bar\n", encoding="utf-8")
(ext_dir / "local-config.yml").write_text(
"notifications:\n enabled: true\n", encoding="utf-8"
)
cm = ConfigManager(tmp_path, "jira")
assert cm.get_value("notifications.enabled") is True
def test_hook_condition_returns_false_without_raising(self, tmp_path):
"""`config.x is set` on a scalar-root config must evaluate cleanly.
Before the fix, _merge_configs raised AttributeError and the
exception was swallowed by should_execute_hook, silently disabling
every config-based hook for the extension. Assert on
_evaluate_condition directly so the crash isn't masked.
"""
ext_dir = tmp_path / ".specify" / "extensions" / "jira"
ext_dir.mkdir(parents=True)
(ext_dir / "jira-config.yml").write_text("just a string\n", encoding="utf-8")
executor = HookExecutor(tmp_path)
assert executor._evaluate_condition("config.x is set", "jira") is False

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

@@ -1,245 +0,0 @@
"""Tests for post_process_command_content() hook on IntegrationBase.
Verifies that the generalized post-processing hook:
- Runs for non-skills format types (Markdown, TOML, YAML)
- Does NOT run for skills-format agents
- Default no-op returns content unchanged
- Exceptions propagate to caller
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from specify_cli.agents import CommandRegistrar
from specify_cli.integrations.base import IntegrationBase
@pytest.fixture
def registrar():
return CommandRegistrar()
@pytest.fixture
def ext_dir(tmp_path):
"""Create a mock extension with a simple command template."""
ext = tmp_path / "extension"
ext.mkdir()
cmd_dir = ext / "commands"
cmd_dir.mkdir()
return ext, cmd_dir
def _write_cmd(cmd_dir, name="review.md", body="Review the code.\n"):
cmd_file = cmd_dir / name
cmd_file.write_text(
f"---\ndescription: Test command\n---\n\n{body}",
encoding="utf-8",
)
return cmd_file
class TestDefaultNoOp:
def test_returns_content_unchanged(self):
base = IntegrationBase()
content = "Some command content\nwith multiple lines."
assert base.post_process_command_content(content) == content
def test_empty_string(self):
base = IntegrationBase()
assert base.post_process_command_content("") == ""
class TestMarkdownAgentPostProcess:
def test_opencode_post_process_applied(
self, tmp_path, registrar, ext_dir, monkeypatch
):
ext, cmd_dir = ext_dir
_write_cmd(cmd_dir)
from specify_cli.integrations import get_integration
opencode = get_integration("opencode")
marker = "<!-- POST_PROCESSED -->"
def _inject_marker(self, content):
return content + marker
monkeypatch.setattr(
opencode.__class__, "post_process_command_content", _inject_marker
)
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
registrar.register_commands(
"opencode", commands, "test-ext", ext, tmp_path
)
cmd_output = tmp_path / ".opencode" / "commands" / "speckit.test.review.md"
assert cmd_output.exists()
content = cmd_output.read_text(encoding="utf-8")
assert marker in content
class TestTomlAgentPostProcess:
def test_gemini_post_process_applied(
self, tmp_path, registrar, ext_dir, monkeypatch
):
ext, cmd_dir = ext_dir
_write_cmd(cmd_dir)
from specify_cli.integrations import get_integration
gemini = get_integration("gemini")
marker = "# POST_PROCESSED"
def _inject_marker(self, content):
return content + f"\n{marker}\n"
monkeypatch.setattr(
gemini.__class__, "post_process_command_content", _inject_marker
)
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
registrar.register_commands(
"gemini", commands, "test-ext", ext, tmp_path
)
cmd_output = tmp_path / ".gemini" / "commands" / "speckit.test.review.toml"
assert cmd_output.exists()
content = cmd_output.read_text(encoding="utf-8")
assert marker in content
class TestYamlAgentPostProcess:
def test_goose_post_process_applied(
self, tmp_path, registrar, ext_dir, monkeypatch
):
ext, cmd_dir = ext_dir
_write_cmd(cmd_dir)
from specify_cli.integrations import get_integration
goose = get_integration("goose")
marker = "# POST_PROCESSED"
def _inject_marker(self, content):
return content + f"\n{marker}\n"
monkeypatch.setattr(
goose.__class__, "post_process_command_content", _inject_marker
)
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
registrar.register_commands(
"goose", commands, "test-ext", ext, tmp_path
)
cmd_output = tmp_path / ".goose" / "recipes" / "speckit.test.review.yaml"
assert cmd_output.exists()
content = cmd_output.read_text(encoding="utf-8")
assert marker in content
class TestSkillsAgentExcluded:
def test_claude_post_process_not_called(
self, tmp_path, registrar, ext_dir, monkeypatch
):
ext, cmd_dir = ext_dir
_write_cmd(cmd_dir)
from specify_cli.integrations import get_integration
claude = get_integration("claude")
marker = "<!-- SHOULD_NOT_APPEAR -->"
def _inject_marker(self, content):
return content + marker
monkeypatch.setattr(
claude.__class__, "post_process_command_content", _inject_marker
)
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
registrar.register_commands(
"claude", commands, "test-ext", ext, tmp_path
)
skill_file = (
tmp_path / ".claude" / "skills" / "speckit-test-review" / "SKILL.md"
)
assert skill_file.exists()
content = skill_file.read_text(encoding="utf-8")
assert marker not in content
def test_skills_agent_method_never_called(
self, tmp_path, registrar, ext_dir
):
ext, cmd_dir = ext_dir
_write_cmd(cmd_dir)
from specify_cli.integrations import get_integration
claude = get_integration("claude")
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
with patch.object(
claude.__class__, "post_process_command_content", wraps=claude.post_process_command_content
) as mock_method:
registrar.register_commands(
"claude", commands, "test-ext", ext, tmp_path
)
mock_method.assert_not_called()
class TestExceptionPropagation:
def test_hook_exception_propagates(
self, tmp_path, registrar, ext_dir, monkeypatch
):
ext, cmd_dir = ext_dir
_write_cmd(cmd_dir)
from specify_cli.integrations import get_integration
opencode = get_integration("opencode")
def _raise(self, content):
raise RuntimeError("Hook failed")
monkeypatch.setattr(
opencode.__class__, "post_process_command_content", _raise
)
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
with pytest.raises(RuntimeError, match="Hook failed"):
registrar.register_commands(
"opencode", commands, "test-ext", ext, tmp_path
)
class TestRegressionPlainTemplate:
@pytest.mark.parametrize(
"agent,path_pattern",
[
("claude", ".claude/skills/speckit-test-plain/SKILL.md"),
("opencode", ".opencode/commands/speckit.test.plain.md"),
],
ids=["skills", "markdown"],
)
def test_plain_template_unchanged(
self, tmp_path, registrar, ext_dir, agent, path_pattern
):
ext, cmd_dir = ext_dir
body_text = "This is a plain command with no special content.\n"
_write_cmd(cmd_dir, name="plain.md", body=body_text)
commands = [{"name": "speckit.test.plain", "file": "commands/plain.md"}]
registrar.register_commands(
agent, commands, "test-ext", ext, tmp_path
)
output_file = tmp_path / path_pattern
assert output_file.exists(), f"Output file missing for {agent}"
content = output_file.read_text(encoding="utf-8")
assert body_text.strip() in content, f"Body text missing in {agent} output"

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

@@ -322,87 +322,3 @@ class TestWorkflowRunWithoutProject:
assert result.exit_code != 0
assert ".specify path exists but is not a directory" in result.output
class TestWorkflowRunJsonErrorStream:
"""Under --json, error text must go to stderr so stdout stays parseable."""
def _bad_workflow(self, tmp_path):
wf = tmp_path / "bad.yml"
wf.write_text(
yaml.dump(
{
"schema_version": "1.0",
"workflow": {
"id": "bad-wf",
"name": "Bad",
"version": "1.0.0",
"description": "fails validation",
},
# shell step missing required 'run' -> validation error
"steps": [{"id": "s", "type": "shell"}],
}
),
encoding="utf-8",
)
return wf
def test_run_json_validation_error_not_on_stdout(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
wf = self._bad_workflow(tmp_path)
runner = CliRunner()
old = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(
app, ["workflow", "run", str(wf), "--json"], catch_exceptions=False
)
finally:
os.chdir(old)
assert result.exit_code == 1
# stdout must carry only JSON (here: nothing) — never human error text.
assert "validation failed" not in result.stdout
assert "Error" not in result.stdout
# The message is routed to stderr instead.
assert "validation failed" in result.stderr
def test_run_json_invalid_input_not_on_stdout(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
# A valid single-shell workflow so we get past load/validate to
# _parse_input_values, which rejects the malformed --input.
wf = tmp_path / "ok.yml"
wf.write_text(
yaml.dump(
{
"schema_version": "1.0",
"workflow": {
"id": "ok-wf",
"name": "OK",
"version": "1.0.0",
"description": "x",
},
"steps": [{"id": "s", "type": "shell", "run": "echo hi"}],
}
),
encoding="utf-8",
)
runner = CliRunner()
old = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(
app,
["workflow", "run", str(wf), "--json", "--input", "no-equals"],
catch_exceptions=False,
)
finally:
os.chdir(old)
assert result.exit_code == 1
assert "Invalid input format" not in result.stdout
assert "Invalid input format" in result.stderr

View File

@@ -260,60 +260,6 @@ class TestExpressions:
ctx = StepContext(inputs={"text": "uses }} syntax"})
assert evaluate_expression("{{ inputs.text | contains('}}') }}", ctx) is True
def test_multi_expression_with_literal_close_brace_in_argument(self):
"""A multi-expression template with a literal ``}}`` inside a string
argument must interpolate, not raise. #3208/#3228 hardened the single-
expression fast path for literal braces but left the interpolation path
on ``_EXPR_PATTERN``, whose non-greedy body stops at the first ``}}`` --
so the block was captured truncated and the filter parser raised
ValueError."""
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(inputs={"name": "Bob", "missing": None})
# ``}}`` in the default fallback of the second block.
result = evaluate_expression(
"{{ inputs.name }}: {{ inputs.missing | default('}}') }}", ctx
)
assert result == "Bob: }}"
# ``}}`` in the first block, expression following it.
result = evaluate_expression(
"{{ inputs.missing | default('}}') }} / {{ inputs.name }}", ctx
)
assert result == "}} / Bob"
def test_multi_expression_with_literal_open_brace_in_argument(self):
"""A literal ``{{`` inside a string argument in a multi-expression
template must not confuse block detection either."""
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(inputs={"name": "Bob", "missing": None})
result = evaluate_expression(
"{{ inputs.name }} {{ inputs.missing | default('{{') }}", ctx
)
assert result == "Bob {{"
def test_multi_expression_unbalanced_quote_still_raises(self):
"""A malformed block (an unbalanced quote in a filter arg) must still
surface a ValueError, not be silently emitted verbatim.
The quote-aware scan never finds a block-closing ``}}`` when a quote is
left open, but a raw ``}}`` is still present in the tail. It must fall
back to that raw delimiter and evaluate — same as the old regex path —
so a typo fails loudly instead of being hidden (Copilot review on
#3307)."""
import pytest
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(inputs={"name": "Bob", "missing": None})
with pytest.raises(ValueError):
evaluate_expression(
"{{ inputs.name }} {{ inputs.missing | default('oops }}", ctx
)
def test_comparison_equals(self):
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
@@ -342,35 +288,6 @@ class TestExpressions:
assert evaluate_expression("{{ steps.plan.output.task_count > 5 }}", ctx) is True
assert evaluate_expression("{{ steps.plan.output.task_count < 5 }}", ctx) is False
def test_ordering_comparison_of_non_numeric_strings(self):
"""`<`/`>`/`<=`/`>=` between non-numeric strings must compare
lexicographically, not silently return False.
`_safe_compare` used to coerce both operands to int/float unconditionally;
a non-numeric string (date, version tag, name) failed that coercion and
the whole comparison returned False. Ordinary strings should order the
way Python does; numeric strings must still compare as numbers."""
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
# ISO dates compare lexicographically (correct chronological order).
ctx = StepContext(inputs={"d": "2026-01-01"})
assert evaluate_expression("{{ inputs.d < '2026-02-01' }}", ctx) is True
assert evaluate_expression("{{ inputs.d > '2026-02-01' }}", ctx) is False
# Plain string ordering.
ctx = StepContext(inputs={"name": "beta"})
assert evaluate_expression("{{ inputs.name > 'alpha' }}", ctx) is True
# Two numeric strings still compare numerically, not lexically
# ("10" > "9" is True as numbers; as strings it would be False).
ctx = StepContext(inputs={"v": "10"})
assert evaluate_expression("{{ inputs.v > '9' }}", ctx) is True
# A number vs a non-numeric string is genuinely incomparable -> False.
ctx = StepContext(inputs={"n": 5})
assert evaluate_expression("{{ inputs.n > 'abc' }}", ctx) is False
def test_boolean_and(self):
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
@@ -1266,26 +1183,6 @@ class TestShellStep:
errors = step.validate({"id": "test"})
assert any("missing 'run'" in e for e in errors)
@pytest.mark.parametrize("bad_run", [None, ["echo", "hi"], 42])
def test_validate_rejects_non_string_run(self, bad_run):
"""A non-string 'run' must be rejected at validation.
execute() str()-coerces run and invokes it under shell=True, so a
null or list run would otherwise run the Python repr as a command.
"""
from specify_cli.workflows.steps.shell import ShellStep
step = ShellStep()
errors = step.validate({"id": "s", "run": bad_run})
assert any("'run' must be a string" in e for e in errors)
def test_validate_accepts_string_and_expression_run(self):
from specify_cli.workflows.steps.shell import ShellStep
step = ShellStep()
assert step.validate({"id": "s", "run": "echo hi"}) == []
assert step.validate({"id": "s", "run": "{{ steps.x.output }}"}) == []
def test_output_format_json_exposes_data(self, tmp_path):
from specify_cli.workflows.steps.shell import ShellStep
@@ -2202,27 +2099,6 @@ class TestFanInStep:
errors = step.validate({"id": "test", "wait_for": "not-a-list"})
assert any("non-empty list" in e for e in errors)
@pytest.mark.parametrize("bad_output", [["{{ fan_in.results }}"], "{{ x }}", 42])
def test_validate_rejects_non_mapping_output(self, bad_output):
"""A non-mapping 'output' must be rejected: execute() would otherwise
silently coerce it to {} and drop the declared aggregation keys."""
from specify_cli.workflows.steps.fan_in import FanInStep
step = FanInStep()
errors = step.validate(
{"id": "j", "wait_for": ["a"], "output": bad_output}
)
assert any("'output' must be a mapping" in e for e in errors)
def test_validate_accepts_mapping_or_absent_output(self):
from specify_cli.workflows.steps.fan_in import FanInStep
step = FanInStep()
assert step.validate(
{"id": "j", "wait_for": ["a"], "output": {"joined": "{{ x }}"}}
) == []
assert step.validate({"id": "j", "wait_for": ["a"]}) == []
class TestFanOutConcurrency:
"""Fan-out honors max_concurrency (WorkflowEngine._run_fan_out)."""
@@ -4101,48 +3977,6 @@ steps:
assert state.status == RunStatus.ABORTED
assert "should-not-run" not in state.step_results
def test_gate_reject_matches_case_insensitively(
self, project_dir, monkeypatch
):
"""A capitalised reject option (`options: [Approve, Reject]`) still
aborts the run. `validate` accepts a reject choice case-insensitively,
so the runtime reject check must agree — a case-sensitive comparison
would treat the echoed `Reject` as approval and silently run
downstream steps.
"""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
from specify_cli.workflows.base import RunStatus
from specify_cli.workflows.steps.gate import GateStep
# `_prompt` echoes the option's original casing, so the operator
# picking "Reject" hands `execute` the capitalised string.
_force_gate_stdin(monkeypatch, tty=True)
monkeypatch.setattr(
GateStep, "_prompt", staticmethod(lambda _msg, _opts: "Reject")
)
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "gate-reject-case"
name: "Gate Reject Case"
version: "1.0.0"
steps:
- id: gate-step
type: gate
message: "Approve?"
options: [Approve, Reject]
on_reject: abort
- id: should-not-run
type: shell
run: "echo nope"
""")
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
assert state.status == RunStatus.ABORTED
assert "should-not-run" not in state.step_results
def test_validation_rejects_non_bool_continue_on_error(self):
"""`continue_on_error` must be a literal boolean; coerced
strings like `"true"` are rejected at validation time so
@@ -5593,23 +5427,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

@@ -69,30 +69,3 @@ def test_http_fetch_rejects_non_https_final_url(monkeypatch):
fetcher = adapters.make_catalog_fetcher(allow_network=True)
with pytest.raises(BundlerError, match="must use HTTPS"):
fetcher(_source("https://example.com/c.json"))
@pytest.mark.parametrize(
"url",
[
"https://:8080", # port only, no host
"https://:0",
"https://user@", # userinfo only, no host
"https://user:pw@",
"https://:8080/catalog.json",
],
)
def test_validate_remote_url_rejects_host_less_urls(url):
"""A URL with a truthy netloc but no host (``https://:8080``,
``https://user@``) must be rejected.
``urlparse`` gives these a non-empty ``netloc`` but ``hostname is None``,
so a ``netloc`` check would wrongly accept them. This mirrors the fix in
``specify_cli.catalogs`` (#3210), which the docstring says this validator
mirrors."""
with pytest.raises(BundlerError, match="valid URL with a host"):
adapters._validate_remote_url("team", url)
def test_validate_remote_url_accepts_normal_https_url():
# Sanity: a real host with a port still passes.
adapters._validate_remote_url("team", "https://example.com:8080/c.json")

View File

@@ -222,39 +222,3 @@ def test_add_source_allows_local_path_with_colon(tmp_path: Path, monkeypatch):
# A relative path containing ':' but no '://' is still a local path.
source = cc.add_source(project, "weird:name.json", policy="install-allowed", priority=50)
assert source.url.endswith("weird:name.json") or "weird" in source.url
def test_add_source_rejects_plain_http_for_non_localhost(tmp_path: Path):
project = tmp_path / "proj"
(project / ".specify").mkdir(parents=True)
with pytest.raises(BundlerError, match="HTTPS"):
cc.add_source(project, "http://example.com/catalog.json", policy="install-allowed", priority=50)
def test_add_source_allows_http_for_localhost(tmp_path: Path):
project = tmp_path / "proj"
(project / ".specify").mkdir(parents=True)
source = cc.add_source(project, "http://localhost:8080/c.json", policy="install-allowed", priority=50)
assert source.url == "http://localhost:8080/c.json"
def test_add_source_rejects_host_less_remote_urls(tmp_path: Path):
project = tmp_path / "proj"
(project / ".specify").mkdir(parents=True)
for url in ("https://:8080", "https://user@"):
with pytest.raises(BundlerError, match="host"):
cc.add_source(project, url, policy="install-allowed", priority=50)
def test_add_source_wraps_invalid_ipv6_as_bundler_error(tmp_path: Path):
project = tmp_path / "proj"
(project / ".specify").mkdir(parents=True)
with pytest.raises(BundlerError, match="Invalid catalog url"):
cc.add_source(project, "https://[::1/c.json", policy="install-allowed", priority=50)
def test_remove_source_does_not_crash_on_invalid_ipv6(tmp_path: Path):
project = tmp_path / "proj"
(project / ".specify").mkdir(parents=True)
with pytest.raises(BundlerError, match="No project-scoped catalog source"):
cc.remove_source(project, "https://[::1/c.json")