Compare commits

..

16 Commits

Author SHA1 Message Date
Tine Kondo
3f7392ae32 docs: add 'spectatui' entry to friends.md (#3362)
* docs: add 'spectatui' entry to friends.md

Added a new entry for 'spectatui', a terminal UI dashboard for GitHub Spec-Kit, detailing its features and capabilities.

https://github.com/tinesoft/spectatui

* Potential fix for pull request finding

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

* docs: fix wording of the `spectatui` tool's decription

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

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-09 13:45:04 -05:00
Manfred Riem
d075b27360 test: pin interpreter probe so py-template render test passes on Windows (#3428)
test_template_renders_python_invocation monkeypatches shutil.which to
return /usr/bin/python3, but on Windows resolve_python_interpreter guards
the which() result with a real _interpreter_runs subprocess probe (#3304).
The mocked /usr/bin/python3 path does not exist on a Windows runner, so the
probe fails, the resolver falls back to sys.executable (a ...python.exe
path), and the python3-anchored regex assertion fails.

Patch _interpreter_runs to return True in the _pin_interpreter fixture so
the resolved interpreter token stays python3 across all platforms, keeping
the #3304 production guard intact while making the assertion deterministic.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-07-09 12:21:50 -05:00
Marsel Safin
eedf73f714 feat(workflows): make shell step timeout configurable (#3404)
* feat(workflows): make shell step timeout configurable

The shell step hardcoded a 300s subprocess timeout, killing any
legitimate long-running QA command. Read an optional timeout field
(seconds, positive integer, default 300) and validate it.

Fixes #3327

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* style: multi-line timeout validation, assert status in default test

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Guard against unvalidated timeout in ShellStep.execute()

The engine does not auto-validate step config, so a string or null
timeout would reach subprocess.run() and crash the run with a
TypeError. Fall back to the 300s default for malformed values,
mirroring how the engine treats unvalidated continue_on_error.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-09 11:06:45 -05:00
Marsel Safin
292eaa6c98 fix: find plans in nested spec directories (#3405)
* fix: find plans in nested spec directories

The agent-context mtime fallback used a one-level specs/*/plan.md
glob, so scoped layouts (specs/<scope>/<feature>/plan.md via
SPECIFY_FEATURE_DIRECTORY) never matched and the SPECKIT block was
written without a plan path. Recurse in both script variants and
update the command doc wording.

Fixes #3024

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor: pick newest plan with max(), align doc wording

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-09 08:53:54 -05:00
Marsel Safin
55da30c66d feat(templates): add py: lines to command templates' scripts frontmatter (#3403)
* feat(templates): add py: lines to command templates' scripts frontmatter

Every templates/commands/*.md with a scripts: block now declares a py:
variant so --script py renders a Python invocation via the existing
interpreter-prefixing in process_template.

Fixes #3283

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: install scripts/python for the py script type

install_shared_infra mapped every non-sh script type to powershell, so
--script py rendered invocations pointing at files that were never
installed. Map py to the python variant dir and skip __pycache__
artifacts during the copy.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test: read templates with explicit utf-8 encoding

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: keep py: lines standalone, enforce script existence in tests

Drop the plan/tasks py: lines that referenced scripts shipping in the
core port (#3280); they move to that PR. Tests now assert every py:
line points at a script the repo ships, so a dangling reference can
never merge green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-09 08:51:20 -05:00
Manfred Riem
062418093d chore: release 0.12.9, begin 0.12.10.dev0 development (#3426)
* chore: bump version to 0.12.9

* chore: begin 0.12.10.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-09 08:30:20 -05:00
Marsel Safin
15ac745e8d fix(integrations): skip Windows Store python3 alias stub in resolve_python_interpreter (#3385)
* fix(integrations): skip Windows Store python3 alias stub in resolve_python_interpreter

On stock Windows, python3 on PATH is the Microsoft Store App Execution
Alias stub: it exists but only prints an installer hint and exits
non-zero, so generated {SCRIPT} invocations for the py script type were
broken. Verify the found interpreter actually runs before accepting it,
on Windows only, mirroring the parse-success-not-availability approach
of #3312/#3320 for the sh scripts. POSIX keeps the plain existence
check. sys.executable remains the fallback and is always live.

Fixes #3383

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(integrations): probe interpreter isolated and without site, discard I/O

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test: pin POSIX platform in PATH-resolution tests

The tests fake shutil.which with POSIX paths; on Windows CI the real
sys.platform made the stub probe run against those fake paths and
fall through to sys.executable.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-09 08:14:29 -05:00
Marsel Safin
8e2e2d2f25 fix(integrations): escape control characters in SKILL.md frontmatter (#3399)
yaml.safe_dump with default_style='"' replaces the hand-rolled quote
helpers in base.py and hermes, so newlines and control characters in
template descriptions round-trip instead of producing unparseable YAML.

Fixes #3391

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-09 08:13:27 -05:00
Noor ul ain
a4d94309e0 fix(workflows): apply chained expression filters left-to-right (#3339)
* fix(workflows): apply chained expression filters left-to-right

The pipe-filter parser in `_evaluate_simple_expression` split the
expression only at the *first* top-level `|` and treated the whole
remainder as a single filter. So a filter chain like
`{{ inputs.rows | map('name') | join(', ') }}` handed
`map('name') | join(', ')` to one filter, where the `(\w+)\((.+)\)`
regex mangled it and raised `ValueError`.

This broke the canonical use of `map`: it returns a list, and `join`
is the only filter that renders a list to a string, so the two are
meant to be chained. Chaining was impossible for every registered
filter.

Split the pipe segments at the top level (quote/bracket aware, so a
literal `|` inside a quoted argument like `join(' | ')` is preserved)
and fold each filter over the running value. The single-filter logic
is extracted verbatim into `_apply_filter`, so all existing strict
handling (`from_json` arity, unsupported-form vs unknown-filter
messages) is unchanged and now applies to every link in the chain.

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

* Potential fix for pull request finding

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-09 08:10:43 -05:00
Noor ul ain
8eadcd7624 fix(scripts): resolve invoke_separator by parse success, not python3 availability (#3304) (#3320)
* fix(scripts): resolve invoke_separator by parse success, add awk fallback (#3304)

`get_invoke_separator` in common.sh selected its JSON parser by tool
*availability* (`command -v python3`) rather than parse *success*, and had
no text fallback after the python3 branch. On stock Windows + Git Bash —
no jq, and `python3` resolving to the Microsoft Store App Execution Alias
stub that passes `command -v` but exits 49 at runtime — it silently fell
back to "." even for `-`-separator integrations (e.g. forge, cline). The
observable result was wrong command hints in error messages, such as
`/speckit.plan` instead of `/speckit-plan`, in check-prerequisites.sh and
setup-tasks.sh.

This is the same existence-vs-runtime pattern fixed for the feature.json
parser in #3304's primary report; the reporter explicitly asked that other
python3 call sites be checked. The two remaining sites (resolve_template,
resolve_template_content) already fall through on failure and are unaffected.

- Restructure the jq -> python3 chain to fall through on parse failure,
  gated on a `parsed` success flag rather than exclusive elif branches.
- Make the python3 branch signal failure (sys.exit(1)) instead of printing
  "." so a stub failure falls through instead of being accepted.
- Add an awk text fallback (portable, no gawk-only whole-file slurp) that
  reads the active integration key and its invoke_separator, handling both
  pretty-printed (the written form) and compact JSON. Malformed input
  safely defaults to ".".

Regression test simulates the broken-stub environment (jq + python3 stubs
that exit 49 on PATH) and asserts the `-` separator is still recovered.

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

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

* fix(scripts): close awk END block so invoke_separator fallback works

The awk text fallback in get_invoke_separator was missing the closing brace for its END block, so awk aborted with a syntax error on every invocation and the separator silently stayed at the default '.'. On environments with neither jq nor a working python3 (stock Windows + Git Bash, the exact case this fallback exists for) a '-'-separator integration like forge produced a wrong command hint. The bug escaped CI because the test that exercises it is gated on working bash and skips on the Windows dev shell.

Also harden the fallback per review: use the portable '[-.]' character class (a leading '-' inside '[]' can be read as an ill-defined range on some awk builds), and correct the test docstring, which said 'with no jq' though the helper installs a present-but-failing jq stub. Addresses Copilot review feedback on PR #3320.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-09 08:08:58 -05:00
Ali jawwad
892dd656f2 fix(shared-infra): refresh_shared_templates preserves recovered user files (#3378)
* fix(shared-infra): refresh_shared_templates preserves recovered user files

refresh_shared_templates skipped a shared template only when it was
untracked or modified, ignoring the manifest's is_recovered marker that
install_shared_infra already honors. So a pre-existing user template
(adopted via record_existing(recovered=True), hence tracked and
hash-unmodified) was silently overwritten with bundled content on
refresh — the exact data-loss class that #2918 fixed for
install_shared_infra. Add the is_recovered check to the skip predicate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(shared-infra): include recovered files in refresh skip warning

The skip predicate now also skips recovered (pre-existing user) files,
so the warning saying only 'modified or untracked' could mislead a user
into thinking they edited a file that was simply recorded as recovered.
Reword to 'modified, untracked, or preserved (recovered)'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 07:49:29 -05:00
Ali jawwad
54ed736479 fix(agents): resolve skill placeholders in Goose (yaml) command output (#3374)
* fix(agents): resolve skill placeholders in Goose (yaml) command output

CommandRegistrar.register_commands resolves {SCRIPT}/__AGENT__ and the
$ARGUMENTS placeholder in the markdown and toml branches, but the yaml
branch (Goose recipes) called render_yaml_command directly, skipping
both. So extension/preset command bodies installed for Goose kept literal
{SCRIPT}, __AGENT__, and repo-relative script paths in the generated
.goose/recipes/*.yaml prompt. Mirror the markdown/toml branches: run
resolve_skill_placeholders + _convert_argument_placeholder on the body
before render_yaml_command.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(goose): assert positive placeholder replacements in recipe prompt

Per review: parse the generated recipe with yaml.safe_load and assert the
prompt contains the resolved values (.specify/scripts/, 'agent goose',
{{args}}), not merely that the literal tokens are absent — a wrong output
that happens to omit the exact strings would otherwise pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 07:48:38 -05:00
Ali jawwad
7b1065d857 fix(bundler): enforce version pin on bundled preset/extension installs (#3377)
* fix(bundler): enforce version pin on bundled preset/extension installs

The bundled install branch of _PresetKindManager/_ExtensionKindManager
called install_from_directory and returned before _assert_pinned_version,
so a bundle manifest pinning e.g. 2.0.0 would silently install the
bundled asset's own version (1.0.0) — the pin was only enforced on the
catalog path. _WorkflowKindManager already enforces it unconditionally.
Read the bundled asset's declared version from its manifest (best-effort;
None => cannot enforce, matching the catalog 'advertises no version'
escape hatch) and call _assert_pinned_version before install, in both
bundled branches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bundler): address review on bundled version-pin check

- Make _assert_pinned_version's error source-agnostic ('resolved version'
  / 'the source') so bundled preset.yml/extension.yml mismatches read
  correctly, not just catalog ones.
- Type-guard _bundled_manifest_version: only a non-empty string version is
  usable; missing/non-string/whitespace -> None ('cannot enforce').
- Add bundled-preset success-path test (matching pin + version=None both
  proceed to install_from_directory), mirroring the extension test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 07:46:22 -05:00
github-actions[bot]
c5fdae752b Update Golden Demo extension to v0.3.0 (#3394)
Update golden-demo extension submitted by @jasstt:
- extensions/catalog.community.json (version, download_url, description, provides, tags, updated_at)
- docs/community/extensions.md community extensions table

Closes #3360

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

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-09 07:39:04 -05:00
Pascal THUET
643f73a1d7 test: isolate integration test home (#3144)
* test: isolate integration test home

Assisted-by: Codex (model: GPT-5, autonomous)

* test: assert integration home isolation

Assisted-by: Codex (model: GPT-5, autonomous)

* test: extend integration home isolation to module-scoped setup

Address Copilot review on #3144.

Add a session-scoped autouse fixture so HOME/USERPROFILE/XDG are redirected
for setup that runs outside a test function (e.g. the module-scoped status_*
fixtures in test_integration_subcommand.py that run `specify init` before any
per-test isolation applies). The function-scoped fixture still overrides HOME
per test.

Also assert Path.home() resolves to the isolated home, since most integrations
(Hermes, catalog) read the home via that API rather than the env vars directly.
2026-07-09 07:35:38 -05:00
Manfred Riem
a7b439174f chore: release 0.12.8, begin 0.12.9.dev0 development (#3410)
* chore: bump version to 0.12.8

* chore: begin 0.12.9.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-08 09:39:56 -05:00
34 changed files with 1130 additions and 131 deletions

View File

@@ -2,6 +2,21 @@
<!-- insert new changelog below this comment --> <!-- insert new changelog below this comment -->
## [0.12.9] - 2026-07-09
### Changed
- fix(integrations): skip Windows Store python3 alias stub in resolve_python_interpreter (#3385)
- fix(integrations): escape control characters in SKILL.md frontmatter (#3399)
- fix(workflows): apply chained expression filters left-to-right (#3339)
- fix(scripts): resolve invoke_separator by parse success, not python3 availability (#3304) (#3320)
- fix(shared-infra): refresh_shared_templates preserves recovered user files (#3378)
- fix(agents): resolve skill placeholders in Goose (yaml) command output (#3374)
- fix(bundler): enforce version pin on bundled preset/extension installs (#3377)
- Update Golden Demo extension to v0.3.0 (#3394)
- test: isolate integration test home (#3144)
- chore: release 0.12.8, begin 0.12.9.dev0 development (#3410)
## [0.12.8] - 2026-07-08 ## [0.12.8] - 2026-07-08
### Changed ### Changed

View File

@@ -58,7 +58,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) | | Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |
| GitHub Issues Integration 1 | Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability | `integration` | Read+Write | [spec-kit-github-issues](https://github.com/Fatima367/spec-kit-github-issues) | | GitHub Issues Integration 1 | Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability | `integration` | Read+Write | [spec-kit-github-issues](https://github.com/Fatima367/spec-kit-github-issues) |
| GitHub Issues Integration 2 | Creates and syncs local specs from an existing GitHub issue | `integration` | Read+Write | [spec-kit-issue](https://github.com/aaronrsun/spec-kit-issue) | | GitHub Issues Integration 2 | Creates and syncs local specs from an existing GitHub issue | `integration` | Read+Write | [spec-kit-issue](https://github.com/aaronrsun/spec-kit-issue) |
| Golden Demo | Extracts acceptance criteria from specs, builds test vectors, and produces a behavioral drift report — complementary to Architecture Guard and CDD | `docs` | Read+Write | [spec-kit-golden-demo](https://github.com/jasstt/spec-kit-golden-demo) | | Golden Demo | Deterministic behavioral drift oracle. Extracts acceptance criteria, generates fuzz test vectors (seed=42), compares golden Python implementations against real code in any language. CI/CD gatekeeper with warn/strict modes. | `docs` | Read+Write | [spec-kit-golden-demo](https://github.com/jasstt/spec-kit-golden-demo) |
| Improve Extension | Audits any codebase as a senior advisor and writes prioritized, self-contained spec prompts under specs/ that the spec-kit lifecycle can process | `process` | Read+Write | [spec-kit-improve](https://github.com/d0whc3r/spec-kit-improve) | | Improve Extension | Audits any codebase as a senior advisor and writes prioritized, self-contained spec prompts under specs/ that the spec-kit lifecycle can process | `process` | Read+Write | [spec-kit-improve](https://github.com/d0whc3r/spec-kit-improve) |
| Intake | Normalize PRD, design, HTML SSOT, and test-case evidence into SDD-ready intake artifacts. | `docs` | Read+Write | [spec-kit-intake](https://github.com/bigsmartben/spec-kit-intake) | | Intake | Normalize PRD, design, HTML SSOT, and test-case evidence into SDD-ready intake artifacts. | `docs` | Read+Write | [spec-kit-intake](https://github.com/bigsmartben/spec-kit-intake) |
| Intelligent Agent Orchestrator | Cross-catalog agent discovery and intelligent prompt-to-command routing | `process` | Read+Write | [spec-kit-orchestrator](https://github.com/pragya247/spec-kit-orchestrator) | | Intelligent Agent Orchestrator | Cross-catalog agent discovery and intelligent prompt-to-command routing | `process` | Read+Write | [spec-kit-orchestrator](https://github.com/pragya247/spec-kit-orchestrator) |

View File

@@ -14,3 +14,5 @@ Community projects that extend, visualize, or build on Spec Kit:
- **[SpecKit Companion](https://marketplace.visualstudio.com/items?itemName=alfredoperez.speckit-companion)** — A VS Code extension that brings a visual GUI to Spec Kit. Browse specs in a rich markdown viewer with clickable file references, create specifications with image attachments, comment and refine each step inline (GitHub-style review), track your progress through the SDD workflow with a visual phase stepper, and manage steering documents like constitutions and templates. - **[SpecKit Companion](https://marketplace.visualstudio.com/items?itemName=alfredoperez.speckit-companion)** — A VS Code extension that brings a visual GUI to Spec Kit. Browse specs in a rich markdown viewer with clickable file references, create specifications with image attachments, comment and refine each step inline (GitHub-style review), track your progress through the SDD workflow with a visual phase stepper, and manage steering documents like constitutions and templates.
- **[cc-spec-kit](https://github.com/speckit-community/cc-spec-kit)** — Community-maintained plugin for Claude Code and GitHub Copilot CLI that installs Spec Kit skills via the plugin marketplace. - **[cc-spec-kit](https://github.com/speckit-community/cc-spec-kit)** — Community-maintained plugin for Claude Code and GitHub Copilot CLI that installs Spec Kit skills via the plugin marketplace.
- **[spectatui](https://github.com/tinesoft/spectatui)** — A terminal UI (TUI) dashboard for Spec Kit that lets you track features, manage specifications, integrations, presets, workflows, and extensions, and monitor AI agent workflows. Attach to existing AI sessions or launch new ones from your terminal. Keyboard and mouse support. Light/dark theme support. Customizable and performance-oriented. Requires the `specify` CLI in your PATH.

View File

@@ -15,7 +15,7 @@ The script reads the agent-context extension config at
- `context_files` — optional project-relative paths for multiple coding agent context files. When non-empty, the script updates each listed file and the list takes precedence over `context_file`. - `context_files` — optional project-relative paths for multiple coding agent context files. When non-empty, the script updates each listed file and the list takes precedence over `context_file`.
- `context_markers.start` / `.end` — the delimiters surrounding the managed section. Defaults to `<!-- SPECKIT START -->` and `<!-- SPECKIT END -->` when the field is missing. - `context_markers.start` / `.end` — the delimiters surrounding the managed section. Defaults to `<!-- SPECKIT START -->` and `<!-- SPECKIT END -->` when the field is missing.
It then creates, replaces, or appends the managed block so that the section points at the most recent plan path when one can be discovered (`specs/<feature>/plan.md`). It then creates, replaces, or appends the managed block so that the section points at the most recent plan path when one can be discovered (`specs/**/plan.md`, any depth).
If `context_files` and `context_file` are empty, the command reports nothing to do and exits successfully. Context file paths must stay project-relative; absolute paths, Windows drive paths, backslash separators, and `..` path segments are rejected. If `context_files` and `context_file` are empty, the command reports nothing to do and exits successfully. Context file paths must stay project-relative; absolute paths, Windows drive paths, backslash separators, and `..` path segments are rejected.
@@ -24,4 +24,4 @@ If `context_files` and `context_file` are empty, the command reports nothing to
- **Bash**: `.specify/extensions/agent-context/scripts/bash/update-agent-context.sh [plan_path]` - **Bash**: `.specify/extensions/agent-context/scripts/bash/update-agent-context.sh [plan_path]`
- **PowerShell**: `.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 [plan_path]` - **PowerShell**: `.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 [plan_path]`
When `plan_path` is omitted, the script auto-detects the most recently modified `specs/*/plan.md`. When `plan_path` is omitted, the script auto-detects the most recently modified `specs/**/plan.md` (any depth, so scoped layouts like `specs/<scope>/<feature>/plan.md` are found).

View File

@@ -12,7 +12,7 @@
# #
# When `plan_path` is omitted, the script derives it from `.specify/feature.json` # When `plan_path` is omitted, the script derives it from `.specify/feature.json`
# (written by /speckit-specify). Falls back to the most recently modified # (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. # `specs/**/plan.md` only when feature.json is absent or its plan does not exist yet.
set -euo pipefail set -euo pipefail
@@ -307,14 +307,14 @@ import sys
from pathlib import Path from pathlib import Path
root = Path(sys.argv[1]).resolve() root = Path(sys.argv[1]).resolve()
specs = root / "specs" specs = root / "specs"
plans = sorted( plan = max(
specs.glob("*/plan.md"), specs.glob("**/plan.md"),
key=lambda p: p.stat().st_mtime, key=lambda p: p.stat().st_mtime,
reverse=True, default=None,
) )
if plans: if plan:
try: try:
print(plans[0].relative_to(root).as_posix()) print(plan.relative_to(root).as_posix())
except ValueError: except ValueError:
print("") print("")
else: else:

View File

@@ -12,7 +12,7 @@
# #
# When `plan_path` is omitted, the script derives it from `.specify/feature.json` # When `plan_path` is omitted, the script derives it from `.specify/feature.json`
# (written by /speckit-specify). Falls back to the most recently modified # (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. # `specs/**/plan.md` only when feature.json is absent or its plan does not exist yet.
[CmdletBinding()] [CmdletBinding()]
param( param(
@@ -426,9 +426,7 @@ if (-not $PlanPath) {
if (-not $PlanPath) { if (-not $PlanPath) {
try { try {
$specsDir = Join-Path $ProjectRoot 'specs' $specsDir = Join-Path $ProjectRoot 'specs'
$candidate = Get-ChildItem -Path $specsDir -Directory -ErrorAction SilentlyContinue | $candidate = Get-ChildItem -Path $specsDir -Recurse -File -Filter 'plan.md' -ErrorAction SilentlyContinue |
ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName 'plan.md') -ErrorAction SilentlyContinue } |
Where-Object { $_ } |
Sort-Object LastWriteTime -Descending | Sort-Object LastWriteTime -Descending |
Select-Object -First 1 Select-Object -First 1
if ($candidate) { if ($candidate) {

View File

@@ -1,6 +1,6 @@
{ {
"schema_version": "1.0", "schema_version": "1.0",
"updated_at": "2026-07-06T00:00:00Z", "updated_at": "2026-07-07T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": { "extensions": {
"aide": { "aide": {
@@ -1398,10 +1398,10 @@
"golden-demo": { "golden-demo": {
"name": "Golden Demo", "name": "Golden Demo",
"id": "golden-demo", "id": "golden-demo",
"description": "Extracts acceptance criteria from specs, builds test vectors, and produces a behavioral drift report — complementary to Architecture Guard and CDD.", "description": "Deterministic behavioral drift oracle. Extracts acceptance criteria, generates fuzz test vectors (seed=42), compares golden Python implementations against real code in any language. CI/CD gatekeeper with warn/strict modes.",
"author": "jasstt", "author": "jasstt",
"version": "0.1.1", "version": "0.3.0",
"download_url": "https://github.com/jasstt/spec-kit-golden-demo/archive/refs/tags/v0.1.1.zip", "download_url": "https://github.com/jasstt/spec-kit-golden-demo/archive/refs/tags/v0.3.0.zip",
"repository": "https://github.com/jasstt/spec-kit-golden-demo", "repository": "https://github.com/jasstt/spec-kit-golden-demo",
"homepage": "https://github.com/jasstt/spec-kit-golden-demo", "homepage": "https://github.com/jasstt/spec-kit-golden-demo",
"documentation": "https://github.com/jasstt/spec-kit-golden-demo", "documentation": "https://github.com/jasstt/spec-kit-golden-demo",
@@ -1412,13 +1412,16 @@
"speckit_version": ">=0.1.0" "speckit_version": ">=0.1.0"
}, },
"provides": { "provides": {
"commands": 2, "commands": 3,
"hooks": 2 "hooks": 2
}, },
"tags": [ "tags": [
"testing", "testing",
"drift-detection", "drift-detection",
"behavioral-oracle", "behavioral-oracle",
"fuzzing",
"ci-cd",
"cross-language",
"tdd", "tdd",
"quality" "quality"
], ],
@@ -1426,7 +1429,7 @@
"downloads": 0, "downloads": 0,
"stars": 0, "stars": 0,
"created_at": "2026-06-24T00:00:00Z", "created_at": "2026-06-24T00:00:00Z",
"updated_at": "2026-06-24T00:00:00Z" "updated_at": "2026-07-07T00:00:00Z"
}, },
"harness": { "harness": {
"name": "Research Harness", "name": "Research Harness",

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "specify-cli" name = "specify-cli"
version = "0.12.8" version = "0.12.10.dev0"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"

View File

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

View File

@@ -141,8 +141,9 @@ def _install_shared_infra(
Copies ``.specify/scripts/<variant>/`` and ``.specify/templates/`` from Copies ``.specify/scripts/<variant>/`` and ``.specify/templates/`` from
the bundled core_pack or source checkout, where ``<variant>`` is the bundled core_pack or source checkout, where ``<variant>`` is
``bash`` when *script_type* is ``"sh"`` and ``powershell`` when it is ``bash`` when *script_type* is ``"sh"``, ``python`` when it is ``"py"``,
``"ps"``. Tracks all installed files in ``speckit.manifest.json``. and ``powershell`` when it is ``"ps"``. Tracks all installed files in
``speckit.manifest.json``.
Shared scripts and page templates are processed to resolve Shared scripts and page templates are processed to resolve
``__SPECKIT_COMMAND_<NAME>__`` placeholders using *invoke_separator* ``__SPECKIT_COMMAND_<NAME>__`` placeholders using *invoke_separator*

View File

@@ -701,6 +701,12 @@ class CommandRegistrar:
) )
output = self.render_toml_command(frontmatter, body, source_id) output = self.render_toml_command(frontmatter, body, source_id)
elif agent_config["format"] == "yaml": elif agent_config["format"] == "yaml":
body = self.resolve_skill_placeholders(
agent_name, frontmatter, body, project_root
)
body = self._convert_argument_placeholder(
body, "$ARGUMENTS", agent_config["args"]
)
output = self.render_yaml_command( output = self.render_yaml_command(
frontmatter, body, source_id, cmd_name frontmatter, body, source_id, cmd_name
) )

View File

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

View File

@@ -17,6 +17,7 @@ import os
import re import re
import shlex import shlex
import shutil import shutil
import subprocess
import sys import sys
from abc import ABC from abc import ABC
from dataclasses import dataclass from dataclasses import dataclass
@@ -54,6 +55,18 @@ _CORE_COMMAND_TEMPLATE_RANK = {
} }
def yaml_quote(value: str) -> str:
"""Emit *value* as a double-quoted YAML scalar on a single line.
A hand-rolled quote cannot carry raw newlines (YAML folds them to
spaces) or control characters (the reader rejects them), so let the
YAML emitter produce the escapes.
"""
return yaml.safe_dump(
str(value), default_style='"', allow_unicode=True, width=sys.maxsize
).strip()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# IntegrationOption # IntegrationOption
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -592,10 +605,42 @@ class IntegrationBase(ABC):
if candidate.exists(): if candidate.exists():
return relative return relative
for name in ("python3", "python"): for name in ("python3", "python"):
if shutil.which(name): found = shutil.which(name)
return name if not found:
continue
# On Windows, python3/python on PATH may be the Microsoft
# Store App Execution Alias stub: it exists but only prints
# an installer hint and exits non-zero, so existence is not
# enough (see #3304 for the same defect in the sh scripts).
if sys.platform == "win32" and not IntegrationBase._interpreter_runs(
found
):
continue
return name
return sys.executable or "python3" return sys.executable or "python3"
@staticmethod
def _interpreter_runs(path: str) -> bool:
"""Return True when *path* executes as a Python interpreter.
Runs isolated (``-I``) without ``site`` (``-S``) and discards
I/O so the probe is a fast liveness check that cannot trigger
``sitecustomize``/user startup hooks.
"""
try:
return (
subprocess.run(
[path, "-I", "-S", "-c", ""],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=15,
).returncode
== 0
)
except (OSError, subprocess.SubprocessError):
return False
@staticmethod @staticmethod
def process_template( def process_template(
content: str, content: str,
@@ -1077,7 +1122,6 @@ class TomlIntegration(IntegrationBase):
# YamlIntegration — YAML-format agents (Goose) # YamlIntegration — YAML-format agents (Goose)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class YamlIntegration(IntegrationBase): class YamlIntegration(IntegrationBase):
"""Concrete base for integrations that use YAML recipe format. """Concrete base for integrations that use YAML recipe format.
@@ -1480,21 +1524,17 @@ class SkillsIntegration(IntegrationBase):
if not description: if not description:
description = f"Spec Kit: {command_name} workflow" description = f"Spec Kit: {command_name} workflow"
# Build SKILL.md with manually formatted frontmatter to match # Build SKILL.md with manually formatted frontmatter (stable
# the release packaging script output exactly (double-quoted # double-quoted values). yaml_quote escapes newlines and control
# values, no yaml.safe_dump quoting differences). # characters that a plain quoted f-string cannot carry.
def _quote(v: str) -> str:
escaped = v.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
skill_content = ( skill_content = (
f"---\n" f"---\n"
f"name: {_quote(skill_name)}\n" f"name: {yaml_quote(skill_name)}\n"
f"description: {_quote(description)}\n" f"description: {yaml_quote(description)}\n"
f"compatibility: {_quote('Requires spec-kit project structure with .specify/ directory')}\n" f"compatibility: {yaml_quote('Requires spec-kit project structure with .specify/ directory')}\n"
f"metadata:\n" f"metadata:\n"
f" author: {_quote('github-spec-kit')}\n" f" author: {yaml_quote('github-spec-kit')}\n"
f" source: {_quote('templates/commands/' + src_file.name)}\n" f" source: {yaml_quote('templates/commands/' + src_file.name)}\n"
f"---\n" f"---\n"
f"{processed_body}" f"{processed_body}"
) )

View File

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

View File

@@ -328,7 +328,10 @@ def refresh_shared_templates(
_ensure_safe_shared_destination(project_path, dst) _ensure_safe_shared_destination(project_path, dst)
rel = dst.relative_to(project_path).as_posix() rel = dst.relative_to(project_path).as_posix()
if dst.exists() and not force: if dst.exists() and not force:
if rel not in tracked_files or rel in modified: if rel not in tracked_files or rel in modified or manifest.is_recovered(rel):
# Never overwrite a recovered (pre-existing user) file without
# --force, matching install_shared_infra's is_recovered gate
# (#2918). Without this, refresh clobbers user content.
skipped_files.append(rel) skipped_files.append(rel)
continue continue
@@ -344,7 +347,7 @@ def refresh_shared_templates(
if skipped_files: if skipped_files:
console.print( console.print(
f"[yellow]⚠[/yellow] {len(skipped_files)} modified or untracked shared template file(s) were not updated:" f"[yellow]⚠[/yellow] {len(skipped_files)} modified, untracked, or preserved (recovered) shared template file(s) were not updated:"
) )
for rel in skipped_files: for rel in skipped_files:
console.print(f" {rel}") console.print(f" {rel}")
@@ -400,7 +403,7 @@ def install_shared_infra(
# manifest entries the core no longer ships (stale-script cleanup, #3076). # manifest entries the core no longer ships (stale-script cleanup, #3076).
seen_rels: set[str] = set() seen_rels: set[str] = set()
scripts_scanned = False scripts_scanned = False
variant_dir = "bash" if script_type == "sh" else "powershell" variant_dir = {"sh": "bash", "py": "python"}.get(script_type, "powershell")
def _decide_overwrite(rel: str, dst: Path) -> tuple[bool, str | None]: def _decide_overwrite(rel: str, dst: Path) -> tuple[bool, str | None]:
"""Return (write, bucket) where bucket is 'skip', 'preserved', or None.""" """Return (write, bucket) where bucket is 'skip', 'preserved', or None."""
@@ -462,6 +465,10 @@ def install_shared_infra(
for src_path in variant_src.rglob("*"): for src_path in variant_src.rglob("*"):
if not src_path.is_file(): if not src_path.is_file():
continue continue
# Python bytecode caches are local artifacts, not
# workflow scripts — never install them.
if "__pycache__" in src_path.parts:
continue
# Mark scanned only once a real source file is seen. An # Mark scanned only once a real source file is seen. An
# empty (or symlink-skipped) variant keeps this False, so # empty (or symlink-skipped) variant keeps this False, so
# stale-cleanup is skipped — otherwise it would treat every # stale-cleanup is skipped — otherwise it would treat every

View File

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

View File

@@ -25,6 +25,14 @@ class ShellStep(StepBase):
run_cmd = str(run_cmd) run_cmd = str(run_cmd)
cwd = context.project_root or "." cwd = context.project_root or "."
# Defensive: the engine does not auto-validate step config, so an
# invalid ``timeout`` (string, None, ...) would otherwise raise a
# TypeError from subprocess.run() and crash the whole run. Mirror
# the engine's handling of unvalidated ``continue_on_error`` by
# only honoring well-formed values and falling back to the default.
timeout = config.get("timeout", 300)
if isinstance(timeout, bool) or not isinstance(timeout, int) or timeout <= 0:
timeout = 300
# NOTE: shell=True is required to support pipes, redirects, and # NOTE: shell=True is required to support pipes, redirects, and
# multi-command expressions in workflow YAML. Workflow authors # multi-command expressions in workflow YAML. Workflow authors
@@ -37,7 +45,7 @@ class ShellStep(StepBase):
capture_output=True, capture_output=True,
text=True, text=True,
cwd=cwd, cwd=cwd,
timeout=300, timeout=timeout,
) )
output = { output = {
"exit_code": proc.returncode, "exit_code": proc.returncode,
@@ -74,7 +82,7 @@ class ShellStep(StepBase):
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return StepResult( return StepResult(
status=StepStatus.FAILED, status=StepStatus.FAILED,
error="Shell command timed out after 300 seconds.", error=f"Shell command timed out after {timeout} seconds.",
output={"exit_code": -1, "stdout": "", "stderr": "timeout"}, output={"exit_code": -1, "stdout": "", "stderr": "timeout"},
) )
except OSError as exc: except OSError as exc:
@@ -106,4 +114,16 @@ class ShellStep(StepBase):
f"Shell step {config.get('id', '?')!r}: 'output_format' must " f"Shell step {config.get('id', '?')!r}: 'output_format' must "
f"be 'json' when present, got {output_format!r}." f"be 'json' when present, got {output_format!r}."
) )
if "timeout" in config:
timeout = config["timeout"]
# bool is an int subclass, so reject it explicitly.
if (
isinstance(timeout, bool)
or not isinstance(timeout, int)
or timeout <= 0
):
errors.append(
f"Shell step {config.get('id', '?')!r}: 'timeout' must be a "
f"positive integer (seconds) when present, got {timeout!r}."
)
return errors return errors

View File

@@ -3,6 +3,7 @@ description: Perform a non-destructive cross-artifact consistency and quality an
scripts: scripts:
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
--- ---
## User Input ## User Input

View File

@@ -3,6 +3,7 @@ description: Generate a custom checklist for the current feature based on user r
scripts: scripts:
sh: scripts/bash/check-prerequisites.sh --json sh: scripts/bash/check-prerequisites.sh --json
ps: scripts/powershell/check-prerequisites.ps1 -Json ps: scripts/powershell/check-prerequisites.ps1 -Json
py: scripts/python/check_prerequisites.py --json
--- ---
## Checklist Purpose: "Unit Tests for English" ## Checklist Purpose: "Unit Tests for English"

View File

@@ -7,6 +7,7 @@ handoffs:
scripts: scripts:
sh: scripts/bash/check-prerequisites.sh --json --paths-only sh: scripts/bash/check-prerequisites.sh --json --paths-only
ps: scripts/powershell/check-prerequisites.ps1 -Json -PathsOnly ps: scripts/powershell/check-prerequisites.ps1 -Json -PathsOnly
py: scripts/python/check_prerequisites.py --json --paths-only
--- ---
## User Input ## User Input

View File

@@ -3,6 +3,7 @@ description: Assess the current codebase against the feature's spec, plan, and t
scripts: scripts:
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
--- ---
## User Input ## User Input

View File

@@ -3,6 +3,7 @@ description: Execute the implementation plan by processing and executing all tas
scripts: scripts:
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
--- ---
## User Input ## User Input

View File

@@ -4,6 +4,7 @@ tools: ['github/github-mcp-server/list_issues', 'github/github-mcp-server/issue_
scripts: scripts:
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
--- ---
## User Input ## User Input

View File

@@ -688,6 +688,62 @@ class TestExtensionSelfSeed:
_MDC_CONTEXT_FILE = ".cursor/rules/specify-rules.mdc" _MDC_CONTEXT_FILE = ".cursor/rules/specify-rules.mdc"
class TestPlanDiscovery:
"""Mtime fallback must find plans in nested spec layouts (#3024).
Repos using SPECIFY_FEATURE_DIRECTORY place plans at
``specs/<scope>/<feature>/plan.md``; a one-level ``specs/*/plan.md``
glob never matches those.
"""
@staticmethod
def _make_plans(project: Path) -> Path:
# Older flat plan plus a newer nested plan: recursive discovery
# must pick the nested one by mtime.
flat = project / "specs" / "old-feature" / "plan.md"
flat.parent.mkdir(parents=True)
flat.write_text("flat plan\n", encoding="utf-8")
os.utime(flat, (1_000_000_000, 1_000_000_000))
nested = project / "specs" / "scope" / "new-feature" / "plan.md"
nested.parent.mkdir(parents=True)
nested.write_text("nested plan\n", encoding="utf-8")
return nested
@requires_bash
def test_bash_script_finds_nested_plan(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=["AGENTS.md"],
)
self._make_plans(project)
result = _run_bash_agent_context_script(project)
assert result.returncode == 0, result.stderr + result.stdout
content = (project / "AGENTS.md").read_text(encoding="utf-8")
assert "specs/scope/new-feature/plan.md" in content
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
def test_powershell_script_finds_nested_plan(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=["AGENTS.md"],
)
self._make_plans(project)
result = _run_powershell_agent_context_script(project)
assert result.returncode == 0, result.stderr + result.stdout
content = (project / "AGENTS.md").read_text(encoding="utf-8")
assert "specs/scope/new-feature/plan.md" in content
class TestMdcFrontmatter: class TestMdcFrontmatter:
"""Cursor-style ``.mdc`` targets must carry ``alwaysApply: true`` frontmatter """Cursor-style ``.mdc`` targets must carry ``alwaysApply: true`` frontmatter
so the rule file is auto-loaded; non-``.mdc`` targets must not gain any.""" so the rule file is auto-loaded; non-``.mdc`` targets must not gain any."""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,107 @@
"""Command templates with a py: script line must render for --script py.
Covers #3283: ``py:`` lines in the ``scripts:`` frontmatter of
``templates/commands/*.md`` reference Python scripts that exist in the repo,
and ``process_template`` turns them into a valid Python invocation
(interpreter-prefixed, path rewritten to the ``.specify`` tree).
``plan.md`` and ``tasks.md`` gain their ``py:`` lines together with
``setup_plan.py``/``setup_tasks.py`` in the core-scripts port (#3280); the
existence check below enforces that ordering.
"""
import re
from pathlib import Path
import pytest
from specify_cli.integrations.base import IntegrationBase
REPO_ROOT = Path(__file__).parent.parent
TEMPLATES_DIR = REPO_ROOT / "templates" / "commands"
_PY_LINE = re.compile(r"^\s*py: (scripts/python/\S+\.py)", re.MULTILINE)
def _py_script(name: str) -> str | None:
match = _PY_LINE.search((TEMPLATES_DIR / name).read_text(encoding="utf-8"))
return match.group(1) if match else None
PY_TEMPLATES = sorted(
p.name for p in TEMPLATES_DIR.glob("*.md") if _py_script(p.name)
)
@pytest.fixture(autouse=True)
def _pin_interpreter(monkeypatch):
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which",
lambda name: "/usr/bin/python3" if name == "python3" else None,
)
# On Windows, ``resolve_python_interpreter`` guards the ``which`` result
# with a real ``_interpreter_runs`` subprocess probe (#3304). The mocked
# ``/usr/bin/python3`` path does not exist on a Windows runner, so the
# probe would fail and the resolver would fall back to ``sys.executable``
# (a ``...python.exe`` path), breaking the ``python3``-anchored assertion.
# Pin the probe to True so the interpreter token stays ``python3`` on all
# platforms.
monkeypatch.setattr(
"specify_cli.integrations.base.IntegrationBase._interpreter_runs",
staticmethod(lambda path: True),
)
def test_py_templates_discovered():
# Guard: the glob must find the known py-scripted templates, otherwise
# the parametrized tests below would silently pass on an empty set.
assert "implement.md" in PY_TEMPLATES
assert "clarify.md" in PY_TEMPLATES
@pytest.mark.parametrize("name", PY_TEMPLATES)
def test_referenced_python_script_exists(name: str):
# A py: line must never point at a script the repo does not ship —
# rendering would produce a broken invocation at runtime.
script = _py_script(name)
assert (REPO_ROOT / script).is_file(), f"{name} references missing {script}"
@pytest.mark.parametrize("name", PY_TEMPLATES)
def test_template_renders_python_invocation(name: str):
content = (TEMPLATES_DIR / name).read_text(encoding="utf-8")
result = IntegrationBase.process_template(content, "agent", "py")
assert "{SCRIPT}" not in result
assert re.search(
r"python3 \.specify/scripts/python/\w+\.py(?: --[\w-]+)*", result
), f"{name} did not render a Python invocation"
@pytest.mark.parametrize("name", PY_TEMPLATES)
def test_sh_rendering_unchanged(name: str):
# Negative: adding py: lines must not leak into sh rendering.
content = (TEMPLATES_DIR / name).read_text(encoding="utf-8")
result = IntegrationBase.process_template(content, "agent", "sh")
assert "{SCRIPT}" not in result
assert "scripts/python" not in result
def test_install_shared_infra_copies_python_scripts(tmp_path):
# --script py must install scripts/python/ into .specify/scripts/python/
# so the rendered invocations point at files that exist.
from rich.console import Console
from specify_cli.shared_infra import install_shared_infra
install_shared_infra(
tmp_path,
"py",
version="0.0.0",
core_pack=None,
repo_root=REPO_ROOT,
console=Console(quiet=True),
force=False,
)
dest = tmp_path / ".specify" / "scripts" / "python"
assert (dest / "check_prerequisites.py").is_file()
assert not (tmp_path / ".specify" / "scripts" / "powershell").exists()

View File

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

View File

@@ -601,6 +601,73 @@ class TestExpressions:
): ):
evaluate_expression("{{ inputs.tags | map }}", ctx) evaluate_expression("{{ inputs.tags | map }}", ctx)
def test_chained_filters_apply_left_to_right(self):
# Filters chain: each filter's result feeds the next. `map` yields a
# list and `join` is the only filter that renders a list to a string,
# so `map('name') | join(', ')` is the canonical pairing — it must not
# raise. Previously the pipe parser split only at the first `|` and
# handed the whole tail (`map('name') | join(', ')`) to one filter,
# which the `name(arg)` regex mangled into a ValueError.
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(
inputs={
"rows": [{"name": "a"}, {"name": "b"}],
"tags": ["x", "y"],
"missing": None,
}
)
assert (
evaluate_expression(
"{{ inputs.rows | map('name') | join(', ') }}", ctx
)
== "a, b"
)
# A three-link chain: map -> join -> contains.
assert (
evaluate_expression(
"{{ inputs.rows | map('name') | join(', ') | contains('a') }}",
ctx,
)
is True
)
# default's fallback then flows into the next filter.
assert (
evaluate_expression(
"{{ inputs.missing | default('x') | contains('x') }}", ctx
)
is True
)
def test_chained_filter_error_in_later_link_raises(self):
# A mis-wired filter anywhere in the chain must fail loudly, not just
# the first link.
import pytest
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(inputs={"rows": [{"name": "a"}]})
with pytest.raises(ValueError, match="unknown filter 'bogus'"):
evaluate_expression(
"{{ inputs.rows | map('name') | bogus }}", ctx
)
def test_pipe_in_quoted_arg_is_not_a_filter_separator(self):
# A literal `|` inside a quoted operand or filter argument must not be
# mistaken for a filter-chain separator — the top-level split has to
# respect quotes.
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(inputs={"mode": "a|b", "tags": ["a|b", "c"]})
assert evaluate_expression("{{ inputs.mode == 'a|b' }}", ctx) is True
# `|` inside a filter argument stays part of the argument.
assert (
evaluate_expression("{{ inputs.tags | join(' | ') }}", ctx)
== "a|b | c"
)
def test_condition_evaluation(self): def test_condition_evaluation(self):
from specify_cli.workflows.expressions import evaluate_condition from specify_cli.workflows.expressions import evaluate_condition
from specify_cli.workflows.base import StepContext from specify_cli.workflows.base import StepContext
@@ -1286,6 +1353,106 @@ class TestShellStep:
assert step.validate({"id": "s", "run": "echo hi"}) == [] assert step.validate({"id": "s", "run": "echo hi"}) == []
assert step.validate({"id": "s", "run": "{{ steps.x.output }}"}) == [] assert step.validate({"id": "s", "run": "{{ steps.x.output }}"}) == []
def test_timeout_is_configurable(self, monkeypatch):
"""A 'timeout' field overrides the 300s default (#3327)."""
import subprocess as sp
from specify_cli.workflows.steps.shell import ShellStep
from specify_cli.workflows.base import StepContext, StepStatus
seen = {}
real_run = sp.run
def spy_run(*args, **kwargs):
seen["timeout"] = kwargs.get("timeout")
return real_run(*args, **kwargs)
monkeypatch.setattr(
"specify_cli.workflows.steps.shell.subprocess.run", spy_run
)
step = ShellStep()
result = step.execute(
{"id": "t", "run": "echo hi", "timeout": 1800}, StepContext()
)
assert result.status == StepStatus.COMPLETED
assert seen["timeout"] == 1800
def test_timeout_defaults_to_300(self, monkeypatch):
import subprocess as sp
from specify_cli.workflows.steps.shell import ShellStep
from specify_cli.workflows.base import StepContext, StepStatus
seen = {}
real_run = sp.run
def spy_run(*args, **kwargs):
seen["timeout"] = kwargs.get("timeout")
return real_run(*args, **kwargs)
monkeypatch.setattr(
"specify_cli.workflows.steps.shell.subprocess.run", spy_run
)
result = ShellStep().execute({"id": "t", "run": "echo hi"}, StepContext())
assert result.status == StepStatus.COMPLETED
assert seen["timeout"] == 300
def test_timeout_error_reports_configured_value(self, monkeypatch):
import subprocess as sp
from specify_cli.workflows.steps.shell import ShellStep
from specify_cli.workflows.base import StepContext, StepStatus
def raise_timeout(*args, **kwargs):
raise sp.TimeoutExpired(cmd="x", timeout=kwargs.get("timeout"))
monkeypatch.setattr(
"specify_cli.workflows.steps.shell.subprocess.run", raise_timeout
)
result = ShellStep().execute(
{"id": "t", "run": "sleep 999", "timeout": 7}, StepContext()
)
assert result.status == StepStatus.FAILED
assert "7 seconds" in result.error
@pytest.mark.parametrize("bad", [0, -5, "600", 1.5, None, True])
def test_execute_ignores_unvalidated_bad_timeout(self, bad, monkeypatch):
"""execute() falls back to 300 when config skipped validation (#3327)."""
import subprocess as sp
from specify_cli.workflows.steps.shell import ShellStep
from specify_cli.workflows.base import StepContext, StepStatus
seen = {}
real_run = sp.run
def spy_run(*args, **kwargs):
seen["timeout"] = kwargs.get("timeout")
return real_run(*args, **kwargs)
monkeypatch.setattr(
"specify_cli.workflows.steps.shell.subprocess.run", spy_run
)
result = ShellStep().execute(
{"id": "t", "run": "echo hi", "timeout": bad}, StepContext()
)
assert result.status == StepStatus.COMPLETED
assert seen["timeout"] == 300
@pytest.mark.parametrize("bad", [0, -5, "600", 1.5, None, True])
def test_validate_rejects_bad_timeout(self, bad):
from specify_cli.workflows.steps.shell import ShellStep
errors = ShellStep().validate({"id": "s", "run": "echo hi", "timeout": bad})
assert any("'timeout'" in e for e in errors)
def test_validate_accepts_positive_int_timeout(self):
from specify_cli.workflows.steps.shell import ShellStep
assert (
ShellStep().validate({"id": "s", "run": "echo hi", "timeout": 1800}) == []
)
def test_output_format_json_exposes_data(self, tmp_path): def test_output_format_json_exposes_data(self, tmp_path):
from specify_cli.workflows.steps.shell import ShellStep from specify_cli.workflows.steps.shell import ShellStep

View File

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