Compare commits

...

41 Commits

Author SHA1 Message Date
github-actions[bot]
e802a7dd52 chore: bump version to 0.12.11 2026-07-10 19:28:01 +00:00
Noor ul ain
126e56882b fix(agent-context): discover nested plan.md in scoped layouts (#3024) (#3301)
* fix(agent-context): discover nested plan.md in scoped layouts (#3024)

The agent-context updater only looked for plan.md one level deep
(specs/*/plan.md), so scoped layouts created via SPECIFY_FEATURE_DIRECTORY
(specs/<scope>/<feature>/plan.md) were never picked up and no plan
reference was written into the context file.

Recurse into specs/ in both the bash (rglob) and PowerShell (-Recurse)
scripts. In the PowerShell script, also replace
[System.IO.Path]::GetRelativePath, which is .NET Core 2.1+ only and throws
under Windows PowerShell 5.1 (.NET Framework); the exception was swallowed
by the surrounding try/catch, leaving the plan path empty on 5.1 even when
a plan was found. Compute the project-relative path by stripping the root
prefix instead.

Add regression tests for both scripts covering nested discovery.

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>

* fix(agent-context): guard mtime plan discovery against symlink escape

Address Copilot review feedback on #3301:

- bash updater: the mtime fallback filtered candidates lexically via
  relative_to() on the *unresolved* path, so a plan reached through a
  specs/ symlink pointing outside the project could be selected and emit
  an in-project-looking path. Resolve each candidate and keep only those
  whose resolved path stays under root before picking the newest.
- test: the nested-plan PowerShell regression targets a Windows
  PowerShell 5.1 (.NET Framework) failure mode, but ran whatever
  POWERSHELL resolved to (prefers pwsh). Prefer powershell.exe on Windows
  so the 5.1-only compat fix is actually exercised.

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

* docs(agent-context): note recursive plan.md discovery in update command

Auto-detection now recurses (`specs/**/plan.md`) to support nested scoped layouts created via SPECIFY_FEATURE_DIRECTORY (#3024). The update command doc still described the old one-level `specs/*/plan.md` glob, which could mislead users troubleshooting plan detection. Addresses Copilot review feedback on PR #3301.

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-10 14:21:43 -05:00
Quratulain-bilal
74d03a2814 fix(auth): return no matches, not raw ValueError, for a malformed URL (#3437)
find_entries_for_url did (urlparse(url).hostname or "").lower() unguarded. a
malformed authority (e.g. an unterminated ipv6 bracket "https://[::1") makes
urlparse/hostname raise ValueError, so instead of the empty list the function
already returns for a host-less url, a raw ValueError leaked out of the shared
http client (build_request / open_url call this before any url validation).

no auth entry can match such a url, so treat it like the host-less case and
return no matches. added a regression test over an unterminated bracket and a
bracketed non-ip host; confirmed it fails on the pre-fix code.
2026-07-10 13:45:44 -05:00
Quratulain-bilal
14fa3ada08 fix(catalogs): raise catalog error, not raw ValueError, on a malformed URL (#3435)
CatalogStackBase._validate_catalog_url read parsed.hostname, which raises
ValueError on a malformed authority (e.g. an unclosed ipv6 bracket
"https://[::1"). every other reject path raises the class catalog error, so
the raw ValueError leaked to the caller. wrap the parse + hostname access and
convert ValueError to the normal error via cls._error.

twin of the bundler fix in adapters._validate_remote_url.
2026-07-10 13:39:01 -05:00
Quratulain-bilal
1736f0746b fix(bundler): raise BundlerError, not raw ValueError, on a malformed catalog URL (#3433)
adapters._validate_remote_url reads parsed.hostname, which raises ValueError
on a malformed authority (e.g. an unclosed ipv6 bracket https://[::1). the
function's contract is to raise BundlerError for any bad url - every other
reject path does - so the raw ValueError leaked to the caller and crashed the
fetch instead of failing cleanly. wrap the parse and convert to BundlerError.

bundler sibling of #3369, which fixed the cli extension/preset/workflow add
paths but not this validator. added a regression test that fails pre-fix.
2026-07-10 11:57:53 -05:00
Vincent Lee
c8ce488073 chore: add pre-commit config and fix trailing whitespace/end-of-file (#3430)
issues
2026-07-10 11:47:36 -05:00
github-actions[bot]
983a87f3e3 Add EARS Requirements Syntax extension to community catalog (#3407)
Add ears extension submitted by @v-dhruv to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #3395

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>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-10 09:56:24 -05:00
github-actions[bot]
e3989e3572 Add Spec Kit Figma extension to community catalog (#3408)
Add figma extension submitted by @sebastienthibaud to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #3396

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>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-10 09:53:36 -05:00
Marsel Safin
34514fb20a fix(workflows): report validation errors instead of crashing on non-string workflow.yml scalars (#3421)
* fix(workflows): validate scalar types before string operations in workflow validation

YAML parses unquoted scalars like version: 1.0 and id: 123 as
float/int, which crashed validate_workflow and workflow add with raw
tracebacks. Type-check id, name, version and step ids before regex
and string operations so these surface as validation errors. Accept
an unquoted schema_version: 1.0 instead of printing a self-identical
rejection message.

Fixes #3420

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

* fix(workflows): treat falsey non-strings as type errors, not missing fields

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

* fix(workflows): only accept schema_version 1.0 so the error message is accurate

The check also accepted "1" while the error said Expected '1.0'.
Unquoted YAML 1.0 still works via str(); plain 1 is now rejected with
the message that matches.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-10 09:51:19 -05:00
Marsel Safin
87a9690cf9 fix(templates): remove self-referencing path in plan-template.md note (#3417)
The note told readers to "See .specify/templates/plan-template.md for
the execution workflow" — that path is the file itself. The execution
workflow lives in the plan command's definition, which the note already
names via the __SPECKIT_COMMAND_PLAN__ placeholder. Point there instead
of at a self-reference.

Fixes #1148

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-10 09:45:31 -05:00
Manfred Riem
b58ffba000 chore: release 0.12.10, begin 0.12.11.dev0 development (#3453)
* chore: bump version to 0.12.10

* chore: begin 0.12.11.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-10 08:14:48 -05:00
dependabot[bot]
f537dfb2ac chore(deps): bump astral-sh/setup-uv from 8.2.0 to 8.3.2 (#3439)
Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 8.2.0 to 8.3.2.
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](fac544c07d...11f9893b08)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: 8.3.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-10 08:05:40 -05:00
dependabot[bot]
43ac4c158c chore(deps): bump DavidAnson/markdownlint-cli2-action (#3438)
Bumps [DavidAnson/markdownlint-cli2-action](https://github.com/davidanson/markdownlint-cli2-action) from 23.2.0 to 24.0.0.
- [Release notes](https://github.com/davidanson/markdownlint-cli2-action/releases)
- [Commits](ded1f9488f...8de2aa07ca)

---
updated-dependencies:
- dependency-name: DavidAnson/markdownlint-cli2-action
  dependency-version: 24.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-10 08:03:35 -05:00
Marsel Safin
d035a3f039 fix(templates): correct phase numbering in plan.md (#3416)
The plan command's outline listed two bullets labeled Phase 1 and the
completion report said the command ends after "Phase 2 planning",
but the Phases section only defines Phase 0 and Phase 1. Phase 2
(tasks.md) belongs to the tasks command, as plan-template.md states.

The duplicated "Phase 1: Update agent context" bullet is a leftover
from before the agent-context extension: core no longer ships an agent
script, and the update runs via the extension's after_plan hook.

Fixes #1036

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-10 07:48:06 -05:00
Noor ul ain
dbefc66acb fix(git-ext): honor explicit -Number 0 in PowerShell branch creation (#3412)
`-Number` defaults to 0, so the previous `-eq 0` / `-ne 0` checks could
not distinguish an unset flag from an explicit `-Number 0`: a user
requesting branch `000-...` was silently routed into auto-detection.
Switch both checks to `$PSBoundParameters.ContainsKey('Number')`, which
tests whether the flag was actually supplied — mirroring the bash twin's
empty-string sentinel (`[ -z "$BRANCH_NUMBER" ]` / `[ -n ... ]`).

Add a parity regression test to both TestCreateFeatureBash and
TestCreateFeaturePowerShell asserting `--number 0` / `-Number 0` yields
`000-zero`.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 07:45:09 -05:00
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
github-actions[bot]
0da969df14 [extension] Add LLM Wiki extension to community catalog (#3361)
* Add LLM Wiki extension to community catalog

Add wiki extension submitted by @formin to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #3319

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: limit catalog.community.json changes to wiki entry + timestamps only

Reverts the unintended reordering and reformatting of existing extensions
(aide, checkpoint, critique, threatmodel, etc.) and companion's tools array.
Only the new wiki entry and updated_at timestamps are now changed.

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>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-08 08:29:02 -05:00
Dyan Galih
13d2cca154 Docs: Document missing CLI flags and integrations (#3182)
* docs: document missing flags and integrations

* docs: remove invalid --refresh-shared-infra from upgrade command

* docs: address PR feedback for extension and integration flags

* docs: reorder extension add options to match CLI help
2026-07-08 07:49:42 -05:00
Dyan Galih
ba1ce366b7 Docs: Remove Cursor from CLI check list in README (#3184)
* docs: reword CLI check behavior to remove exhaustive list of tools

* docs: clarify conditional CLI tool installation check
2026-07-08 07:48:08 -05:00
Marsel Safin
295eb221e3 feat(extensions): port update-agent-context to Python (#3387)
* feat(extensions): port update-agent-context to Python

Ports the agent-context extension updater to a single Python script,
per #3281 and the check-prerequisites PoC pattern from #3302. The bash
version already ran its core logic through embedded Python heredocs, so
the port lifts that logic into a standalone script. Parity tests run
bash and Python side by side and compare output and resulting
context-file bytes.

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

* fix(extensions): match bash case-insensitivity on MSYS, test unparseable config gate

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-08 07:46:29 -05:00
Quratulain-bilal
5a901a698b fix(scripts): fall through to grep/sed when python3 is a broken stub in feature.json parser (#3312)
* fix(scripts): fall through to grep/sed when python3 is a broken stub in feature.json parser

read_feature_json_feature_directory picked its json parser by availability
(if jq / elif python3 / else grep-sed). on windows `python3` usually resolves
to the microsoft store app execution alias stub: it satisfies `command -v` but
fails at runtime (exit 49). the elif selected it, the runtime failure was
swallowed to _fd='', and the grep/sed last resort was never reached - so a
valid .specify/feature.json read as empty and every setup-plan / setup-tasks /
check-prerequisites call errored with "Feature directory not found" right after
a successful `specify init --script sh`.

change selection from availability to parse success: try jq, then python3 only
if still empty, then grep/sed only if still empty. a parser that exists but
produces nothing now falls through instead of terminating the chain.

the write path (_persist_feature_json) already uses jq-or-printf with no
python3, so it was unaffected; only the read path needed this.

add a regression test that puts a python3 stub (exit 49, like the store alias)
first on PATH and asserts setup-plan.sh still resolves the feature via the
grep/sed fallback.

fixes #3304

* test: shadow jq so the broken-python3 fallback test actually exercises it

the test claimed it dropped jq so the parser chain would reach python3 and
then grep/sed, but it only prepended the python3 stub dir to PATH. on a
runner with jq installed, read_feature_json_feature_directory parses via jq
and never reaches the fallback the test is meant to cover.

add a failing jq stub alongside the python3 stub so the chain is forced
through jq -> python3 -> grep/sed regardless of what the runner has installed.
2026-07-08 07:45:01 -05:00
Quratulain-bilal
94c7ec288f fix(toml): escape control characters so generated command files parse (#3341)
* fix(toml): escape control characters so generated command files parse

both toml renderers (TomlIntegration._render_toml_string for gemini/tabnine
and CommandRegistrar.render_toml_command for extension/preset commands) wrote
control characters raw into a multiline or basic string. toml forbids literal
control chars (U+0000-U+001F except tab/newline, and U+007F) in every string
form, and a bare CR that is not part of a CRLF pair, so a description or body
containing one produced a .toml file that fails to parse.

route any value with such a character to a fully-escaped basic string that
emits the leftover control chars as \uXXXX. added regression tests that
round-trip through tomllib.

* refactor(toml): centralize control-char escaping in one shared helper

the control-char detection and basic-string escaping added for both toml
renderers were copy-pasted into agents.py and integrations/base.py. move the
two functions into specify_cli/_toml_string.py and have both renderers
delegate to it, so the escaping rules can't drift apart later.

no behavior change; both renderers now reference the same implementation.
2026-07-08 07:42:34 -05:00
Marsel Safin
882e1e90d0 fix(cli): exit cleanly on malformed IPv6 URLs in extension/preset/workflow add (#3369)
* fix(cli): exit cleanly on malformed IPv6 URLs in extension/preset/workflow add

extension add --from, preset add --from, and workflow add <url> parsed
the user-supplied URL with a bare urlparse before their HTTPS/host
validation, so an unclosed IPv6 bracket escaped as a raw ValueError
traceback. Wrap each parse and emit the surrounding validation's clean
error style + typer.Exit(1) instead.

Fixes #3368

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

* fix(cli): convert malformed redirect URLs to URLError in shared redirect handler

Parse the redirect target once in _StripAuthOnRedirect.redirect_request
before the validator and stdlib handler run, converting ValueError into
URLError which every download path already catches. Also escape from_url
in the preset install message so IPv6 brackets don't break Rich markup.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-07 15:20:13 -05:00
Ali jawwad
a307894709 fix(github-http): return None on malformed GHES port instead of raising (#3379)
resolve_github_release_asset_api_url's is_ghes branch built the authority
with 'parsed.port', which raises ValueError on a malformed port (e.g.
host:notaport). The function's contract is to resolve or return None,
never raise — every other unresolvable case returns None. An allowlisted
GHES host with a bad port therefore crashed the caller. Read parsed.port
defensively and return None on ValueError.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:14:25 -05:00
Ali jawwad
10d4bca64c fix(integrations): guard _sha256 against unreadable managed files (#3376)
manifest.py::_sha256 does an unguarded open(). check_modified() and
uninstall() both call it on a readable-but-unopenable regular file
(e.g. permission denied) without catching OSError, so
'specify integration upgrade/uninstall/switch' surface a raw
PermissionError traceback. Guard both call sites: in check_modified()
treat an unreadable file as modified (consistent with the adjacent
symlink / non-regular-file handling); in uninstall() treat it as skipped
and preserve it (mirroring the existing path.unlink() OSError guard just
below). The force short-circuit is unchanged.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:11:18 -05:00
Manfred Riem
f1a8d8f95b chore: release 0.12.7, begin 0.12.8.dev0 development (#3398)
* chore: bump version to 0.12.7

* chore: begin 0.12.8.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-07 15:01:43 -05:00
102 changed files with 3213 additions and 500 deletions

1
.github/CODEOWNERS vendored
View File

@@ -5,4 +5,3 @@
/extensions/catalog.community.json @mnriem
/integrations/catalog.community.json @mnriem
/presets/catalog.community.json @mnriem

View File

@@ -7,7 +7,7 @@ body:
attributes:
value: |
Thanks for requesting a new agent! Before submitting, please check if the agent is already supported.
**Currently supported agents**: Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed
- type: input

View File

@@ -7,7 +7,7 @@ body:
attributes:
value: |
Thanks for contributing an extension! This template helps you submit your extension to the community catalog.
**Before submitting:**
- Review the [Extension Publishing Guide](https://github.com/github/spec-kit/blob/main/extensions/EXTENSION-PUBLISHING-GUIDE.md)
- Ensure your extension has a valid `extension.yml` manifest
@@ -209,9 +209,9 @@ body:
**Tested on:**
- macOS 14.0 with Spec Kit v0.1.0
- Linux Ubuntu 22.04 with Spec Kit v0.1.0
**Test project:** [Link or description]
**Test scenarios:**
1. Installed extension
2. Configured settings
@@ -230,7 +230,7 @@ body:
```bash
# Install extension
specify extension add <extension-name> --from https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip
# Use a command
/speckit.your-extension.command-name arg1 arg2
```

View File

@@ -7,7 +7,7 @@ body:
attributes:
value: |
Thanks for contributing a preset! This template helps you submit your preset to the community catalog.
**Before submitting:**
- Review the [Preset Publishing Guide](https://github.com/github/spec-kit/blob/main/presets/PUBLISHING.md)
- Ensure your preset has a valid `preset.yml` manifest

View File

@@ -19,4 +19,3 @@
- [ ] I **did** use AI assistance (describe below)
<!-- If you used AI, briefly describe how (e.g., "Code generated by Copilot", "Consulted ChatGPT for approach"): -->

View File

@@ -37,7 +37,7 @@ jobs:
fi
- name: Run markdownlint-cli2
uses: DavidAnson/markdownlint-cli2-action@ded1f9488f68a970bc66ea5619e13e9b52e601cd # v23
uses: DavidAnson/markdownlint-cli2-action@8de2aa07cae85fd17c0b35642db70cf5495f1d25 # v24.0.0
with:
globs: |
'**/*.md'

View File

@@ -32,7 +32,7 @@ jobs:
ref: refs/tags/${{ inputs.tag }}
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
@@ -74,7 +74,7 @@ jobs:
path: dist/
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
- name: Publish to PyPI
run: uv publish

View File

@@ -20,24 +20,24 @@ jobs:
days-before-stale: 150
# Days of inactivity before a stale issue or PR is closed (after being marked stale)
days-before-close: 30
# Stale issue settings
stale-issue-message: 'This issue has been automatically marked as stale because it has not had any activity for 150 days. It will be closed in 30 days if no further activity occurs.'
close-issue-message: 'This issue has been automatically closed due to inactivity (180 days total). If you believe this issue is still relevant, please reopen it or create a new issue.'
stale-issue-label: 'stale'
# Stale PR settings
stale-pr-message: 'This pull request has been automatically marked as stale because it has not had any activity for 150 days. It will be closed in 30 days if no further activity occurs.'
close-pr-message: 'This pull request has been automatically closed due to inactivity (180 days total). If you believe this PR is still relevant, please reopen it or create a new PR.'
stale-pr-label: 'stale'
# Exempt issues and PRs with these labels from being marked as stale
exempt-issue-labels: 'pinned,security'
exempt-pr-labels: 'pinned,security'
# Only issues or PRs with all of these labels are checked
# Leave empty to check all issues and PRs
any-of-labels: ''
# Operations per run (helps avoid rate limits)
operations-per-run: 250

View File

@@ -16,7 +16,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
@@ -37,7 +37,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6

View File

@@ -26,4 +26,4 @@
"ignores": [
".genreleases/"
]
}
}

12
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,12 @@
---
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-executables-have-shebangs
- id: check-yaml
exclude: \.lock\.yml$
- id: end-of-file-fixer
exclude: \.lock\.yml$
- id: trailing-whitespace
exclude: \.lock\.yml$

View File

@@ -2,6 +2,81 @@
<!-- insert new changelog below this comment -->
## [0.12.11] - 2026-07-10
### Changed
- fix(agent-context): discover nested plan.md in scoped layouts (#3024) (#3301)
- fix(auth): return no matches, not raw ValueError, for a malformed URL (#3437)
- fix(catalogs): raise catalog error, not raw ValueError, on a malformed URL (#3435)
- fix(bundler): raise BundlerError, not raw ValueError, on a malformed catalog URL (#3433)
- chore: add pre-commit config and fix trailing whitespace/end-of-file (#3430)
- Add EARS Requirements Syntax extension to community catalog (#3407)
- Add Spec Kit Figma extension to community catalog (#3408)
- fix(workflows): report validation errors instead of crashing on non-string workflow.yml scalars (#3421)
- fix(templates): remove self-referencing path in plan-template.md note (#3417)
- chore: release 0.12.10, begin 0.12.11.dev0 development (#3453)
## [0.12.10] - 2026-07-10
### Changed
- chore(deps): bump astral-sh/setup-uv from 8.2.0 to 8.3.2 (#3439)
- chore(deps): bump DavidAnson/markdownlint-cli2-action (#3438)
- fix(templates): correct phase numbering in plan.md (#3416)
- fix(git-ext): honor explicit -Number 0 in PowerShell branch creation (#3412)
- docs: add 'spectatui' entry to friends.md (#3362)
- test: pin interpreter probe so py-template render test passes on Windows (#3428)
- feat(workflows): make shell step timeout configurable (#3404)
- fix: find plans in nested spec directories (#3405)
- feat(templates): add py: lines to command templates' scripts frontmatter (#3403)
- chore: release 0.12.9, begin 0.12.10.dev0 development (#3426)
## [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
### Changed
- [extension] Add LLM Wiki extension to community catalog (#3361)
- Docs: Document missing CLI flags and integrations (#3182)
- Docs: Remove Cursor from CLI check list in README (#3184)
- feat(extensions): port update-agent-context to Python (#3387)
- fix(scripts): fall through to grep/sed when python3 is a broken stub in feature.json parser (#3312)
- fix(toml): escape control characters so generated command files parse (#3341)
- fix(cli): exit cleanly on malformed IPv6 URLs in `extension`/`preset`/`workflow add` (#3369)
- fix(github-http): return None on malformed GHES port instead of raising (#3379)
- fix(integrations): guard _sha256 against unreadable managed files (#3376)
- chore: release 0.12.7, begin 0.12.8.dev0 development (#3398)
## [0.12.7] - 2026-07-07
### Changed
- fix(bundler): bundle update uninstalls components dropped by new version (#3353)
- fix(workflows): route run/resume errors to stderr under --json (#3352)
- fix(workflows): fan-in validate() rejects non-mapping output (#3349)
- fix(workflows): shell step validate() rejects non-string run (#3348)
- fix(integrations): agy honors SPECKIT_INTEGRATION_AGY_EXTRA_ARGS (#3347)
- Add Orchestration Task Context Management extension to community catalog (#3372)
- Update DocGuard — CDD Enforcement extension to v0.30.0 (#3371)
- Update Ripple extension to v1.1.0 (#3370)
- feat(integrations): generalize post-processing to all format types (#3311)
- chore: release 0.12.6, begin 0.12.7.dev0 development (#3393)
## [0.12.6] - 2026-07-07
### Changed

View File

@@ -19,4 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

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

1
docs/.gitignore vendored
View File

@@ -6,4 +6,3 @@ obj/
# Temporary files
*.tmp
*.log

View File

@@ -52,13 +52,14 @@ The following community-contributed extensions are available in [`catalog.commun
| Cost Tracker | Track real LLM dollar cost across SDD workflows — per-feature budgets, per-integration comparison, and finance-ready exports | `visibility` | Read+Write | [spec-kit-cost](https://github.com/Quratulain-bilal/spec-kit-cost) |
| Data Model Diagram | Generates Mermaid ER diagrams from Spec Kit data models after planning | `docs` | Read+Write | [spec-kit-data-model-diagram](https://github.com/benizzio/spec-kit-data-model-diagram) |
| DocGuard — CDD Enforcement | The only doc-integrity engine with an MCP server, SARIF output, and a deterministic zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, GitHub Action with PR annotations, spec-kit hooks. Pure Node.js, one pinned dep. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
| EARS Requirements Syntax | Author, lint, and convert requirements using EARS - the five industry-standard sentence patterns for unambiguous, testable requirements | `docs` | Read+Write | [spec-kit-ears](https://github.com/dhruv-15-03/spec-kit-ears) |
| Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) |
| Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) |
| FixIt Extension | Spec-aware bug fixing — maps bugs to spec artifacts, proposes a plan, applies minimal changes | `code` | Read+Write | [spec-kit-fixit](https://github.com/speckit-community/spec-kit-fixit) |
| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |
| GitHub Issues Integration 1 | Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability | `integration` | Read+Write | [spec-kit-github-issues](https://github.com/Fatima367/spec-kit-github-issues) |
| GitHub Issues Integration 2 | Creates and syncs local specs from an existing GitHub issue | `integration` | Read+Write | [spec-kit-issue](https://github.com/aaronrsun/spec-kit-issue) |
| Golden Demo | 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) |
| 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) |
@@ -67,6 +68,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Jira Integration (Sync Engine) | Idempotent, drift-aware, fail-closed reconcile engine mirroring spec-kit specs into Jira (Epic per repo, Story per spec, Subtask per phase) | `integration` | Read+Write | [spec-kit-jira-sync](https://github.com/ashbrener/spec-kit-jira-sync) |
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
| Linear Integration | Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional). | `integration` | Read+Write | [spec-kit-linear-sync](https://github.com/ashbrener/spec-kit-linear-sync) |
| LLM Wiki | LLM-maintained compounding project wiki: source ingestion, cited answers, and consistency linting | `docs` | Read+Write | [spec-kit-wiki](https://github.com/formin/spec-kit-wiki) |
| Loop Engineering | Engineer safe autonomous agent loops for spec-driven development: a maker/checker split, externalized loop state, and stay-the-engineer guardrails against comprehension debt and cognitive surrender | `process` | Read+Write | [spec-kit-loop](https://github.com/formin/spec-kit-loop) |
| MAQA — Multi-Agent & Quality Assurance | Coordinator → feature → QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins. Optional CI gate. | `process` | Read+Write | [spec-kit-maqa-ext](https://github.com/GenieRobot/spec-kit-maqa-ext) |
| MAQA Azure DevOps Integration | Azure DevOps Boards integration for MAQA — syncs User Stories and Task children as features progress | `integration` | Read+Write | [spec-kit-maqa-azure-devops](https://github.com/GenieRobot/spec-kit-maqa-azure-devops) |
@@ -116,6 +118,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Spec Critique Extension | Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives | `docs` | Read-only | [spec-kit-critique](https://github.com/arunt14/spec-kit-critique) |
| Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) |
| Spec Kit Discovery Extension | Run technical discovery commands for feasibility, technology selection, scenario-specific technical decisions, legacy codebase assessment, implementation understanding, and proof-of-concept validation | `process` | Read+Write | [spec-kit-discovery](https://github.com/bigsmartben/spec-kit-discovery) |
| Spec Kit Figma | Agent-agnostic SpecKit extension that grounds spec, plan & task generation in Figma design context — REST + optional MCP, single/mono/multi-repo, macOS/Linux/Windows. | `integration` | Read+Write | [spec-kit-figma](https://github.com/Fyloss/spec-kit-figma) |
| Spec Kit Preview | Generate evidence-backed low, mid, or high fidelity previews from Spec Kit artifacts as Markdown or self-contained HTML | `docs` | Read+Write | [spec-kit-preview](https://github.com/bigsmartben/spec-kit-preview) |
| Spec Kit Schedule | Optimal multi-agent task scheduling via CP-SAT — DAG precedence, hallucination-aware caps, file-conflict avoidance, stochastic durations, replanning, and interactive HTML output | `process` | Read+Write | [spec-kit-schedule](https://github.com/jfranc38/spec-kit-schedule) |
| Spec Kit TLDR | Render a feature's spec.md / plan.md into a review-oriented TLDR (self-contained HTML dashboard + PR-native Markdown) that surfaces risks for faster PR review. | `visibility` | Read+Write | [speckit-tldr](https://github.com/qurore/speckit-tldr) |

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.
- **[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

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

View File

@@ -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_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 (any `plan.md` under `specs/`, including nested scoped layouts such as `specs/<scope>/<feature>/plan.md`).
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]`
- **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` (searched recursively, so nested scoped layouts are discovered).

View File

@@ -12,7 +12,7 @@
#
# When `plan_path` is omitted, the script derives it from `.specify/feature.json`
# (written by /speckit-specify). Falls back to the most recently modified
# `specs/*/plan.md` only when feature.json is absent or its plan does not exist yet.
# `specs/**/plan.md` only when feature.json is absent or its plan does not exist yet.
set -euo pipefail
@@ -307,16 +307,28 @@ import sys
from pathlib import Path
root = Path(sys.argv[1]).resolve()
specs = root / "specs"
plans = sorted(
specs.glob("*/plan.md"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
if plans:
def _resolved_rel(p):
# Resolve symlinks before checking containment: relative_to() is lexical
# and would otherwise accept a plan reached through a specs/ symlink that
# points outside the project, emitting an in-project-looking path for an
# out-of-project file (or picking it as "most recent").
try:
print(plans[0].relative_to(root).as_posix())
except ValueError:
print("")
return p.resolve().relative_to(root)
except (OSError, ValueError):
return None
# Recurse (rather than the old one-level specs/*/plan.md glob) so scoped layouts
# created via SPECIFY_FEATURE_DIRECTORY, e.g. specs/<scope>/<feature>/plan.md,
# are still discovered when feature.json is absent (#3024).
candidates = []
for p in specs.rglob("plan.md"):
rel = _resolved_rel(p)
if rel:
candidates.append((p, rel))
candidates.sort(key=lambda pr: pr[0].stat().st_mtime, reverse=True)
if candidates:
print(candidates[0][1].as_posix())
else:
print("")
PY

View File

@@ -12,7 +12,7 @@
#
# When `plan_path` is omitted, the script derives it from `.specify/feature.json`
# (written by /speckit-specify). Falls back to the most recently modified
# `specs/*/plan.md` only when feature.json is absent or its plan does not exist yet.
# `specs/**/plan.md` only when feature.json is absent or its plan does not exist yet.
[CmdletBinding()]
param(
@@ -426,9 +426,11 @@ if (-not $PlanPath) {
if (-not $PlanPath) {
try {
$specsDir = Join-Path $ProjectRoot 'specs'
$candidate = Get-ChildItem -Path $specsDir -Directory -ErrorAction SilentlyContinue |
ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName 'plan.md') -ErrorAction SilentlyContinue } |
Where-Object { $_ } |
# Recurse (rather than the old one-level specs/*/plan.md scan) so scoped
# layouts created via SPECIFY_FEATURE_DIRECTORY, e.g.
# specs/<scope>/<feature>/plan.md, are still discovered when
# feature.json is absent (#3024).
$candidate = Get-ChildItem -Path $specsDir -Filter 'plan.md' -File -Recurse -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if ($candidate) {

View File

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

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-06T00:00:00Z",
"updated_at": "2026-07-08T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -1180,6 +1180,39 @@
"created_at": "2026-03-13T00:00:00Z",
"updated_at": "2026-03-13T00:00:00Z"
},
"ears": {
"name": "EARS Requirements Syntax",
"id": "ears",
"description": "Author, lint, and convert requirements using EARS (Easy Approach to Requirements Syntax) - the five industry-standard sentence patterns for unambiguous, testable requirements.",
"author": "dhruv-15-03",
"version": "1.0.0",
"download_url": "https://github.com/dhruv-15-03/spec-kit-ears/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/dhruv-15-03/spec-kit-ears",
"homepage": "https://github.com/dhruv-15-03/spec-kit-ears",
"documentation": "https://github.com/dhruv-15-03/spec-kit-ears/blob/main/README.md",
"changelog": "https://github.com/dhruv-15-03/spec-kit-ears/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "docs",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.9.0"
},
"provides": {
"commands": 3,
"hooks": 0
},
"tags": [
"ears",
"requirements",
"specification",
"quality"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-08T00:00:00Z",
"updated_at": "2026-07-08T00:00:00Z"
},
"extensify": {
"name": "Extensify",
"id": "extensify",
@@ -1213,6 +1246,47 @@
"created_at": "2026-03-18T00:00:00Z",
"updated_at": "2026-04-23T00:00:00Z"
},
"figma": {
"name": "Spec Kit Figma",
"id": "figma",
"description": "Agent-agnostic SpecKit extension that grounds spec, plan & task generation in Figma design context — REST + optional MCP, single/mono/multi-repo, macOS/Linux/Windows.",
"author": "Fyloss",
"version": "1.6.0",
"download_url": "https://github.com/Fyloss/spec-kit-figma/archive/refs/tags/v1.6.0.zip",
"repository": "https://github.com/Fyloss/spec-kit-figma",
"homepage": "https://github.com/Fyloss/spec-kit-figma",
"documentation": "https://github.com/Fyloss/spec-kit-figma/blob/main/docs/INSTALL.md",
"changelog": "https://github.com/Fyloss/spec-kit-figma/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "integration",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.1.0",
"tools": [
{ "name": "git", "required": true },
{ "name": "bash", "required": false },
{ "name": "curl", "required": false },
{ "name": "jq", "required": false },
{ "name": "pwsh", "required": false }
]
},
"provides": {
"commands": 5,
"hooks": 6
},
"tags": [
"figma",
"design",
"frontend",
"ui",
"design-system"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-08T00:00:00Z",
"updated_at": "2026-07-08T00:00:00Z"
},
"fix-findings": {
"name": "Fix Findings",
"id": "fix-findings",
@@ -1398,10 +1472,10 @@
"golden-demo": {
"name": "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",
"version": "0.1.1",
"download_url": "https://github.com/jasstt/spec-kit-golden-demo/archive/refs/tags/v0.1.1.zip",
"version": "0.3.0",
"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",
"homepage": "https://github.com/jasstt/spec-kit-golden-demo",
"documentation": "https://github.com/jasstt/spec-kit-golden-demo",
@@ -1412,13 +1486,16 @@
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 2,
"commands": 3,
"hooks": 2
},
"tags": [
"testing",
"drift-detection",
"behavioral-oracle",
"fuzzing",
"ci-cd",
"cross-language",
"tdd",
"quality"
],
@@ -1426,7 +1503,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-06-24T00:00:00Z",
"updated_at": "2026-06-24T00:00:00Z"
"updated_at": "2026-07-07T00:00:00Z"
},
"harness": {
"name": "Research Harness",
@@ -4375,6 +4452,40 @@
"created_at": "2026-04-13T00:00:00Z",
"updated_at": "2026-04-13T00:00:00Z"
},
"wiki": {
"name": "LLM Wiki",
"id": "wiki",
"description": "LLM-maintained compounding project wiki: source ingestion, cited answers, and consistency linting",
"author": "formin",
"version": "1.0.0",
"download_url": "https://github.com/formin/spec-kit-wiki/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/formin/spec-kit-wiki",
"homepage": "https://github.com/formin/spec-kit-wiki",
"documentation": "https://github.com/formin/spec-kit-wiki/blob/main/README.md",
"changelog": "https://github.com/formin/spec-kit-wiki/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "docs",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.2.0"
},
"provides": {
"commands": 5,
"hooks": 2
},
"tags": [
"wiki",
"knowledge-base",
"docs",
"memory",
"context-management"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-06T00:00:00Z",
"updated_at": "2026-07-06T00:00:00Z"
},
"wireframe": {
"name": "Wireframe Visual Feedback Loop",
"id": "wireframe",

View File

@@ -48,4 +48,4 @@
]
}
}
}
}

View File

@@ -446,7 +446,10 @@ if ($env:GIT_BRANCH_NAME) {
$branchSuffix = Get-BranchName -Description $featureDesc
}
if ($Timestamp -and $Number -ne 0) {
# Warn if -Number and -Timestamp are both specified. Use ContainsKey (not
# `-ne 0`) so an explicit `-Number 0` is also detected, matching the bash twin's
# `[ -n "$BRANCH_NUMBER" ]` check.
if ($Timestamp -and $PSBoundParameters.ContainsKey('Number')) {
Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used"
$Number = 0
}
@@ -456,7 +459,10 @@ if ($env:GIT_BRANCH_NAME) {
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
} else {
$branchScopePrefix = Get-BranchScopePrefix -Template $branchTemplate -BranchSuffix $branchSuffix
if ($Number -eq 0) {
# Auto-detect the next number only when -Number was not supplied; an
# explicit value (including 0) is honored, matching the bash twin's
# `[ -z "$BRANCH_NUMBER" ]` check.
if (-not $PSBoundParameters.ContainsKey('Number')) {
if ($DryRun -and $hasGit) {
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch -ScopePrefix $branchScopePrefix
} elseif ($DryRun) {

View File

@@ -48,7 +48,7 @@ cat .specify/extensions/.registry/$ARGUMENTS.json
### Step 4: Verification Report
Analyze the standard output of the three steps.
Analyze the standard output of the three steps.
Generate a terminal-style test output format detailing the results of discovery, installation, and registration. Return this directly to the user.
Example output format:

View File

@@ -76,5 +76,3 @@ Areas under discussion or in progress for future development:
- **Continued agent expansion** -- seven new agents were added in March alone. The agent-agnostic design means support for emerging tools can be added by anyone. [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/)
- **Experience simplification** -- the preset system, custom workflows, and growing walkthrough library lower the learning curve, but extension discoverability will need a more robust solution as the catalog grows. [\[github.com\]](https://github.com/github/spec-kit/releases)
- **Toward a stable release** -- nine releases in one month reflects pre-1.0 momentum. Reaching 1.0 will require stabilizing the extension and preset APIs and ensuring backward compatibility across the agent and extension surface area. [\[github.com\]](https://github.com/github/spec-kit/blob/main/newsletters/2026-February.md)

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.12.7.dev0"
version = "0.12.11"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
readme = "README.md"
requires-python = ">=3.11"
@@ -83,4 +83,3 @@ extend-select = [
"S604", # call-with-shell-equals-true
"S605", # start-process-with-a-shell
]

View File

@@ -57,13 +57,13 @@ OPTIONS:
EXAMPLES:
# Check task prerequisites (plan.md required)
./check-prerequisites.sh --json
# Check implementation prerequisites (plan.md + tasks.md required)
./check-prerequisites.sh --json --require-tasks --include-tasks
# Get feature paths only (no validation)
./check-prerequisites.sh --paths-only
EOF
exit 0
;;
@@ -182,13 +182,13 @@ else
# Text output
echo "FEATURE_DIR:$FEATURE_DIR"
echo "AVAILABLE_DOCS:"
# Show status of each potential document
check_file "$RESEARCH" "research.md"
check_file "$DATA_MODEL" "data-model.md"
check_dir "$CONTRACTS_DIR" "contracts/"
check_file "$QUICKSTART" "quickstart.md"
if $INCLUDE_TASKS; then
check_file "$TASKS" "tasks.md"
fi

View File

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

View File

@@ -94,7 +94,7 @@ fi
get_highest_from_specs() {
local specs_dir="$1"
local highest=0
if [ -d "$specs_dir" ]; then
for dir in "$specs_dir"/*; do
[ -d "$dir" ] || continue
@@ -109,7 +109,7 @@ get_highest_from_specs() {
fi
done
fi
echo "$highest"
}
@@ -135,19 +135,19 @@ fi
# Function to generate branch name with stop word filtering and length filtering
generate_branch_name() {
local description="$1"
# Common stop words to filter out
local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$"
# Convert to lowercase and split into words
local clean_name=$(printf '%s' "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g')
# Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original)
local meaningful_words=()
for word in $clean_name; do
# Skip empty words
[ -z "$word" ] && continue
# Keep words that are NOT stop words AND (length >= 3 OR are potential acronyms)
if ! echo "$word" | grep -qiE "$stop_words"; then
if [ ${#word} -ge 3 ]; then
@@ -160,12 +160,12 @@ generate_branch_name() {
fi
fi
done
# If we have meaningful words, use first 3-4 of them
if [ ${#meaningful_words[@]} -gt 0 ]; then
local max_words=3
if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi
local result=""
local count=0
for word in "${meaningful_words[@]}"; do
@@ -221,15 +221,15 @@ if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
# Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
# Truncate suffix at word boundary if possible
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
# Remove trailing hyphen if truncation created one
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"

View File

@@ -8,17 +8,17 @@ ARGS=()
for arg in "$@"; do
case "$arg" in
--json)
JSON_MODE=true
--json)
JSON_MODE=true
;;
--help|-h)
--help|-h)
echo "Usage: $0 [--json]"
echo " --json Output results in JSON format"
echo " --help Show this help message"
exit 0
exit 0
;;
*)
ARGS+=("$arg")
*)
ARGS+=("$arg")
;;
esac
done
@@ -77,8 +77,7 @@ if $JSON_MODE; then
fi
else
echo "FEATURE_SPEC: $FEATURE_SPEC"
echo "IMPL_PLAN: $IMPL_PLAN"
echo "IMPL_PLAN: $IMPL_PLAN"
echo "SPECS_DIR: $FEATURE_DIR"
echo "BRANCH: $CURRENT_BRANCH"
fi

View File

@@ -42,10 +42,10 @@ OPTIONS:
EXAMPLES:
# Check task prerequisites (plan.md required)
.\check-prerequisites.ps1 -Json
# Check implementation prerequisites (plan.md + tasks.md required)
.\check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
# Get feature paths only (no validation)
.\check-prerequisites.ps1 -PathsOnly
@@ -118,35 +118,35 @@ if (Test-Path $paths.RESEARCH) { $docs += 'research.md' }
if (Test-Path $paths.DATA_MODEL) { $docs += 'data-model.md' }
# Check contracts directory (only if it exists and has files)
if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) {
$docs += 'contracts/'
if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) {
$docs += 'contracts/'
}
if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' }
# Include tasks.md if requested and it exists
if ($IncludeTasks -and (Test-Path $paths.TASKS)) {
$docs += 'tasks.md'
if ($IncludeTasks -and (Test-Path $paths.TASKS)) {
$docs += 'tasks.md'
}
# Output results
if ($Json) {
# JSON output
[PSCustomObject]@{
[PSCustomObject]@{
FEATURE_DIR = $paths.FEATURE_DIR
AVAILABLE_DOCS = $docs
AVAILABLE_DOCS = $docs
} | ConvertTo-Json -Compress
} else {
# Text output
Write-Output "FEATURE_DIR:$($paths.FEATURE_DIR)"
Write-Output "AVAILABLE_DOCS:"
# Show status of each potential document
Test-FileExists -Path $paths.RESEARCH -Description 'research.md' | Out-Null
Test-FileExists -Path $paths.DATA_MODEL -Description 'data-model.md' | Out-Null
Test-DirHasFiles -Path $paths.CONTRACTS_DIR -Description 'contracts/' | Out-Null
Test-FileExists -Path $paths.QUICKSTART -Description 'quickstart.md' | Out-Null
if ($IncludeTasks) {
Test-FileExists -Path $paths.TASKS -Description 'tasks.md' | Out-Null
}

View File

@@ -191,7 +191,7 @@ function Get-FeaturePathsEnv {
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json.")
exit 1
}
# When no branch context exists (no SPECIFY_FEATURE, feature resolved via
# SPECIFY_FEATURE_DIRECTORY or feature.json), fall back to the feature
# directory basename so CURRENT_BRANCH is a usable identifier rather than

View File

@@ -63,7 +63,7 @@ if (Test-Path $paths.IMPL_PLAN -PathType Leaf) {
# Output results
if ($Json) {
$result = [PSCustomObject]@{
$result = [PSCustomObject]@{
FEATURE_SPEC = $paths.FEATURE_SPEC
IMPL_PLAN = $paths.IMPL_PLAN
SPECS_DIR = $paths.FEATURE_DIR

View File

@@ -5,4 +5,4 @@
}
],
"settings": {}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -196,7 +196,15 @@ def find_entries_for_url(
url: str, entries: list[AuthConfigEntry]
) -> list[AuthConfigEntry]:
"""Return entries whose ``hosts`` match the hostname of *url*."""
hostname = (urlparse(url).hostname or "").lower()
# A malformed authority (e.g. an unterminated IPv6 bracket "https://[::1")
# makes urlparse/hostname raise ValueError. Treat that the same as a
# host-less URL: no entry can match, so return no matches rather than
# leaking a raw ValueError out of the shared HTTP client (build_request /
# open_url call this before any URL validation).
try:
hostname = (urlparse(url).hostname or "").lower()
except ValueError:
return []
if not hostname:
return []
return [

View File

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

View File

@@ -68,8 +68,18 @@ def _validate_remote_url(source_id: str, url: str) -> None:
Mirrors ``specify_cli.catalogs`` URL validation to avoid MITM/downgrade
issues before any network call.
"""
parsed = urlparse(url)
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
# A malformed authority (e.g. an unclosed IPv6 bracket ``https://[::1``)
# makes urlparse / hostname access raise ValueError. This function's
# contract is to raise BundlerError for a bad URL, so surface that as a
# clean error rather than leaking a raw ValueError to the caller.
try:
parsed = urlparse(url)
hostname = parsed.hostname
except ValueError:
raise BundlerError(
f"Catalog '{source_id}' URL is malformed: {url}"
) from None
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
raise BundlerError(
f"Catalog '{source_id}' URL must use HTTPS (got {parsed.scheme}://). "
@@ -79,7 +89,7 @@ def _validate_remote_url(source_id: str, url: str) -> None:
# "https://:8080" or "https://user@...", so requiring netloc would let
# those through even though they carry no host. hostname is None in those
# cases. Mirrors the fix in ``specify_cli.catalogs`` (#3210).
if not parsed.hostname:
if not hostname:
raise BundlerError(
f"Catalog '{source_id}' URL must be a valid URL with a host: {url}"
)

View File

@@ -33,12 +33,13 @@ DEFAULT_PRIORITY = 10
def _assert_pinned_version(
kind: str, component_id: str, pinned: str | None, advertised: object
) -> 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
whatever the active catalog currently serves would silently violate the
pin. When the catalog advertises no version we cannot enforce the pin, so
installation proceeds (the catalog, not the bundler, owns that gap).
whatever the resolved source (catalog *or* bundled asset) provides would
silently violate the pin. When the source advertises no version we cannot
enforce the pin, so installation proceeds (the source, not the bundler,
owns that gap).
"""
if not pinned or advertised is None:
return
@@ -54,11 +55,35 @@ def _assert_pinned_version(
if not matches:
raise BundlerError(
f"{kind} '{component_id}' is pinned to version {pinned} in the bundle "
f"manifest, but the active catalog serves {actual}. Update the bundle's "
"pinned version or the catalog before installing."
f"manifest, but the resolved version is {actual}. Update the bundle's "
"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):
def is_installed(self, component: ComponentRef) -> bool: ...
@@ -134,6 +159,15 @@ class _PresetKindManager:
bundled = _locate_bundled_preset(component.id)
if bundled is not None:
# Enforce the manifest pin against the bundled asset's own version,
# mirroring the catalog path below (the bundled path previously
# skipped the pin entirely).
_assert_pinned_version(
"Preset",
component.id,
component.version,
_bundled_manifest_version(bundled / "preset.yml", "preset"),
)
self._manager.install_from_directory(bundled, speckit_version, priority)
return
@@ -198,6 +232,15 @@ class _ExtensionKindManager:
bundled = _locate_bundled_extension(component.id)
if bundled is not None:
# Enforce the manifest pin against the bundled asset's own version,
# mirroring the catalog path below (the bundled path previously
# skipped the pin entirely).
_assert_pinned_version(
"Extension",
component.id,
component.version,
_bundled_manifest_version(bundled / "extension.yml", "extension"),
)
self._manager.install_from_directory(
bundled, speckit_version, priority=priority
)

View File

@@ -71,8 +71,12 @@ class CatalogStackBase:
"""Validate that a catalog URL uses HTTPS, except localhost HTTP."""
from urllib.parse import urlparse
parsed = urlparse(url)
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
try:
parsed = urlparse(url)
hostname = parsed.hostname
except ValueError:
raise cls._error(f"Catalog URL is malformed: {url}") from None
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
raise cls._error(
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
@@ -81,7 +85,7 @@ class CatalogStackBase:
# Check hostname, not netloc: netloc is truthy for host-less URLs like
# "https://:8080" or "https://user@", so the host guarantee this error
# promises would not actually hold. hostname is None in those cases (#3209).
if not parsed.hostname:
if not hostname:
raise cls._error("Catalog URL must be a valid URL with a host.")
def _load_catalog_config(self, config_path: Path) -> list[CatalogEntry] | None:

View File

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

View File

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

View File

@@ -62,4 +62,4 @@ class DevinIntegration(SkillsIntegration):
default=True,
help="Install as agent skills (default for Devin)",
),
]
]

View File

@@ -18,13 +18,13 @@ from ..manifest import IntegrationManifest
def format_forge_command_name(cmd_name: str) -> str:
"""Convert command name to Forge-compatible hyphenated format.
Forge requires command names to use hyphens instead of dots for
compatibility with ZSH and other shells. This function converts
dot-notation command names to hyphenated format.
The function is idempotent: already-formatted names are returned unchanged.
Examples:
>>> format_forge_command_name("plan")
'speckit-plan'
@@ -38,26 +38,26 @@ def format_forge_command_name(cmd_name: str) -> str:
'speckit-my-extension-example'
>>> format_forge_command_name("speckit.jira.sync-status")
'speckit-jira-sync-status'
Args:
cmd_name: Command name in dot notation (speckit.foo.bar),
cmd_name: Command name in dot notation (speckit.foo.bar),
hyphenated format (speckit-foo-bar), or plain name (foo)
Returns:
Hyphenated command name with 'speckit-' prefix
"""
# Already in hyphenated format - return as-is (idempotent)
if cmd_name.startswith("speckit-"):
return cmd_name
# Strip 'speckit.' prefix if present
short_name = cmd_name
if short_name.startswith("speckit."):
short_name = short_name[len("speckit."):]
# Replace all dots with hyphens
short_name = short_name.replace(".", "-")
# Return with 'speckit-' prefix
return f"speckit-{short_name}"

View File

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

View File

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

View File

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

View File

@@ -328,7 +328,10 @@ def refresh_shared_templates(
_ensure_safe_shared_destination(project_path, dst)
rel = dst.relative_to(project_path).as_posix()
if dst.exists() and not force:
if rel not in tracked_files or rel in modified:
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)
continue
@@ -344,7 +347,7 @@ def refresh_shared_templates(
if skipped_files:
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:
console.print(f" {rel}")
@@ -400,7 +403,7 @@ def install_shared_infra(
# manifest entries the core no longer ships (stale-script cleanup, #3076).
seen_rels: set[str] = set()
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]:
"""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("*"):
if not src_path.is_file():
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
# empty (or symlink-skipped) variant keeps this False, so
# stale-cleanup is skipped — otherwise it would treat every

View File

@@ -601,7 +601,15 @@ def workflow_add(
except (ValueError, yaml.YAMLError) as exc:
console.print(f"[red]Error:[/red] Invalid workflow YAML: {exc}")
raise typer.Exit(1)
if not definition.id or not definition.id.strip():
# Non-string ids (e.g. unquoted ``id: 123`` or ``id: 0``) fall through
# to validate_workflow below, which reports a typed error instead of
# crashing on ``.strip()`` here. Only None/empty/whitespace-only ids
# are rejected as missing.
if (
definition.id is None
or definition.id == ""
or (isinstance(definition.id, str) and not definition.id.strip())
):
console.print("[red]Error:[/red] Workflow definition has an empty or missing 'id'")
raise typer.Exit(1)
@@ -631,7 +639,11 @@ def workflow_add(
from urllib.parse import urlparse
from specify_cli.authentication.http import open_url as _open_url
parsed_src = urlparse(source)
try:
parsed_src = urlparse(source)
except ValueError:
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(source)}")
raise typer.Exit(1)
src_host = parsed_src.hostname or ""
src_loopback = src_host == "localhost"
if not src_loopback:

View File

@@ -129,26 +129,49 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
errors: list[str] = []
# -- Schema version ---------------------------------------------------
if definition.schema_version not in ("1.0", "1"):
# str() so an unquoted ``schema_version: 1.0`` (YAML float) is accepted —
# rejecting it would print "Unsupported schema_version 1.0. Expected '1.0'."
if str(definition.schema_version) != "1.0":
errors.append(
f"Unsupported schema_version {definition.schema_version!r}. "
f"Expected '1.0'."
)
# -- Top-level fields -------------------------------------------------
if not definition.id:
# YAML parses unquoted scalars like ``id: 123`` or ``version: 1.0`` as
# int/float; check types before regex/string operations so authoring
# mistakes surface as validation errors instead of tracebacks. Only
# ``None``/empty-string count as missing so falsey non-strings
# (``id: 0``, ``name: false``) still get the typed error.
if definition.id is None or definition.id == "":
errors.append("Workflow is missing 'workflow.id'.")
elif not isinstance(definition.id, str):
errors.append(
f"'workflow.id' must be a string, got "
f"{type(definition.id).__name__} ({definition.id!r})."
)
elif not _ID_PATTERN.match(definition.id):
errors.append(
f"Workflow ID {definition.id!r} must be lowercase alphanumeric "
f"with hyphens."
)
if not definition.name:
if definition.name is None or definition.name == "":
errors.append("Workflow is missing 'workflow.name'.")
elif not isinstance(definition.name, str):
errors.append(
f"'workflow.name' must be a string, got "
f"{type(definition.name).__name__} ({definition.name!r})."
)
if not definition.version:
if definition.version is None or definition.version == "":
errors.append("Workflow is missing 'workflow.version'.")
elif not isinstance(definition.version, str):
errors.append(
f"'workflow.version' must be a string, got "
f"{type(definition.version).__name__} ({definition.version!r}) — "
f'quote it in YAML (version: "1.0.0").'
)
elif not re.match(r"^\d+\.\d+\.\d+$", definition.version):
errors.append(
f"Workflow version {definition.version!r} is not valid "
@@ -256,9 +279,15 @@ def _validate_steps(
continue
step_id = step_config.get("id")
if not step_id:
if step_id is None or step_id == "":
errors.append("Step is missing 'id' field.")
continue
if not isinstance(step_id, str):
errors.append(
f"Step ID must be a string, got "
f"{type(step_id).__name__} ({step_id!r})."
)
continue
if ":" in step_id:
errors.append(

View File

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

View File

@@ -25,6 +25,14 @@ class ShellStep(StepBase):
run_cmd = str(run_cmd)
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
# multi-command expressions in workflow YAML. Workflow authors
@@ -37,7 +45,7 @@ class ShellStep(StepBase):
capture_output=True,
text=True,
cwd=cwd,
timeout=300,
timeout=timeout,
)
output = {
"exit_code": proc.returncode,
@@ -74,7 +82,7 @@ class ShellStep(StepBase):
except subprocess.TimeoutExpired:
return StepResult(
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"},
)
except OSError as exc:
@@ -106,4 +114,16 @@ class ShellStep(StepBase):
f"Shell step {config.get('id', '?')!r}: 'output_format' must "
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

View File

@@ -6,16 +6,16 @@
**Note**: This checklist is generated by the `__SPECKIT_COMMAND_CHECKLIST__` command based on feature context and requirements.
<!--
<!--
============================================================================
IMPORTANT: The checklist items below are SAMPLE ITEMS for illustration only.
The __SPECKIT_COMMAND_CHECKLIST__ command MUST replace these with actual items based on:
- User's specific checklist request
- Feature requirements from spec.md
- Technical context from plan.md
- Implementation details from tasks.md
DO NOT keep these sample items in the generated checklist file.
============================================================================
-->

View File

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

View File

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

View File

@@ -1,12 +1,13 @@
---
description: Identify underspecified areas in the current feature spec by asking up to 5 highly targeted clarification questions and encoding answers back into the spec.
handoffs:
handoffs:
- label: Build Technical Plan
agent: speckit.plan
prompt: Create a plan for the spec. I am building with...
scripts:
sh: scripts/bash/check-prerequisites.sh --json --paths-only
ps: scripts/powershell/check-prerequisites.ps1 -Json -PathsOnly
py: scripts/python/check_prerequisites.py --json --paths-only
---
## User Input

View File

@@ -1,6 +1,6 @@
---
description: Create or update the project constitution from interactive or provided principle inputs, ensuring all dependent templates stay in sync.
handoffs:
handoffs:
- label: Build Specification
agent: speckit.specify
prompt: Implement the feature specification based on the updated constitution. I want to build...

View File

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

View File

@@ -3,6 +3,7 @@ description: Execute the implementation plan by processing and executing all tas
scripts:
sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks
ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
py: scripts/python/check_prerequisites.py --json --require-tasks --include-tasks
---
## User Input
@@ -42,7 +43,7 @@ You **MUST** consider the user input before proceeding (if not empty).
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Outline.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
@@ -144,7 +145,7 @@ You **MUST** consider the user input before proceeding (if not empty).
6. Execute implementation following the task plan:
- **Phase-by-phase execution**: Complete each phase before moving to the next
- **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together
- **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together
- **Follow TDD approach**: Execute test tasks before their corresponding implementation tasks
- **File-based coordination**: Tasks affecting the same files must run sequentially
- **Validation checkpoints**: Verify each phase completion before proceeding

View File

@@ -1,6 +1,6 @@
---
description: Execute the implementation planning workflow using the plan template to generate design artifacts.
handoffs:
handoffs:
- label: Create Tasks
agent: speckit.tasks
prompt: Break the plan into tasks
@@ -68,7 +68,6 @@ You **MUST** consider the user input before proceeding (if not empty).
- Evaluate gates (ERROR if violations unjustified)
- Phase 0: Generate research.md (resolve all NEEDS CLARIFICATION)
- Phase 1: Generate data-model.md, contracts/, quickstart.md
- Phase 1: Update agent context by running the agent script
- Re-evaluate Constitution Check post-design
## Mandatory Post-Execution Hooks
@@ -107,7 +106,7 @@ Check if `.specify/extensions.yml` exists in the project root.
## Completion Report
Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generated artifacts.
Command ends after Phase 1 design. Report branch, IMPL_PLAN path, and generated artifacts.
## Phases

View File

@@ -1,6 +1,6 @@
---
description: Create or update the feature specification from a natural language feature description.
handoffs:
handoffs:
- label: Build Technical Plan
agent: speckit.plan
prompt: Create a plan for the spec. I am building with...
@@ -147,20 +147,20 @@ Given that feature description, do this:
```markdown
# Specification Quality Checklist: [FEATURE NAME]
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: [DATE]
**Feature**: [Link to spec.md]
## Content Quality
- [ ] No implementation details (languages, frameworks, APIs)
- [ ] Focused on user value and business needs
- [ ] Written for non-technical stakeholders
- [ ] All mandatory sections completed
## Requirement Completeness
- [ ] No [NEEDS CLARIFICATION] markers remain
- [ ] Requirements are testable and unambiguous
- [ ] Success criteria are measurable
@@ -169,16 +169,16 @@ Given that feature description, do this:
- [ ] Edge cases are identified
- [ ] Scope is clearly bounded
- [ ] Dependencies and assumptions identified
## Feature Readiness
- [ ] All functional requirements have clear acceptance criteria
- [ ] User scenarios cover primary flows
- [ ] Feature meets measurable outcomes defined in Success Criteria
- [ ] No implementation details leak into specification
## Notes
- Items marked incomplete require spec updates before `__SPECKIT_COMMAND_CLARIFY__` or `__SPECKIT_COMMAND_PLAN__`
```
@@ -203,20 +203,20 @@ Given that feature description, do this:
```markdown
## Question [N]: [Topic]
**Context**: [Quote relevant spec section]
**What we need to know**: [Specific question from NEEDS CLARIFICATION marker]
**Suggested Answers**:
| Option | Answer | Implications |
|--------|--------|--------------|
| A | [First suggested answer] | [What this means for the feature] |
| B | [Second suggested answer] | [What this means for the feature] |
| C | [Third suggested answer] | [What this means for the feature] |
| Custom | Provide your own answer | [Explain how to provide custom input] |
**Your choice**: _[Wait for user response]_
```

View File

@@ -1,6 +1,6 @@
---
description: Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts.
handoffs:
handoffs:
- label: Analyze For Consistency
agent: speckit.analyze
prompt: Run a project analysis for consistency
@@ -51,7 +51,7 @@ You **MUST** consider the user input before proceeding (if not empty).
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Outline.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
@@ -161,7 +161,7 @@ Every task MUST strictly follow this format:
4. **[Story] label**: REQUIRED for user story phase tasks only
- Format: [US1], [US2], [US3], etc. (maps to user stories from spec.md)
- Setup phase: NO story label
- Foundational phase: NO story label
- Foundational phase: NO story label
- User Story phases: MUST have story label
- Polish phase: NO story label
5. **Description**: Clear action with exact file path

View File

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

View File

@@ -4,7 +4,7 @@
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
**Note**: This template is filled in by the `__SPECKIT_COMMAND_PLAN__` command. See `.specify/templates/plan-template.md` for the execution workflow.
**Note**: This template is filled in by the `__SPECKIT_COMMAND_PLAN__` command; its definition describes the execution workflow.
## Summary

View File

@@ -11,4 +11,3 @@
".specify/scripts/powershell/": true
}
}

View File

@@ -638,6 +638,21 @@ class TestCreateFeatureBash:
assert result.returncode != 0
assert "requires updated Spec Kit core scripts" in result.stderr
def test_explicit_number_zero_is_honored(self, tmp_path: Path):
"""An explicit --number 0 is honored (yields 000), not treated as
'auto-detect'. Pins the canonical behavior the PowerShell twin must
mirror; the empty-string check (`[ -z "$BRANCH_NUMBER" ]`) already
distinguishes an unset flag from a supplied 0."""
project = _setup_project(tmp_path)
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "--dry-run", "--number", "0", "--short-name", "zero", "Zero feature",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "000-zero"
assert data["FEATURE_NUM"] == "000"
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
class TestCreateFeaturePowerShell:
@@ -942,6 +957,23 @@ class TestCreateFeaturePowerShell:
assert result.returncode != 0
assert "requires updated Spec Kit core scripts" in result.stderr
def test_explicit_number_zero_is_honored(self, tmp_path: Path):
"""An explicit -Number 0 is honored (yields 000), matching the bash twin's
--number 0. Regression guard: -Number defaults to 0, so a bare `-eq 0`
check cannot tell an unset flag from a supplied 0 and would silently
auto-detect instead. Uses PSBoundParameters.ContainsKey('Number')."""
project = _setup_project(tmp_path)
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "-DryRun", "-Number", "0", "-ShortName", "zero", "Zero feature",
)
assert result.returncode == 0, result.stderr
json_line = [ln for ln in result.stdout.splitlines() if ln.strip().startswith("{")]
assert json_line, f"No JSON in output: {result.stdout}"
data = json.loads(json_line[-1])
assert data["BRANCH_NAME"] == "000-zero"
assert data["FEATURE_NUM"] == "000"
# ── auto-commit.sh Tests ─────────────────────────────────────────────────────

View File

@@ -25,6 +25,14 @@ BASH = shutil.which("bash")
POWERSHELL = (
shutil.which("pwsh") or shutil.which("powershell.exe") or shutil.which("powershell")
)
# On Windows, prefer the built-in Windows PowerShell 5.1 (.NET Framework) when a
# test needs to exercise a 5.1-specific code path; fall back to whatever
# POWERSHELL resolves to elsewhere.
WINDOWS_POWERSHELL = (
(shutil.which("powershell.exe") or shutil.which("powershell") or POWERSHELL)
if os.name == "nt"
else POWERSHELL
)
def _write_ext_config(project_root: Path, **overrides: object) -> None:
@@ -279,12 +287,14 @@ def shlex_quote(value: str) -> str:
return "'" + value.replace("'", "'\"'\"'") + "'"
def _run_powershell_agent_context_script(project_root: Path) -> subprocess.CompletedProcess:
def _run_powershell_agent_context_script(
project_root: Path, powershell: str | None = None
) -> subprocess.CompletedProcess:
script = EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1"
env = _bundled_script_env(project_root)
return subprocess.run(
[
POWERSHELL,
powershell or POWERSHELL,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
@@ -412,6 +422,29 @@ class TestBundledUpdaterPathValidation:
assert output.count("agent-context: updated CLAUDE.md") == 1
assert "agent-context: updated agents.md" not in output
@requires_bash
def test_bash_script_discovers_nested_plan(self, tmp_path):
"""Plan discovery recurses into scoped layouts (#3024)."""
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=[],
)
plan = project / "specs" / "scope" / "001-feature" / "plan.md"
plan.parent.mkdir(parents=True)
plan.write_text("# Plan\n", encoding="utf-8")
result = _run_bash_agent_context_script(project)
assert result.returncode == 0, result.stderr + result.stdout
text = (project / "AGENTS.md").read_text(encoding="utf-8")
# The old one-level glob (specs/*/plan.md) would find nothing here, so no
# "at" line would be emitted. Normalize separators before matching: on
# MSYS bash the emitted path may be absolute with backslashes.
assert "specs/scope/001-feature/plan.md" in text.replace("\\", "/")
@requires_bash
def test_bash_script_falls_back_from_invalid_speckit_python(self, tmp_path):
project = tmp_path / "project"
@@ -484,6 +517,33 @@ class TestBundledUpdaterPathValidation:
assert output.count("agent-context: updated CLAUDE.md") == 1
assert "agent-context: updated agents.md" not in output
@pytest.mark.skipif(WINDOWS_POWERSHELL is None, reason="PowerShell not available")
def test_powershell_script_discovers_nested_plan(self, tmp_path):
"""Plan discovery recurses into scoped layouts (#3024).
The relative-path fix this covers is specific to Windows PowerShell 5.1
(.NET Framework), so prefer ``powershell.exe`` over ``pwsh`` here to
actually exercise that failure mode on Windows.
"""
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=[],
)
plan = project / "specs" / "scope" / "001-feature" / "plan.md"
plan.parent.mkdir(parents=True)
plan.write_text("# Plan\n", encoding="utf-8")
result = _run_powershell_agent_context_script(
project, powershell=WINDOWS_POWERSHELL
)
assert result.returncode == 0, result.stderr + result.stdout
text = (project / "AGENTS.md").read_text(encoding="utf-8")
assert "at specs/scope/001-feature/plan.md" in text
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
def test_powershell_script_falls_back_from_invalid_speckit_python(self, tmp_path):
project = tmp_path / "project"
@@ -688,6 +748,62 @@ class TestExtensionSelfSeed:
_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:
"""Cursor-style ``.mdc`` targets must carry ``alwaysApply: true`` frontmatter
so the rule file is auto-loaded; non-``.mdc`` targets must not gain any."""

View File

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

View File

@@ -6,7 +6,7 @@ hooks:
extension: "test-extension"
command: "pre_implement_test"
description: "Test before implement hook execution"
after_implement:
- id: post_test
enabled: true

View File

@@ -7,7 +7,7 @@ This directory contains a mock project to verify that LLM agents correctly ident
1. Open a chat with an LLM (like GitHub Copilot) in this project.
2. Ask it to generate tasks for the current directory:
> "Please follow `/speckit.tasks` for the `./tests/hooks` directory."
3. **Expected Behavior**:
3. **Expected Behavior**:
- Before doing any generation, the LLM should notice the `AUTOMATIC Pre-Hook` in `.specify/extensions.yml` under `before_tasks`.
- It should state it is executing `EXECUTE_COMMAND: pre_tasks_test`.
- It should then proceed to read the `.md` docs and produce a `tasks.md`.

View File

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

View File

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

View File

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

View File

@@ -15,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_DATA_HOME"]) == home / ".local" / "share"
# Most integrations resolve the user home via Path.home() (e.g. Hermes,
# catalog), so the isolation has to reach that API, not just the env vars.
assert Path.home() == home
assert home.is_dir()
assert (home / ".cache").is_dir()
assert (home / ".config").is_dir()

View File

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

View File

@@ -84,6 +84,20 @@ class TestCatalogURLValidation:
with pytest.raises(IntegrationCatalogError, match="valid URL"):
IntegrationCatalog._validate_catalog_url(url)
@pytest.mark.parametrize(
"url",
[
"https://[::1", # unclosed ipv6 bracket
"https://[not-an-ip]/c.json", # bracketed non-ip host
],
)
def test_malformed_url_rejected_cleanly(self, url):
# A malformed authority makes urlparse/hostname raise ValueError. The
# validator must turn that into its normal catalog error, not leak a
# raw ValueError to the caller.
with pytest.raises(IntegrationCatalogError, match="malformed"):
IntegrationCatalog._validate_catalog_url(url)
# ---------------------------------------------------------------------------
# IntegrationCatalog — active catalogs

View File

@@ -231,4 +231,3 @@ class TestCursorAgentCliDispatch:
argv = mock_run.call_args[0][0]
assert argv[0] == "cursor-agent"

View File

@@ -71,4 +71,4 @@ class TestDevinInitFlow:
)
assert result.exit_code == 0, f"init --integration devin failed: {result.output}"
assert (target / ".devin" / "skills" / "speckit-plan" / "SKILL.md").exists()
assert (target / ".devin" / "skills" / "speckit-plan" / "SKILL.md").exists()

View File

@@ -288,13 +288,13 @@ class TestForgeCommandRegistrar:
def test_registrar_formats_extension_command_names_for_forge(self, tmp_path):
"""Verify CommandRegistrar converts dot notation to hyphens for Forge."""
from specify_cli.agents import CommandRegistrar
# Create a mock extension command file
ext_dir = tmp_path / "extension"
ext_dir.mkdir()
cmd_dir = ext_dir / "commands"
cmd_dir.mkdir()
# Create a test command with dot notation name
cmd_file = cmd_dir / "example.md"
cmd_file.write_text(
@@ -304,7 +304,7 @@ class TestForgeCommandRegistrar:
"Test content with $ARGUMENTS\n",
encoding="utf-8"
)
# Register with Forge
registrar = CommandRegistrar()
commands = [
@@ -313,7 +313,7 @@ class TestForgeCommandRegistrar:
"file": "commands/example.md"
}
]
registered = registrar.register_commands(
"forge",
commands,
@@ -321,14 +321,14 @@ class TestForgeCommandRegistrar:
ext_dir,
tmp_path
)
# Verify registration succeeded
assert "speckit.my-extension.example" in registered
# Check the generated file has hyphenated name in frontmatter
forge_cmd = tmp_path / ".forge" / "commands" / "speckit-my-extension-example.md"
assert forge_cmd.exists()
content = forge_cmd.read_text(encoding="utf-8")
# Parse frontmatter to validate name field precisely
frontmatter, _ = registrar.parse_frontmatter(content)
@@ -339,13 +339,13 @@ class TestForgeCommandRegistrar:
def test_registrar_formats_alias_names_for_forge(self, tmp_path):
"""Verify CommandRegistrar converts alias names to hyphens for Forge."""
from specify_cli.agents import CommandRegistrar
# Create a mock extension command file
ext_dir = tmp_path / "extension"
ext_dir.mkdir()
cmd_dir = ext_dir / "commands"
cmd_dir.mkdir()
cmd_file = cmd_dir / "example.md"
cmd_file.write_text(
"---\n"
@@ -354,7 +354,7 @@ class TestForgeCommandRegistrar:
"Test content\n",
encoding="utf-8"
)
# Register with Forge including an alias
registrar = CommandRegistrar()
commands = [
@@ -364,7 +364,7 @@ class TestForgeCommandRegistrar:
"aliases": ["speckit.my-extension.ex"]
}
]
registrar.register_commands(
"forge",
commands,
@@ -372,11 +372,11 @@ class TestForgeCommandRegistrar:
ext_dir,
tmp_path
)
# Check the alias file has hyphenated name in frontmatter
alias_file = tmp_path / ".forge" / "commands" / "speckit-my-extension-ex.md"
assert alias_file.exists()
content = alias_file.read_text(encoding="utf-8")
# Parse frontmatter to validate alias name field precisely
frontmatter, _ = registrar.parse_frontmatter(content)
@@ -387,13 +387,13 @@ class TestForgeCommandRegistrar:
def test_registrar_does_not_affect_other_agents(self, tmp_path):
"""Verify format_name callback is Forge-specific and doesn't affect other agents."""
from specify_cli.agents import CommandRegistrar
# Create a mock extension command file
ext_dir = tmp_path / "extension"
ext_dir.mkdir()
cmd_dir = ext_dir / "commands"
cmd_dir.mkdir()
cmd_file = cmd_dir / "example.md"
cmd_file.write_text(
"---\n"
@@ -402,7 +402,7 @@ class TestForgeCommandRegistrar:
"Test content with $ARGUMENTS\n",
encoding="utf-8"
)
# Register with Kilo Code (standard markdown agent without inject_name)
registrar = CommandRegistrar()
commands = [
@@ -411,7 +411,7 @@ class TestForgeCommandRegistrar:
"file": "commands/example.md"
}
]
registrar.register_commands(
"kilocode",
commands,
@@ -419,12 +419,12 @@ class TestForgeCommandRegistrar:
ext_dir,
tmp_path
)
# Kilo Code uses standard markdown format without name injection.
# The format_name callback should not be invoked for non-Forge agents.
kilocode_cmd = tmp_path / ".kilocode" / "workflows" / "speckit.my-extension.example.md"
assert kilocode_cmd.exists()
content = kilocode_cmd.read_text(encoding="utf-8")
# Kilo Code should NOT have a name field injected
assert "name:" not in content, (

View File

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

View File

@@ -34,4 +34,4 @@ class TestVibeUserInvocable:
parsed = yaml.safe_load(parts[1])
assert parsed.get("user-invocable") is True, (
f"{f.parent.name}/SKILL.md is missing user-invocable: true in frontmatter"
)
)

View File

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

View File

@@ -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

@@ -315,6 +315,20 @@ class TestFindEntriesForUrl:
def test_empty_url_returns_empty(self):
assert find_entries_for_url("", [_github_entry()]) == []
@pytest.mark.parametrize(
"url",
[
"https://[::1", # unterminated ipv6 bracket
"https://[not-an-ip]/file", # bracketed non-ip host
],
)
def test_malformed_url_returns_empty(self, url):
# A malformed authority makes urlparse/hostname raise ValueError.
# Since no entry can match such a URL, this must return no matches
# (like a host-less URL) rather than leaking a raw ValueError out of
# the shared HTTP client.
assert find_entries_for_url(url, [_github_entry()]) == []
def test_empty_entries_returns_empty(self):
assert find_entries_for_url("https://github.com/org/repo", []) == []
@@ -845,6 +859,22 @@ class TestRedirectStripping:
auth3 = req3.get_header("Authorization") or req3.unredirected_hdrs.get("Authorization")
assert auth3 == "Bearer tok"
def test_malformed_redirect_url_raises_urlerror_not_valueerror(self):
"""A redirect to a malformed URL (unterminated IPv6 bracket) surfaces
as URLError, which download paths already handle, rather than an
unhandled ValueError traceback."""
import urllib.error
from specify_cli.authentication.http import _StripAuthOnRedirect
from urllib.request import Request
import io
handler = _StripAuthOnRedirect(("github.com",))
req = Request("https://github.com/org/repo")
with pytest.raises(urllib.error.URLError):
handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
"https://[::1/asset")
# ---------------------------------------------------------------------------
# _fetch_latest_release_tag delegation

View File

@@ -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

@@ -17,7 +17,7 @@ class TestExtensionRegistration:
"""Standard registration: Adding an extension should add it to the list."""
executor = HookExecutor(project_dir)
executor.register_extension("test-ext")
config = executor.get_project_config()
assert "installed" in config
assert config["installed"] == ["test-ext"]
@@ -28,7 +28,7 @@ class TestExtensionRegistration:
executor.register_extension("zebra-ext")
executor.register_extension("apple-ext")
executor.register_extension("middle-ext")
config = executor.get_project_config()
assert config["installed"] == ["apple-ext", "middle-ext", "zebra-ext"]
@@ -37,7 +37,7 @@ class TestExtensionRegistration:
executor = HookExecutor(project_dir)
executor.register_extension("test-ext")
executor.register_extension("test-ext")
config = executor.get_project_config()
assert config["installed"] == ["test-ext"]
assert len(config["installed"]) == 1
@@ -47,9 +47,9 @@ class TestExtensionRegistration:
executor = HookExecutor(project_dir)
executor.register_extension("ext-1")
executor.register_extension("ext-2")
executor.unregister_extension("ext-1")
config = executor.get_project_config()
assert config["installed"] == ["ext-2"]
@@ -57,10 +57,10 @@ class TestExtensionRegistration:
"""Safe Removal: Unregistering a non-existent extension should do nothing."""
executor = HookExecutor(project_dir)
executor.register_extension("ext-1")
# Should not raise or change the list
executor.unregister_extension("ext-nonexistent")
config = executor.get_project_config()
assert config["installed"] == ["ext-1"]
@@ -87,27 +87,27 @@ class TestExtensionRegistration:
manifest_path = tmp_path / "extension.yml"
with open(manifest_path, "w") as f:
yaml.dump(manifest_data, f)
manifest = ExtensionManifest(manifest_path)
executor = HookExecutor(project_dir)
# This should call register_extension internally
executor.register_hooks(manifest)
config = executor.get_project_config()
assert "hook-ext" in config["installed"]
def test_missing_installed_key_initialization(self, project_dir):
"""Graceful Initialization: If 'installed' key is missing, it should be created."""
executor = HookExecutor(project_dir)
# Manually create a config without 'installed'
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump({"settings": {"auto_execute_hooks": True}}))
# This should detect the missing key and initialize it
executor.register_extension("new-ext")
config = executor.get_project_config()
assert "installed" in config
assert config["installed"] == ["new-ext"]
@@ -135,20 +135,20 @@ class TestExtensionRegistration:
manifest_path = tmp_path / "extension.yml"
with open(manifest_path, "w") as f:
yaml.dump(manifest_data, f)
manifest = ExtensionManifest(manifest_path)
executor = HookExecutor(project_dir)
# Register hooks first
executor.register_hooks(manifest)
config = executor.get_project_config()
assert "hook-ext" in config["installed"]
assert "after_tasks" in config["hooks"]
# Now unregister hooks
executor.unregister_hooks("hook-ext")
config = executor.get_project_config()
assert "hook-ext" not in config["installed"]
# unregister_hooks() removes the empty hook array entirely, so the key is absent
@@ -157,16 +157,16 @@ class TestExtensionRegistration:
def test_unregister_hooks_no_hooks_key(self, project_dir):
"""Resilience: unregister_hooks should work even if config has no 'hooks' key."""
executor = HookExecutor(project_dir)
# Register extension without hooks
executor.register_extension("ext-no-hooks")
config = executor.get_project_config()
assert "ext-no-hooks" in config["installed"]
# Unregister should not crash even if no hooks key exists
executor.unregister_hooks("ext-no-hooks")
config = executor.get_project_config()
assert "ext-no-hooks" not in config["installed"]
@@ -175,12 +175,12 @@ class TestExtensionRegistration:
# Create a corrupted config (root is a list)
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump(["corrupted", "list"]))
executor = HookExecutor(project_dir)
# Should not raise even with corrupted config
executor.unregister_hooks("non-existent")
# Config should remain as-is or be handled gracefully
config = executor.get_project_config()
# If it's corrupted, it's returned as-is or handled by defensive logic
@@ -223,30 +223,30 @@ class TestExtensionRegistration:
"after_tasks": {"command": "speckit.ext-2.run"}
}
}
manifest_path_1 = tmp_path / "extension1.yml"
manifest_path_2 = tmp_path / "extension2.yml"
with open(manifest_path_1, "w") as f:
yaml.dump(manifest_data_1, f)
with open(manifest_path_2, "w") as f:
yaml.dump(manifest_data_2, f)
manifest1 = ExtensionManifest(manifest_path_1)
manifest2 = ExtensionManifest(manifest_path_2)
executor = HookExecutor(project_dir)
# Register both extensions
executor.register_hooks(manifest1)
executor.register_hooks(manifest2)
config = executor.get_project_config()
assert "ext-1" in config["installed"]
assert "ext-2" in config["installed"]
assert len(config["hooks"]["after_tasks"]) == 2
# Unregister first extension
executor.unregister_hooks("ext-1")
config = executor.get_project_config()
assert "ext-1" not in config["installed"]
assert "ext-2" in config["installed"]
@@ -346,9 +346,9 @@ class TestExtensionRegistration:
manifest_path = tmp_path / "extension.yml"
with open(manifest_path, "w") as f:
yaml.dump(manifest_data, f)
manifest = ExtensionManifest(manifest_path)
# Should not raise TypeError when trying to append to None
executor.register_hooks(manifest)
@@ -378,16 +378,16 @@ class TestExtensionRegistration:
"""Review Feedback: register_extension should support and preserve dict entries."""
executor = HookExecutor(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
# Setup config with a pinned extension (dict)
pinned_ext = {"id": "pinned-ext", "version": "1.0.0"}
config_path.write_text(yaml.dump({
"installed": [pinned_ext, "string-ext"]
}))
# Register a new extension
executor.register_extension("new-ext")
config = executor.get_project_config()
# Should contain all three, sorted by id: new-ext, pinned-ext, string-ext
assert config["installed"] == ["new-ext", pinned_ext, "string-ext"]
@@ -396,15 +396,15 @@ class TestExtensionRegistration:
"""Review Feedback: unregister_extension should support removing matching dict entries."""
executor = HookExecutor(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
pinned_ext = {"id": "to-remove", "version": "1.0.0"}
config_path.write_text(yaml.dump({
"installed": [pinned_ext, "other-ext"]
}))
# Unregister by ID
executor.unregister_extension("to-remove")
config = executor.get_project_config()
assert config["installed"] == ["other-ext"]
@@ -412,14 +412,14 @@ class TestExtensionRegistration:
"""Hardening: unregister_extension should handle non-list installed key."""
executor = HookExecutor(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump({
"installed": "not-a-list"
}))
# Should not crash and should normalize to []
executor.unregister_extension("any-ext")
config = executor.get_project_config()
assert config["installed"] == []
def test_register_hooks_mixed_type_hook_list(self, project_dir, tmp_path):
@@ -458,7 +458,7 @@ class TestExtensionRegistration:
config = executor.get_project_config()
hooks = config["hooks"]["after_tasks"]
# Should have 2 valid dict hooks, and 0 non-dict items
assert len(hooks) == 2
assert all(isinstance(h, dict) for h in hooks)
@@ -469,12 +469,12 @@ class TestExtensionRegistration:
"""Hardening: unregister_extension should handle scalar root config."""
executor = HookExecutor(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump(123))
# Should not crash and should normalize to {}
executor.unregister_extension("any-ext")
config = executor.get_project_config()
assert isinstance(config, dict)
assert config["installed"] == []
@@ -483,15 +483,15 @@ class TestExtensionRegistration:
"""Regression: unregister_hooks() must handle scalar hook event values."""
executor = HookExecutor(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump({
"installed": ["some-ext"],
"hooks": {"after_tasks": 123}
}))
# Should not raise TypeError when iterating
executor.unregister_hooks("some-ext")
config = executor.get_project_config()
assert "some-ext" not in config["installed"]
assert "after_tasks" not in config["hooks"]

View File

@@ -20,28 +20,28 @@ def test_extension_update_corrupted_config_root(project_dir, monkeypatch):
"""Regression: extension update must handle corrupted extensions.yml (root is scalar)."""
# chdir into project_dir so _require_specify_project() succeeds
monkeypatch.chdir(project_dir)
# Corrupt extensions.yml
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump(123))
# Mock ExtensionManager to return an installed extension for resolution
monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}])
monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True})
monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"})
# Mock download_extension to avoid network calls; use tmp_path so the test is hermetic
# and returns a Path so zip_path.exists() / zip_path.unlink() work without AttributeError
mock_zip = project_dir / "mock.zip"
monkeypatch.setattr(ExtensionCatalog, "download_extension", lambda self, ext_id: mock_zip)
# Mock confirmation to true
monkeypatch.setattr("typer.confirm", lambda _: True)
# Run update
result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir})
# extension_update() catches exceptions internally and exits with code 1 on failure.
assert result.exit_code == 1
assert "AttributeError" not in result.output
@@ -50,13 +50,13 @@ def test_extension_update_corrupted_config_root(project_dir, monkeypatch):
def test_extension_update_corrupted_hooks_value(project_dir, monkeypatch):
"""Regression: extension update must handle non-dict 'hooks' in extensions.yml."""
monkeypatch.chdir(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump({
"installed": ["test-ext"],
"hooks": ["not", "a", "dict"]
}))
monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}])
monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True})
monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"})
@@ -64,9 +64,9 @@ def test_extension_update_corrupted_hooks_value(project_dir, monkeypatch):
mock_zip = project_dir / "mock.zip"
monkeypatch.setattr(ExtensionCatalog, "download_extension", lambda self, ext_id: mock_zip)
monkeypatch.setattr("typer.confirm", lambda _: True)
result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir})
# extension_update() catches exceptions internally and exits with code 1 on failure.
assert result.exit_code == 1
assert "AttributeError" not in result.output
@@ -75,33 +75,33 @@ def test_extension_update_corrupted_hooks_value(project_dir, monkeypatch):
def test_extension_update_rollback_corrupted_config(project_dir, monkeypatch):
"""Regression: extension update rollback must handle corrupted extensions.yml."""
monkeypatch.chdir(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
# Write config with hooks: null; get_project_config() normalizes this to {}
# so the backup captures {} and the restored config will have hooks: {}.
config_path.write_text(yaml.dump({"installed": ["test-ext"], "hooks": None}))
# Mock update process to fail after backup
monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}])
monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True})
# Force failure in download_extension to trigger rollback
def mock_download_fail(*args, **kwargs):
# Corrupt the config BEFORE rollback is triggered
config_path.write_text(yaml.dump("CORRUPTED"))
raise Exception("Download failed")
monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"})
monkeypatch.setattr(ExtensionCatalog, "download_extension", mock_download_fail)
monkeypatch.setattr("typer.confirm", lambda _: True)
result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir})
# Should handle Exception and NOT crash with AttributeError during rollback
assert result.exit_code == 1
assert "Download failed" in result.output
assert not isinstance(result.exception, AttributeError)
# Verify hooks key was preserved (normalized to {} if it was null/corrupted)
restored_config = yaml.safe_load(config_path.read_text())
assert isinstance(restored_config, dict)

View File

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

View File

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

View File

@@ -10,12 +10,12 @@ def test_merge_json_files_type_mismatch_preservation(tmp_path):
existing_file = tmp_path / "settings.json"
# User might have overridden a setting with a simple string or different type
existing_file.write_text('{"chat.editor.fontFamily": "CustomFont"}')
# Template might expect a dict for the same key (hypothetically)
new_settings = {
"chat.editor.fontFamily": {"font": "TemplateFont"}
}
merged = merge_json_files(existing_file, new_settings)
# Result is None because user settings were preserved and nothing else changed
assert merged is None
@@ -32,7 +32,7 @@ def test_merge_json_files_deep_nesting(tmp_path):
}
}
""")
new_settings = {
"a": {
"b": {
@@ -41,7 +41,7 @@ def test_merge_json_files_deep_nesting(tmp_path):
"e": 3 # New mid-level key
}
}
merged = merge_json_files(existing_file, new_settings)
assert merged["a"]["b"]["c"] == 1
assert merged["a"]["b"]["d"] == 2
@@ -51,7 +51,7 @@ def test_merge_json_files_empty_existing(tmp_path):
"""Merging into an empty/new file."""
existing_file = tmp_path / "empty.json"
existing_file.write_text("{}")
new_settings = {"a": 1}
merged = merge_json_files(existing_file, new_settings)
assert merged == {"a": 1}
@@ -74,7 +74,7 @@ def test_merge_vscode_realistic_scenario(tmp_path):
} // User comment
}
""")
template_settings = {
"chat.promptFilesRecommendations": {
"speckit.specify": True,
@@ -84,14 +84,14 @@ def test_merge_vscode_realistic_scenario(tmp_path):
".specify/scripts/bash/": True
}
}
merged = merge_json_files(existing_file, template_settings)
# Check preservation
assert merged["editor.fontSize"] == 12
assert merged["files.exclude"]["**/.git"] is True
assert merged["chat.promptFilesRecommendations"]["existing.tool"] is True
# Check additions
assert merged["chat.promptFilesRecommendations"]["speckit.specify"] is True
assert merged["chat.tools.terminal.autoApprove"][".specify/scripts/bash/"] is True
@@ -104,7 +104,7 @@ def test_merge_json_files_with_bom(tmp_path):
content = '{"a": 1}'
# Prepend UTF-8 BOM
existing_file.write_bytes(b'\xef\xbb\xbf' + content.encode('utf-8'))
new_settings = {"b": 2}
merged = merge_json_files(existing_file, new_settings)
assert merged == {"a": 1, "b": 2}
@@ -113,7 +113,7 @@ def test_merge_json_files_not_a_dictionary_template(tmp_path):
"""If for some reason new_content is not a dict, PRESERVE existing settings by returning None."""
existing_file = tmp_path / "ok.json"
existing_file.write_text('{"a": 1}')
# Secure fallback: return None to skip writing and avoid clobbering
assert merge_json_files(existing_file, ["not", "a", "dict"]) is None
@@ -121,7 +121,7 @@ def test_merge_json_files_unparseable_existing(tmp_path):
"""If the existing file is unparseable JSON, return None to avoid overwriting it."""
bad_file = tmp_path / "bad.json"
bad_file.write_text('{"a": 1, missing_value}') # Invalid JSON
assert merge_json_files(bad_file, {"b": 2}) is None
@@ -129,11 +129,11 @@ def test_merge_json_files_list_preservation(tmp_path):
"""Verify that existing list values are preserved and NOT merged or overwritten."""
existing_file = tmp_path / "list.json"
existing_file.write_text('{"my.list": ["user_item"]}')
template_settings = {
"my.list": ["template_item"]
}
merged = merge_json_files(existing_file, template_settings)
# The polite merge policy says: keep existing values if they exist and aren't both dicts.
# Since nothing changed, it returns None.
@@ -143,12 +143,12 @@ def test_merge_json_files_no_changes(tmp_path):
"""If the merge doesn't introduce any new keys or changes, return None to skip rewrite."""
existing_file = tmp_path / "no_change.json"
existing_file.write_text('{"a": 1, "b": {"c": 2}}')
template_settings = {
"a": 1, # Already exists
"b": {"c": 2} # Already exists nested
}
# Should return None because result == existing
assert merge_json_files(existing_file, template_settings) is None
@@ -156,11 +156,11 @@ def test_merge_json_files_type_mismatch_no_op(tmp_path):
"""If a key exists with different type and we preserve it, it might still result in no change."""
existing_file = tmp_path / "mismatch_no_op.json"
existing_file.write_text('{"a": "user_string"}')
template_settings = {
"a": {"key": "template_dict"} # Mismatch, will be ignored
}
# Should return None because we preserved the user's string and nothing else changed
assert merge_json_files(existing_file, template_settings) is None

View File

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

View File

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

View File

@@ -1,15 +1,15 @@
"""Tests for setup-tasks.{sh,ps1} template resolution and feature resolution."""
import json
import os
import shutil
import subprocess
from pathlib import Path
import pytest
from tests.conftest import requires_bash
PROJECT_ROOT = Path(__file__).resolve().parent.parent
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
SETUP_TASKS_SH = PROJECT_ROOT / "scripts" / "bash" / "setup-tasks.sh"
@@ -18,38 +18,38 @@ COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
SETUP_TASKS_PS = PROJECT_ROOT / "scripts" / "powershell" / "setup-tasks.ps1"
CHECK_PREREQ_PS = PROJECT_ROOT / "scripts" / "powershell" / "check-prerequisites.ps1"
TASKS_TEMPLATE = PROJECT_ROOT / "templates" / "tasks-template.md"
HAS_PWSH = shutil.which("pwsh") is not None
_WINDOWS_POWERSHELL = (shutil.which("powershell.exe") or shutil.which("powershell")) if os.name == "nt" else None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _install_bash_scripts(repo: Path) -> None:
d = repo / ".specify" / "scripts" / "bash"
d.mkdir(parents=True, exist_ok=True)
shutil.copy(COMMON_SH, d / "common.sh")
shutil.copy(SETUP_TASKS_SH, d / "setup-tasks.sh")
shutil.copy(CHECK_PREREQ_SH, d / "check-prerequisites.sh")
def _install_ps_scripts(repo: Path) -> None:
d = repo / ".specify" / "scripts" / "powershell"
d.mkdir(parents=True, exist_ok=True)
shutil.copy(COMMON_PS, d / "common.ps1")
shutil.copy(SETUP_TASKS_PS, d / "setup-tasks.ps1")
shutil.copy(CHECK_PREREQ_PS, d / "check-prerequisites.ps1")
def _install_core_tasks_template(repo: Path) -> None:
"""Copy the real tasks-template.md into the core template location."""
tdir = repo / ".specify" / "templates"
tdir.mkdir(parents=True, exist_ok=True)
shutil.copy(TASKS_TEMPLATE, tdir / "tasks-template.md")
def _write_feature_json(
repo: Path, feature_directory: str = "specs/001-my-feature"
) -> None:
@@ -90,8 +90,8 @@ def _write_integration_state(repo: Path, integration: str = "claude", separator:
json.dumps(state),
encoding="utf-8",
)
def _clean_env() -> dict[str, str]:
"""
Return os.environ with all SPECIFY_* variables stripped so the scripts
@@ -102,8 +102,8 @@ def _clean_env() -> dict[str, str]:
if key.startswith("SPECIFY_"):
env.pop(key)
return env
def _run_bash_format_command(repo: Path, command_name: str) -> subprocess.CompletedProcess:
script = repo / ".specify" / "scripts" / "bash" / "common.sh"
return subprocess.run(
@@ -145,12 +145,12 @@ def _git_init(repo: Path) -> None:
subprocess.run(
["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=repo, check=True
)
# ---------------------------------------------------------------------------
# Shared fixture
# ---------------------------------------------------------------------------
@pytest.fixture
def tasks_repo(tmp_path: Path) -> Path:
"""
@@ -162,7 +162,7 @@ def tasks_repo(tmp_path: Path) -> Path:
repo = tmp_path / "proj"
repo.mkdir()
_git_init(repo)
# Keep a numbered branch name in this repo fixture; setup-tasks now resolves
# feature directories from repository state rather than validating git branches.
subprocess.run(
@@ -170,18 +170,18 @@ def tasks_repo(tmp_path: Path) -> Path:
cwd=repo,
check=True,
)
(repo / ".specify").mkdir()
_install_core_tasks_template(repo)
_install_bash_scripts(repo)
_install_ps_scripts(repo)
return repo
# ===========================================================================
# BASH TESTS
# ===========================================================================
@requires_bash
def test_setup_tasks_bash_core_template_resolved(tasks_repo: Path) -> None:
"""
@@ -191,7 +191,7 @@ def test_setup_tasks_bash_core_template_resolved(tasks_repo: Path) -> None:
"""
_minimal_feature(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=tasks_repo,
@@ -200,16 +200,16 @@ def test_setup_tasks_bash_core_template_resolved(tasks_repo: Path) -> None:
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
data = json.loads(result.stdout)
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file"
assert tasks_tmpl.name == "tasks-template.md"
@requires_bash
def test_setup_tasks_bash_override_wins(tasks_repo: Path) -> None:
"""
@@ -217,15 +217,15 @@ def test_setup_tasks_bash_override_wins(tasks_repo: Path) -> None:
setup-tasks.sh --json must return the override path, not the core path.
"""
_minimal_feature(tasks_repo)
# Create the override
overrides_dir = tasks_repo / ".specify" / "templates" / "overrides"
overrides_dir.mkdir(parents=True, exist_ok=True)
override_file = overrides_dir / "tasks-template.md"
override_file.write_text("# override tasks template\n", encoding="utf-8")
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=tasks_repo,
@@ -234,9 +234,9 @@ def test_setup_tasks_bash_override_wins(tasks_repo: Path) -> None:
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
data = json.loads(result.stdout)
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
@@ -245,8 +245,8 @@ def test_setup_tasks_bash_override_wins(tasks_repo: Path) -> None:
assert "overrides" in tasks_tmpl.parts, (
f"Expected override path but got: {tasks_tmpl}"
)
@requires_bash
def test_setup_tasks_bash_extension_wins_over_core(tasks_repo: Path) -> None:
"""
@@ -254,7 +254,7 @@ def test_setup_tasks_bash_extension_wins_over_core(tasks_repo: Path) -> None:
tasks-template.md from the extension before falling back to the core path.
"""
_minimal_feature(tasks_repo)
# FIX: real extension layout is .specify/extensions/<id>/templates/<name>.md
extension_dir = (
tasks_repo / ".specify" / "extensions" / "test-extension" / "templates"
@@ -262,9 +262,9 @@ def test_setup_tasks_bash_extension_wins_over_core(tasks_repo: Path) -> None:
extension_dir.mkdir(parents=True, exist_ok=True)
extension_file = extension_dir / "tasks-template.md"
extension_file.write_text("# extension tasks template\n", encoding="utf-8")
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=tasks_repo,
@@ -273,9 +273,9 @@ def test_setup_tasks_bash_extension_wins_over_core(tasks_repo: Path) -> None:
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
data = json.loads(result.stdout)
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
@@ -283,8 +283,8 @@ def test_setup_tasks_bash_extension_wins_over_core(tasks_repo: Path) -> None:
assert tasks_tmpl == extension_file.resolve(), (
f"Expected extension path but got: {tasks_tmpl}"
)
@requires_bash
def test_setup_tasks_bash_preset_wins_over_extension(tasks_repo: Path) -> None:
"""
@@ -292,7 +292,7 @@ def test_setup_tasks_bash_preset_wins_over_extension(tasks_repo: Path) -> None:
resolve the preset path because presets outrank extensions.
"""
_minimal_feature(tasks_repo)
# FIX: real extension layout is .specify/extensions/<id>/templates/<name>.md
extension_dir = (
tasks_repo / ".specify" / "extensions" / "test-extension" / "templates"
@@ -300,15 +300,15 @@ def test_setup_tasks_bash_preset_wins_over_extension(tasks_repo: Path) -> None:
extension_dir.mkdir(parents=True, exist_ok=True)
extension_file = extension_dir / "tasks-template.md"
extension_file.write_text("# extension tasks template\n", encoding="utf-8")
# FIX: real preset layout is .specify/presets/<id>/templates/<name>.md
preset_dir = tasks_repo / ".specify" / "presets" / "test-preset" / "templates"
preset_dir.mkdir(parents=True, exist_ok=True)
preset_file = preset_dir / "tasks-template.md"
preset_file.write_text("# preset tasks template\n", encoding="utf-8")
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=tasks_repo,
@@ -317,9 +317,9 @@ def test_setup_tasks_bash_preset_wins_over_extension(tasks_repo: Path) -> None:
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
data = json.loads(result.stdout)
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
@@ -327,8 +327,8 @@ def test_setup_tasks_bash_preset_wins_over_extension(tasks_repo: Path) -> None:
assert tasks_tmpl == preset_file.resolve(), (
f"Expected preset path but got: {tasks_tmpl}"
)
@requires_bash
def test_setup_tasks_bash_preset_priority_order(tasks_repo: Path) -> None:
"""
@@ -336,7 +336,7 @@ def test_setup_tasks_bash_preset_priority_order(tasks_repo: Path) -> None:
.specify/presets/.registry wins.
"""
_minimal_feature(tasks_repo)
# resolve_template reads .specify/presets/.registry as a JSON object with a
# "presets" map where each entry has a numeric "priority" (lower = higher
# precedence). Create two presets; priority-1-preset wins over priority-2-preset.
@@ -349,7 +349,7 @@ def test_setup_tasks_bash_preset_priority_order(tasks_repo: Path) -> None:
low_priority_dir = (
tasks_repo / ".specify" / "presets" / "priority-2-preset" / "templates"
)
low_priority_dir.mkdir(parents=True, exist_ok=True)
low_priority_file = low_priority_dir / "tasks-template.md"
low_priority_file.write_text("# low priority preset tasks template\n", encoding="utf-8")
@@ -366,9 +366,9 @@ def test_setup_tasks_bash_preset_priority_order(tasks_repo: Path) -> None:
}),
encoding="utf-8",
)
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=tasks_repo,
@@ -377,9 +377,9 @@ def test_setup_tasks_bash_preset_priority_order(tasks_repo: Path) -> None:
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
data = json.loads(result.stdout)
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
@@ -387,8 +387,8 @@ def test_setup_tasks_bash_preset_priority_order(tasks_repo: Path) -> None:
assert tasks_tmpl == high_priority_file.resolve(), (
f"Expected high-priority preset path but got: {tasks_tmpl}"
)
@requires_bash
def test_setup_tasks_bash_missing_template_errors(tasks_repo: Path) -> None:
"""
@@ -396,13 +396,13 @@ def test_setup_tasks_bash_missing_template_errors(tasks_repo: Path) -> None:
exit non-zero and print a helpful ERROR message to stderr.
"""
_minimal_feature(tasks_repo)
# Remove the core template so no template exists anywhere
core = tasks_repo / ".specify" / "templates" / "tasks-template.md"
core.unlink()
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=tasks_repo,
@@ -415,8 +415,8 @@ def test_setup_tasks_bash_missing_template_errors(tasks_repo: Path) -> None:
assert result.returncode != 0
assert "ERROR" in result.stderr
assert "tasks-template" in result.stderr
@requires_bash
def test_bash_command_hint_defaults_to_dot_without_integration_json(tasks_repo: Path) -> None:
integration_json = tasks_repo / ".specify" / "integration.json"
@@ -466,6 +466,68 @@ def test_bash_command_hint_preserves_hyphens_inside_segments(tasks_repo: Path) -
assert result.stdout.strip() == "/speckit.jira.sync-status"
def _install_broken_json_tool_stubs(repo: Path) -> Path:
"""Create a bin dir with `jq` and `python3` stubs that exist but fail.
Mimics stock Windows + Git Bash, where a JSON tool may be missing or broken
and `python3` resolves to the Microsoft Store App Execution Alias stub: both
satisfy `command -v` yet fail at runtime (the alias exits 49). Prepending
this to PATH forces the invoke-separator parser past jq and python3 to its
awk text fallback (#3304).
"""
stub_dir = repo / "_broken_bin"
stub_dir.mkdir(exist_ok=True)
for name in ("jq", "python3"):
stub = stub_dir / name
stub.write_text(
"#!/bin/sh\n"
'echo "simulated broken interpreter/tool" >&2\n'
"exit 49\n",
encoding="utf-8",
newline="\n",
)
stub.chmod(0o755)
return stub_dir
@requires_bash
def test_bash_command_hint_falls_back_to_awk_when_jq_and_python3_broken(
tasks_repo: Path,
) -> None:
"""Separator resolution survives broken jq and python3 stubs (#3304).
`get_invoke_separator` historically selected python3 by availability and
had no text fallback, so a Windows Store python3 stub made it silently
return "." even for `-`-separator integrations (e.g. forge), yielding a
wrong hint like `/speckit.plan`. The awk fallback must recover `-`.
"""
_write_integration_state(tasks_repo, "forge", "-")
stub_dir = _install_broken_json_tool_stubs(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "bash" / "common.sh"
env = _clean_env()
env["PATH"] = f"{stub_dir}{os.pathsep}{env.get('PATH', '')}"
result = subprocess.run(
[
"bash",
"-c",
'source "$1"; format_speckit_command "$2" "$PWD"',
"bash",
str(script),
"plan",
],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=env,
)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "/speckit-plan"
@requires_bash
def test_bash_command_hint_caches_invoke_separator_per_process(tasks_repo: Path) -> None:
_write_integration_state(tasks_repo, "claude", "-")
@@ -557,15 +619,15 @@ def test_setup_tasks_bash_passes_custom_branch_when_feature_json_valid(
cwd=tasks_repo,
check=True,
)
feat = tasks_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
_write_feature_json(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=tasks_repo,
@@ -574,10 +636,10 @@ def test_setup_tasks_bash_passes_custom_branch_when_feature_json_valid(
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
@requires_bash
def test_setup_tasks_bash_errors_without_feature_context(
tasks_repo: Path,
@@ -601,11 +663,11 @@ def test_setup_tasks_bash_errors_without_feature_context(
assert result.returncode != 0
assert "Feature directory not found" in result.stderr
# ===========================================================================
# POWERSHELL TESTS
# ===========================================================================
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_setup_tasks_ps_core_template_resolved(tasks_repo: Path) -> None:
"""
@@ -616,7 +678,7 @@ def test_setup_tasks_ps_core_template_resolved(tasks_repo: Path) -> None:
_minimal_feature(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=tasks_repo,
@@ -625,16 +687,16 @@ def test_setup_tasks_ps_core_template_resolved(tasks_repo: Path) -> None:
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
data = json.loads(result.stdout)
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file"
assert tasks_tmpl.name == "tasks-template.md"
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_setup_tasks_ps_override_wins(tasks_repo: Path) -> None:
"""
@@ -642,15 +704,15 @@ def test_setup_tasks_ps_override_wins(tasks_repo: Path) -> None:
setup-tasks.ps1 -Json must return the override path, not the core path.
"""
_minimal_feature(tasks_repo)
overrides_dir = tasks_repo / ".specify" / "templates" / "overrides"
overrides_dir.mkdir(parents=True, exist_ok=True)
override_file = overrides_dir / "tasks-template.md"
override_file.write_text("# override tasks template\n", encoding="utf-8")
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=tasks_repo,
@@ -659,9 +721,9 @@ def test_setup_tasks_ps_override_wins(tasks_repo: Path) -> None:
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
data = json.loads(result.stdout)
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
@@ -669,8 +731,8 @@ def test_setup_tasks_ps_override_wins(tasks_repo: Path) -> None:
assert "overrides" in tasks_tmpl.parts, (
f"Expected override path but got: {tasks_tmpl}"
)
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_setup_tasks_ps_missing_template_errors(tasks_repo: Path) -> None:
"""
@@ -678,13 +740,13 @@ def test_setup_tasks_ps_missing_template_errors(tasks_repo: Path) -> None:
exit non-zero and write a helpful error to stderr.
"""
_minimal_feature(tasks_repo)
core = tasks_repo / ".specify" / "templates" / "tasks-template.md"
core.unlink()
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=tasks_repo,
@@ -693,11 +755,11 @@ def test_setup_tasks_ps_missing_template_errors(tasks_repo: Path) -> None:
check=False,
env=_clean_env(),
)
assert result.returncode != 0
assert "tasks-template" in result.stderr.lower() or "tasks-template" in result.stdout.lower()
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_powershell_command_hint_normalizes_mixed_separators(
tasks_repo: Path,
@@ -793,16 +855,16 @@ def test_setup_tasks_ps_passes_custom_branch_when_feature_json_valid(
cwd=tasks_repo,
check=True,
)
feat = tasks_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
_write_feature_json(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=tasks_repo,
@@ -811,10 +873,10 @@ def test_setup_tasks_ps_passes_custom_branch_when_feature_json_valid(
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_setup_tasks_ps_errors_without_feature_context(
tasks_repo: Path,

View File

@@ -1321,4 +1321,3 @@ class TestDescriptionQuoting:
"""Plain description without special characters continues to work."""
result = run_script(git_repo, "--dry-run", "--short-name", "feat", "Add login feature")
assert result.returncode == 0, result.stderr

View File

@@ -601,6 +601,73 @@ class TestExpressions:
):
evaluate_expression("{{ inputs.tags | map }}", ctx)
def test_chained_filters_apply_left_to_right(self):
# Filters chain: each filter's result feeds the next. `map` yields a
# list and `join` is the only filter that renders a list to a string,
# so `map('name') | join(', ')` is the canonical pairing — it must not
# raise. Previously the pipe parser split only at the first `|` and
# handed the whole tail (`map('name') | join(', ')`) to one filter,
# which the `name(arg)` regex mangled into a ValueError.
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(
inputs={
"rows": [{"name": "a"}, {"name": "b"}],
"tags": ["x", "y"],
"missing": None,
}
)
assert (
evaluate_expression(
"{{ inputs.rows | map('name') | join(', ') }}", ctx
)
== "a, b"
)
# A three-link chain: map -> join -> contains.
assert (
evaluate_expression(
"{{ inputs.rows | map('name') | join(', ') | contains('a') }}",
ctx,
)
is True
)
# default's fallback then flows into the next filter.
assert (
evaluate_expression(
"{{ inputs.missing | default('x') | contains('x') }}", ctx
)
is True
)
def test_chained_filter_error_in_later_link_raises(self):
# A mis-wired filter anywhere in the chain must fail loudly, not just
# the first link.
import pytest
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(inputs={"rows": [{"name": "a"}]})
with pytest.raises(ValueError, match="unknown filter 'bogus'"):
evaluate_expression(
"{{ inputs.rows | map('name') | bogus }}", ctx
)
def test_pipe_in_quoted_arg_is_not_a_filter_separator(self):
# A literal `|` inside a quoted operand or filter argument must not be
# mistaken for a filter-chain separator — the top-level split has to
# respect quotes.
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(inputs={"mode": "a|b", "tags": ["a|b", "c"]})
assert evaluate_expression("{{ inputs.mode == 'a|b' }}", ctx) is True
# `|` inside a filter argument stays part of the argument.
assert (
evaluate_expression("{{ inputs.tags | join(' | ') }}", ctx)
== "a|b | c"
)
def test_condition_evaluation(self):
from specify_cli.workflows.expressions import evaluate_condition
from specify_cli.workflows.base import StepContext
@@ -1286,6 +1353,106 @@ class TestShellStep:
assert step.validate({"id": "s", "run": "echo hi"}) == []
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):
from specify_cli.workflows.steps.shell import ShellStep
@@ -2627,6 +2794,101 @@ steps:
errors = validate_workflow(definition)
assert any("lowercase alphanumeric" in e for e in errors)
def test_non_string_workflow_id_reports_error(self):
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
definition = WorkflowDefinition.from_string("""
workflow:
id: 123
name: "Test"
version: "1.0.0"
steps:
- id: step-one
command: speckit.specify
""")
errors = validate_workflow(definition)
assert any("workflow.id" in e and "string" in e for e in errors)
def test_non_string_name_reports_error(self):
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
definition = WorkflowDefinition.from_string("""
workflow:
id: "test"
name: 123
version: "1.0.0"
steps:
- id: step-one
command: speckit.specify
""")
errors = validate_workflow(definition)
assert any("workflow.name" in e and "string" in e for e in errors)
def test_unquoted_float_version_reports_error(self):
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
definition = WorkflowDefinition.from_string("""
workflow:
id: "test"
name: "Test"
version: 1.0
steps:
- id: step-one
command: speckit.specify
""")
errors = validate_workflow(definition)
assert any("workflow.version" in e and "quote" in e for e in errors)
def test_non_string_step_id_reports_error(self):
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
definition = WorkflowDefinition.from_string("""
workflow:
id: "test"
name: "Test"
version: "1.0.0"
steps:
- id: 123
command: speckit.specify
""")
errors = validate_workflow(definition)
assert any("Step ID" in e and "string" in e for e in errors)
def test_falsey_non_string_scalars_report_typed_errors(self):
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
definition = WorkflowDefinition.from_string("""
workflow:
id: 0
name: false
version: 0.0
steps:
- id: 0
command: speckit.specify
""")
errors = validate_workflow(definition)
assert any("'workflow.id' must be a string" in e for e in errors)
assert any("'workflow.name' must be a string" in e for e in errors)
assert any("'workflow.version' must be a string" in e for e in errors)
assert any("Step ID must be a string" in e for e in errors)
assert not any("missing" in e for e in errors)
def test_unquoted_schema_version_accepted(self):
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
definition = WorkflowDefinition.from_string("""
schema_version: 1.0
workflow:
id: "test"
name: "Test"
version: "1.0.0"
steps:
- id: step-one
command: speckit.specify
""")
errors = validate_workflow(definition)
assert errors == []
def test_no_steps(self):
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
@@ -5593,6 +5855,23 @@ class TestWorkflowRemoveGuard:
class TestWorkflowAddSymlinkGuard:
def test_add_malformed_ipv6_url_exits_cleanly(self, temp_dir, monkeypatch):
"""A malformed IPv6 URL must produce a clean error, not a ValueError traceback."""
from typer.testing import CliRunner
from specify_cli import app
(temp_dir / ".specify").mkdir(exist_ok=True)
monkeypatch.chdir(temp_dir)
result = CliRunner().invoke(
app,
["workflow", "add", "https://[::1/wf.yaml"],
catch_exceptions=True,
)
assert result.exit_code == 1
assert result.exception is None or isinstance(result.exception, SystemExit)
assert "Invalid URL" in result.output
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
def test_add_refuses_symlinked_specify(self, temp_dir, monkeypatch):
"""workflow add must refuse a symlinked .specify (writes could escape root)."""
@@ -6865,3 +7144,53 @@ steps:
},
)
assert _gate_outcome(state) is None
class TestWorkflowAddNonStringScalars:
"""`workflow add` reports clean errors for non-string YAML scalars (#3420)."""
@pytest.mark.parametrize(
("field_yaml", "expected"),
[
('id: 123\n name: "Probe"\n version: "1.0.0"', "workflow.id"),
('id: "probe"\n name: "Probe"\n version: 1.0', "workflow.version"),
],
)
def test_add_reports_validation_error_not_traceback(
self, project_dir, monkeypatch, field_yaml, expected
):
from typer.testing import CliRunner
from specify_cli import app
monkeypatch.chdir(project_dir)
wf = project_dir / "workflow.yml"
wf.write_text(
"schema_version: \"1.0\"\n"
f"workflow:\n {field_yaml}\n"
"steps:\n - id: s1\n type: shell\n run: \"echo hi\"\n",
encoding="utf-8",
)
runner = CliRunner()
result = runner.invoke(app, ["workflow", "add", str(wf)])
assert result.exit_code == 1
assert result.exception is None or isinstance(result.exception, SystemExit)
assert expected in result.output
def test_add_non_string_step_id_reports_validation_error(
self, project_dir, monkeypatch
):
from typer.testing import CliRunner
from specify_cli import app
monkeypatch.chdir(project_dir)
wf = project_dir / "workflow.yml"
wf.write_text(
"workflow:\n id: \"probe\"\n name: \"Probe\"\n version: \"1.0.0\"\n"
"steps:\n - id: 123\n type: shell\n run: \"echo hi\"\n",
encoding="utf-8",
)
runner = CliRunner()
result = runner.invoke(app, ["workflow", "add", str(wf)])
assert result.exit_code == 1
assert result.exception is None or isinstance(result.exception, SystemExit)
assert "Step ID" in result.output

Some files were not shown because too many files have changed in this diff Show More