Compare commits

...

21 Commits

Author SHA1 Message Date
github-actions[bot]
56515ff610 chore: bump version to 0.12.12 2026-07-13 15:02:40 +00:00
Ali jawwad
903d707d21 fix(extensions): set-priority repairs corrupted boolean priority (#3268)
The set-priority skip guard 'isinstance(raw_priority, int) and
raw_priority == priority' treats a stored boolean as a match because
isinstance(True, int) is True and True == 1 (False == 0). So a corrupted
boolean priority short-circuits to 'already has priority N' and is never
rewritten to a real int — contradicting the adjacent comment that
promises corrupted values get repaired. Exclude bools explicitly,
mirroring normalize_priority's own bool guard.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 09:41:47 -05:00
Ali jawwad
f6f3540409 fix(presets): set-priority repairs corrupted boolean priority (#3269)
Same bool-is-int trap as the extension set-priority command: the skip
guard 'isinstance(raw_priority, int) and raw_priority == priority' treats
a stored boolean as a match (isinstance(True, int) is True, True == 1),
so a corrupted boolean priority reports 'already has priority N' and is
never rewritten to a real int — contradicting the adjacent comment.
Exclude bools explicitly, mirroring normalize_priority's bool guard.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 09:35:51 -05:00
Ali jawwad
9d96c62901 fix(workflows): engine loop cap ignores bool max_iterations (#3270)
The while/do-while loop cap guard 'not isinstance(max_iters, int) or
max_iters < 1' does not fall back to the default for a boolean
max_iterations: isinstance(True, int) is True and True < 1 is False. The
loop then runs range(max_iters - 1) == range(True - 1) == range(0),
capping at a single iteration instead of the default 10. Exclude bools,
mirroring the merged while/do-while validators (#3237) and this
function's own continue_on_error bool handling. execute() does not
auto-validate, so this engine guard is the only defence.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 09:34:29 -05:00
Ali jawwad
a3bcd67925 docs(bundles): document --integration on 'bundle update' (#3271)
The 'bundle update' command accepts --integration (verified via
'specify bundle update --help' and the command signature), used as the
integration override when the project's active integration can't be
detected. The Update Bundles options table in reference/bundles.md
omitted it, listing only --all and --offline — unlike the install/init
tables which already document --integration. Add the missing row.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 09:33:39 -05:00
Ali jawwad
5c90a0547e fix(workflows): harden catalog.py against mis-shaped registry & non-string fields (#3375)
* fix(workflows): harden catalog.py against mis-shaped registry & non-string fields

Two robustness gaps where WorkflowRegistry/WorkflowCatalog diverged from
their StepRegistry/StepCatalog siblings, which already guard both:

- WorkflowRegistry._load returned json.load() verbatim, so a JSON-valid
  but mis-shaped registry (a list root, or a dict lacking a 'workflows'
  mapping) made is_installed/get/list/remove/add crash with
  TypeError/KeyError. Mirror StepRegistry._load: validate the shape and
  reset to default, and widen the except tuple to OSError/UnicodeError.
- WorkflowCatalog.search joined name/description/id without coercion, so a
  null or non-string field raised TypeError. Coerce with str(... or '')
  exactly as StepCatalog.search does.

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

* test(workflows): tighten mis-shaped-registry assertions

Per review: WorkflowRegistry.list() always returns a dict, so assert
'== {}' directly (the previous '== {} or == []' called list() twice and
admitted a shape it never returns), and reference
WorkflowRegistry.SCHEMA_VERSION instead of hard-coding '1.0'.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 09:32:53 -05:00
github-actions[bot]
c2af5c5a52 Add Verify Review Ship extension to community catalog (#3450)
Add verify-review-ship extension submitted by @cadugevaerd to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #3429

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-13 09:17:43 -05:00
Ali jawwad
ba1f13a8b1 fix(bundle): reject file:// / local download_url — catalog URLs are HTTPS-only (#3344)
* fix(bundle): resolve file:// download_url via the file-URL helper

_download_manifest built the local path from raw parsed.path, which
keeps the leading slash of file:///C:/x (yielding a \C:\x path that
never exists on Windows) and skips percent-decoding (my%20bundles stays
encoded on every OS) — so a catalog entry whose download_url is the
canonical URI Python itself produces via Path.as_uri() always fails
with 'Bundle manifest not found'. Route the file scheme through the
existing bundler.services.adapters._file_url_to_path helper, which
already handles drive letters, UNC hosts, and percent-decoding for
catalog file:// URLs (make_catalog_fetcher). The bare-path branch is
unchanged.

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

* fix(bundle): reject file:// / local download_url; catalog URLs are HTTPS-only

Per maintainer review (route B): file:// in a catalog download_url was
never intended — catalog URLs are HTTPS-only (http for localhost) across
the extensions/presets/workflows catalog systems, and disk installs go
through the positional path (specify bundle install <path>), handled by
_local_manifest_source before catalog resolution. Remove the
file:///bare-path branch from _download_manifest and route everything
through _download_remote_manifest (HTTPS-only via _require_https), with an
actionable error pointing at the positional install. Invert the file://
tests to assert rejection (+ a positional-path resolution test), and
migrate the three bundle-info contract tests off local download_urls onto
an HTTPS-only entry with a mocked manifest fetch.

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

* fix(bundle): validate HTTPS before the offline gate in _download_manifest

Per review: for a non-local download_url the offline check ran before any
URL validation, so an invalid/non-HTTPS scheme surfaced a misleading
'Network access disabled' error under --offline when the real problem is
the URL would be rejected even online. Call _require_https before the
offline gate so the correct error is reported in every mode.

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

* fix(bundle): reword non-HTTPS download_url error to not mislabel scheme-less URLs

A scheme-less download_url (urlparse scheme == "") can be a bare
filesystem path OR a missing-scheme value like 'example.com/foo.zip',
not necessarily file://. Reword the reject error to state the real
HTTPS-only constraint and enumerate what is rejected (file://, local
path, scheme-less), instead of labeling every case 'local/file://'.

Behavior unchanged; the 'bundle install' actionable hint is preserved,
so the existing reject-path tests still pass.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 09:15:55 -05:00
Ali jawwad
e59da78677 fix(extensions): handle prefix-colliding env vars in _get_env_config (#3350)
* fix(extensions): handle prefix-colliding env vars in _get_env_config

_get_env_config built the nested dict with 'if part not in current:
current[part] = {}' and an unconditional leaf assignment. Two env vars
that collide on a prefix — e.g. SPECKIT_X_CONNECTION and
SPECKIT_X_CONNECTION_URL — then either crash (scalar processed first:
the walk indexes into a str -> TypeError 'str object does not support
item assignment') or silently clobber the nested dict (scalar processed
last). Via should_execute_hook's blanket except, the crash silently
disables every config-based hook for the extension. Guard the walk and
the leaf assignment with isinstance checks so a colliding scalar yields
to the nested dict; result is order-independent
({'connection': {'url': ...}} either way), matching _merge_configs'
dict-preserving semantics.

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

* test(extensions): assert via public should_execute_hook, not private helper

Per review: the colliding-env hook test described should_execute_hook
swallowing the TypeError, but asserted on the private _evaluate_condition.
Assert on the public should_execute_hook instead — it returns False
(silently disabled) before the fix and True after, matching the
real-world failure mode and not coupling to a private helper.

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

* fix(extensions): ignore malformed env var names in _get_env_config

Per review: a name like SPECKIT_<EXT>_ (no key) or with consecutive
underscores produced empty path components, creating surprising entries
under an empty key (env_config[''] = ...). Filter out empty parts and
skip the variable entirely when nothing remains.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 09:14:32 -05:00
Quratulain-bilal
a10fd2f355 docs: document copilot skills mode (--skills) and markdown deprecation (#3313)
* docs: document copilot skills mode (--skills) and markdown deprecation

as of #3256 (v0.12.3) the copilot integration supports a skills mode via
`--integration-options "--skills"`, and installing without it warns that the
legacy markdown default is being phased out. this was undocumented:

- the copilot row in the supported-agents table had an empty notes cell while
  other skills-capable agents describe their behavior there.
- `--skills` was missing from the integration-specific options table (only
  generic and kimi were listed).

fill both. wording matches the code: skills scaffold as speckit-<name>/SKILL.md
under .github/skills/ and are invoked as /speckit-<name>; without the flag the
install emits the deprecation warning from _warn_legacy_markdown_default().

fixes #3300

* docs: describe copilot default as legacy markdown mode (.agent.md + .prompt.md)

the copilot rows said the default installs .agent.md files, but the default
scaffold also writes companion .prompt.md files under .github/prompts/. also
reworded to 'legacy markdown mode' to match the deprecation warning users
actually see and to avoid ambiguity, since skills are markdown too.

* docs: spell out copilot legacy markdown paths and use <command> in copilot rows

address the follow-up copilot review: name where the default scaffold writes
files (.github/agents/*.agent.md plus .github/prompts/*.prompt.md and a
.vscode/settings.json merge), and switch speckit-<name> to speckit-<command>
to match the rest of the table. verified all three paths against the copilot
integration source.

* docs: use --integration-options="..." form in copilot notes cell

match the equals form the rest of the doc uses (generic row, the options
table, and the install example) so readers don't mistake the quotes for part
of the value. addresses copilot review feedback.
2026-07-13 09:10:14 -05:00
Manfred Riem
1be42992e6 chore: release 0.12.11, begin 0.12.12.dev0 development (#3460)
* chore: bump version to 0.12.11

* chore: begin 0.12.12.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-10 14:28:25 -05: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
70 changed files with 1226 additions and 381 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

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

@@ -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,51 @@
<!-- insert new changelog below this comment -->
## [0.12.12] - 2026-07-13
### Changed
- fix(extensions): set-priority repairs corrupted boolean priority (#3268)
- fix(presets): set-priority repairs corrupted boolean priority (#3269)
- fix(workflows): engine loop cap ignores bool max_iterations (#3270)
- docs(bundles): document --integration on 'bundle update' (#3271)
- fix(workflows): harden catalog.py against mis-shaped registry & non-string fields (#3375)
- Add Verify Review Ship extension to community catalog (#3450)
- fix(bundle): reject file:// / local download_url — catalog URLs are HTTPS-only (#3344)
- fix(extensions): handle prefix-colliding env vars in _get_env_config (#3350)
- docs: document copilot skills mode (--skills) and markdown deprecation (#3313)
- chore: release 0.12.11, begin 0.12.12.dev0 development (#3460)
## [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

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.

1
docs/.gitignore vendored
View File

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

View File

@@ -52,6 +52,7 @@ 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) |
@@ -117,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) |
@@ -146,6 +148,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Token Economy | Token routing, measured savings, and context audit workflows | `process` | Read+Write | [spec-kit-token-economy](https://github.com/formin/spec-kit-token-economy) |
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |
| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) |
| Verify Review Ship | Adds post-implementation verify, review, and ship readiness gates to Spec Kit workflows | `process` | Read-only | [spec-kit-verify-review-ship](https://github.com/cadugevaerd/spec-kit-verify-review-ship) |
| Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) |
| Version Guard | Verify tech stack versions against live npm registries before planning and implementation | `process` | Read-only | [spec-kit-version-guard](https://github.com/KevinBrown5280/spec-kit-version-guard) |
| What-if Analysis | Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them | `visibility` | Read-only | [spec-kit-whatif](https://github.com/DevAbdullah90/spec-kit-whatif) |

View File

@@ -51,10 +51,11 @@ If the current directory is not yet a Spec Kit project, `install` initializes on
specify bundle update [<bundle_id>]
```
| Option | Description |
| ------------ | ------------------------------------ |
| `--all` | Update every installed bundle |
| `--offline` | Do not access the network |
| Option | Description |
| ---------------- | --------------------------------------------------------------------------------------------------------------------- |
| `--all` | Update every installed bundle |
| `--integration` | Override the integration used when refreshing components; applied only when the project's active integration can't be determined |
| `--offline` | Do not access the network |
Re-resolves a bundle and **refreshes** its components through each primitive's update path, bringing already-installed components up to the bundle's newly pinned versions while preserving primitive-level overrides (such as preset priority). Provide a bundle id, or use `--all` to update everything installed.

View File

@@ -18,7 +18,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Firebender](https://firebender.com/) | `firebender` | IDE-based agent for Android Studio / IntelliJ |
| [Forge](https://forgecode.dev/) | `forge` | |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | |
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | |
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | Defaults to legacy markdown mode: `.agent.md` command files under `.github/agents/`, companion `.prompt.md` files under `.github/prompts/`, and a `.vscode/settings.json` merge. Pass `--integration-options="--skills"` to scaffold skills as `speckit-<command>/SKILL.md` under `.github/skills/` instead. Legacy markdown mode is deprecated and will stop being the default in a future release. |
| [Goose](https://goose-docs.ai/) | `goose` | Uses YAML recipe format in `.goose/recipes/` |
| [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes` | Skills-based integration; installs skills globally into `~/.hermes/skills/` |
| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent |
@@ -219,6 +219,7 @@ Some integrations accept additional options via `--integration-options`:
| ----------- | ------------------- | -------------------------------------------------------------- |
| `generic` | `--commands-dir` | Required. Directory for command files |
| `kimi` | `--migrate-legacy` | Migrate legacy `.kimi/skills/` installs to `.kimi-code/skills/` (including dotted→hyphenated skill naming, e.g. `speckit.xxx``speckit-xxx`) |
| `copilot` | `--skills` | Scaffold commands as agent skills (`speckit-<command>/SKILL.md` under `.github/skills/`, invoked as `/speckit-<command>`) instead of the default legacy markdown mode (`.github/agents/*.agent.md` plus `.github/prompts/*.prompt.md` and a `.vscode/settings.json` merge). Without this flag, install warns that legacy markdown mode is deprecated. |
Example:

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/**/plan.md`, any depth).
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` (any depth, so scoped layouts like `specs/<scope>/<feature>/plan.md` are found).
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

@@ -307,16 +307,28 @@ import sys
from pathlib import Path
root = Path(sys.argv[1]).resolve()
specs = root / "specs"
plan = max(
specs.glob("**/plan.md"),
key=lambda p: p.stat().st_mtime,
default=None,
)
if plan:
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(plan.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

@@ -426,7 +426,11 @@ if (-not $PlanPath) {
if (-not $PlanPath) {
try {
$specsDir = Join-Path $ProjectRoot 'specs'
$candidate = Get-ChildItem -Path $specsDir -Recurse -File -Filter 'plan.md' -ErrorAction SilentlyContinue |
# 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

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-07T00:00:00Z",
"updated_at": "2026-07-10T00: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",
@@ -4282,6 +4356,40 @@
"created_at": "2026-03-03T00:00:00Z",
"updated_at": "2026-04-09T00:00:00Z"
},
"verify-review-ship": {
"name": "Verify Review Ship",
"id": "verify-review-ship",
"description": "Adds post-implementation verify, review, and ship readiness gates to Spec Kit workflows.",
"author": "Carlos Eduardo Gevaerd Araujo",
"version": "0.1.0",
"download_url": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/archive/refs/tags/v0.1.0.zip",
"repository": "https://github.com/cadugevaerd/spec-kit-verify-review-ship",
"homepage": "https://github.com/cadugevaerd/spec-kit-verify-review-ship",
"documentation": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/blob/main/README.md",
"changelog": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "process",
"effect": "read-only",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 3,
"hooks": 1
},
"tags": [
"quality",
"review",
"shipping",
"workflow",
"testing"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-10T00:00:00Z",
"updated_at": "2026-07-10T00:00:00Z"
},
"verify-tasks": {
"name": "Verify Tasks Extension",
"id": "verify-tasks",

View File

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

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.10.dev0"
version = "0.12.12"
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

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

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

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

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

@@ -746,11 +746,16 @@ def _resolve_manifest_path(path: Path | None) -> Path:
def _download_manifest(resolved, *, offline: bool):
"""Resolve a bundle's manifest from its catalog ``download_url``.
Local/``file://`` URLs always work offline and may point at a ``.zip``
artifact, a bundle directory, or a ``bundle.yml`` (handled by
:func:`_local_manifest_source`). Remote ``https://`` URLs are fetched with
the shared authenticated, redirect-validated HTTP client, and only when not
``--offline``.
Catalog ``download_url``s are HTTPS-only (``http`` allowed for localhost),
matching the extensions/presets/workflows catalog systems. Remote URLs are
fetched with the shared authenticated, redirect-validated HTTP client, and
only when not ``--offline``.
Local and ``file://`` sources are intentionally not resolved here: to
install a bundle from disk, pass the path positionally
(``specify bundle install ./path/to/bundle.yml`` — a bundle directory or a
``.zip`` artifact also works), which :func:`_local_manifest_source` handles
before catalog resolution and which never touches ``download_url``.
"""
from urllib.parse import urlparse
@@ -763,26 +768,35 @@ def _download_manifest(resolved, *, offline: bool):
parsed = urlparse(url)
scheme = parsed.scheme.lower()
# On Windows an absolute path like ``C:\bundle.yml`` parses with a
# single-letter ``scheme``; treat it as a local file, not a URL scheme.
# ``file://`` URLs and bare filesystem paths (including Windows drive paths
# like ``C:\bundle.yml``, which urlparse reads as a single-letter scheme)
# are not valid catalog download URLs. Catalog URLs are HTTPS-only across
# every catalog system; installing from disk is done by passing the path
# positionally, which never reaches URL resolution. Give an actionable
# error rather than accepting a scheme the rest of the codebase rejects.
if scheme in ("", "file") or re.match(r"^[A-Za-z]:[\\/]", url):
local = Path(parsed.path if scheme == "file" else url)
manifest = _local_manifest_source(str(local))
if manifest is None:
raise BundlerError(f"Bundle manifest not found: {local}")
return manifest
raise BundlerError(
f"Catalog entry '{resolved.entry.id}' has a non-HTTP(S) download_url "
f"({url}); catalog download URLs must be HTTPS (http for localhost) — "
"a file:// URL, a local filesystem path, or a scheme-less value "
"(e.g. 'example.com/bundle.zip') is not accepted. "
"To install a bundle from disk, pass the path directly: "
"'specify bundle install <path-to-bundle.yml | bundle-dir | .zip>'."
)
if scheme in ("http", "https"):
if offline:
raise BundlerError(
f"Network access disabled; cannot download bundle '{resolved.entry.id}' "
f"from {url}."
)
return _download_remote_manifest(resolved.entry.id, url)
# Validate the scheme/host *before* the offline gate so an invalid or
# non-HTTPS download_url reports the real problem in every mode, rather
# than a misleading "Network access disabled" under --offline.
# (_download_remote_manifest re-checks this, but only once network access
# is permitted.) HTTPS-only, http allowed for localhost.
_require_https(f"bundle '{resolved.entry.id}'", url)
raise BundlerError(
f"Unsupported download_url scheme for bundle '{resolved.entry.id}': {url}"
)
if offline:
raise BundlerError(
f"Network access disabled; cannot download bundle '{resolved.entry.id}' "
f"from {url}."
)
return _download_remote_manifest(resolved.entry.id, url)
def _require_https(label: str, url: str) -> None:

View File

@@ -2755,18 +2755,32 @@ class ConfigManager:
if not key.startswith(prefix):
continue
# Remove prefix and split into parts
config_path = key[len(prefix) :].lower().split("_")
# Remove prefix and split into parts. Drop empty components from a
# malformed name (e.g. ``SPECKIT_<EXT>_`` with no key, or
# consecutive underscores ``SPECKIT_X__Y``) so we never create an
# entry under an empty key.
config_path = [p for p in key[len(prefix) :].lower().split("_") if p]
if not config_path:
continue
# Build nested dict
# Build nested dict. Two env vars can collide on a prefix, e.g.
# SPECKIT_X_CONNECTION=a and SPECKIT_X_CONNECTION_URL=b. Guard the
# walk so a colliding scalar is replaced by a dict (deeper/more
# specific vars win) instead of being indexed into — which raised
# TypeError ('str' object does not support item assignment) — and
# guard the leaf so a scalar processed after the nested var does
# not clobber the nested dict. Order-independent: both insertion
# orders yield {'connection': {'url': ...}}. Nested-wins mirrors
# _merge_configs' dict-preserving semantics.
current = env_config
for part in config_path[:-1]:
if part not in current:
if not isinstance(current.get(part), dict):
current[part] = {}
current = current[part]
# Set the final value
current[config_path[-1]] = value
# Set the final value, unless a nested dict already occupies it.
if not isinstance(current.get(config_path[-1]), dict):
current[config_path[-1]] = value
return env_config

View File

@@ -1566,7 +1566,14 @@ def extension_set_priority(
raw_priority = metadata.get("priority")
# Only skip if the stored value is already a valid int equal to requested priority
# This ensures corrupted values (e.g., "high") get repaired even when setting to default (10)
if isinstance(raw_priority, int) and raw_priority == priority:
# A bool is an int in Python (isinstance(True, int) is True), so exclude it explicitly —
# mirroring normalize_priority's bool guard — otherwise a corrupted True/False priority
# equals 1/0 here and is never repaired.
if (
isinstance(raw_priority, int)
and not isinstance(raw_priority, bool)
and raw_priority == priority
):
console.print(f"[yellow]Extension '{_escape_markup(str(display_name))}' already has priority {priority}[/yellow]")
raise typer.Exit(0)

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

@@ -469,7 +469,14 @@ def preset_set_priority(
raw_priority = metadata.get("priority")
# Only skip if the stored value is already a valid int equal to requested priority
# This ensures corrupted values (e.g., "high") get repaired even when setting to default (10)
if isinstance(raw_priority, int) and raw_priority == priority:
# A bool is an int in Python (isinstance(True, int) is True), so exclude it explicitly —
# mirroring normalize_priority's bool guard — otherwise a corrupted True/False priority
# equals 1/0 here and is never repaired.
if (
isinstance(raw_priority, int)
and not isinstance(raw_priority, bool)
and raw_priority == priority
):
console.print(f"[yellow]Preset '{preset_id}' already has priority {priority}[/yellow]")
raise typer.Exit(0)

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)

View File

@@ -76,8 +76,16 @@ class WorkflowRegistry:
if self.registry_path.exists():
try:
with open(self.registry_path, encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, ValueError):
data = json.load(f)
# Validate shape: must be a dict with a dict "workflows" field,
# otherwise every method that indexes data["workflows"] crashes.
# Mirrors StepRegistry._load.
if not isinstance(data, dict):
return {"schema_version": self.SCHEMA_VERSION, "workflows": {}}
if not isinstance(data.get("workflows"), dict):
data["workflows"] = {}
return data
except (json.JSONDecodeError, ValueError, OSError, UnicodeError):
# Corrupted registry file — reset to default
return {"schema_version": self.SCHEMA_VERSION, "workflows": {}}
return {"schema_version": self.SCHEMA_VERSION, "workflows": {}}
@@ -438,9 +446,9 @@ class WorkflowCatalog:
q = query.lower()
searchable = " ".join(
[
wf_data.get("name", ""),
wf_data.get("description", ""),
wf_data.get("id", ""),
str(wf_data.get("name") or ""),
str(wf_data.get("description") or ""),
str(wf_data.get("id") or ""),
]
).lower()
if q not in searchable:

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(
@@ -953,7 +982,16 @@ class WorkflowEngine:
from .expressions import evaluate_condition
max_iters = step_config.get("max_iterations")
if not isinstance(max_iters, int) or max_iters < 1:
# A bool is an int in Python (isinstance(True, int) is True
# and True == 1), so a bool max_iterations would slip past
# the int check and cap the loop at range(0)==1 iteration
# instead of the default. Exclude bools, mirroring the
# while/do-while validators and the continue_on_error guard.
if (
isinstance(max_iters, bool)
or not isinstance(max_iters, int)
or max_iters < 1
):
max_iters = 10
condition = step_config.get("condition", False)
for _loop_iter in range(max_iters - 1):

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

@@ -1,6 +1,6 @@
---
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...

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

@@ -43,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.
@@ -145,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

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

@@ -175,7 +175,23 @@ def test_build_produces_artifact(project: Path):
assert len(artifacts) == 1
def test_info_expands_full_component_set(project: Path):
def _mock_manifest_download(monkeypatch, source_path: Path) -> None:
"""Mock the HTTPS manifest fetch to return a locally-authored manifest.
Catalog ``download_url``s are HTTPS-only, so ``info`` tests can no longer
point one at a local file. Patch ``_download_manifest`` to return the
manifest parsed from *source_path* (a bundle.yml or a .zip artifact),
exercising ``info``'s expansion without a network call.
"""
from specify_cli.commands.bundle import _local_manifest_source
monkeypatch.setattr(
"specify_cli.commands.bundle._download_manifest",
lambda resolved, *, offline: _local_manifest_source(str(source_path)),
)
def test_info_expands_full_component_set(project: Path, monkeypatch):
bundle_dir = project / "src-bundle"
bundle_dir.mkdir()
(bundle_dir / "bundle.yml").write_text(
@@ -183,13 +199,14 @@ def test_info_expands_full_component_set(project: Path):
)
catalog = project / "local-catalog.json"
entry = catalog_entry_dict(
"demo-bundle", download_url=str(bundle_dir / "bundle.yml")
"demo-bundle", download_url="https://example.com/demo-bundle.zip"
)
write_catalog_file(catalog, {"demo-bundle": entry})
added = runner.invoke(
app, ["bundle", "catalog", "add", str(catalog), "--id", "local"]
)
assert added.exit_code == 0, added.output
_mock_manifest_download(monkeypatch, bundle_dir / "bundle.yml")
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"])
assert result.exit_code == 0, result.output
@@ -207,7 +224,7 @@ def test_info_expands_full_component_set(project: Path):
assert "Trust" in text.output
def test_info_expands_discovery_only_bundle(project: Path):
def test_info_expands_discovery_only_bundle(project: Path, monkeypatch):
# Discovery-only bundles must still be fully inspectable via `info`;
# only `install` is refused for them.
bundle_dir = project / "disc-bundle"
@@ -217,7 +234,7 @@ def test_info_expands_discovery_only_bundle(project: Path):
)
catalog = project / "disc-catalog.json"
entry = catalog_entry_dict(
"demo-bundle", download_url=str(bundle_dir / "bundle.yml")
"demo-bundle", download_url="https://example.com/demo-bundle.zip"
)
write_catalog_file(catalog, {"demo-bundle": entry})
config = {
@@ -230,6 +247,7 @@ def test_info_expands_discovery_only_bundle(project: Path):
(project / ".specify" / "bundle-catalogs.yml").write_text(
yaml.safe_dump(config), encoding="utf-8"
)
_mock_manifest_download(monkeypatch, bundle_dir / "bundle.yml")
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"])
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
@@ -237,8 +255,9 @@ def test_info_expands_discovery_only_bundle(project: Path):
assert ("extensions", "ext-a") in components
def test_info_resolves_local_zip_download_url(project: Path):
# A local .zip artifact as download_url is extracted to read bundle.yml.
def test_info_expands_zip_sourced_bundle(project: Path, monkeypatch):
# A .zip artifact is extracted to read bundle.yml; info expands it. (The
# download itself is HTTPS-only now and mocked here — see contract note.)
bundle_dir = project / "zip-src"
bundle_dir.mkdir()
(bundle_dir / "bundle.yml").write_text(
@@ -249,12 +268,15 @@ def test_info_resolves_local_zip_download_url(project: Path):
catalog = project / "zip-catalog.json"
write_catalog_file(
catalog,
{"demo-bundle": catalog_entry_dict("demo-bundle", download_url=str(artifact))},
{"demo-bundle": catalog_entry_dict(
"demo-bundle", download_url="https://example.com/demo-bundle.zip"
)},
)
added = runner.invoke(
app, ["bundle", "catalog", "add", str(catalog), "--id", "local"]
)
assert added.exit_code == 0, added.output
_mock_manifest_download(monkeypatch, artifact)
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"])
assert result.exit_code == 0, result.output
payload = json.loads(result.output)

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"

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

@@ -112,3 +112,62 @@ def test_install_bundled_extension_from_zip_offline(tmp_path: Path):
assert not ExtensionManager(project).registry.is_installed("agent-context")
finally:
os.chdir(previous)
def test_download_manifest_rejects_file_url(tmp_path: Path):
"""A catalog ``file://`` download_url is rejected — catalog URLs are
HTTPS-only, matching extensions/presets/workflows. Disk installs go through
the positional path (see the local-source tests above), not download_url.
"""
from types import SimpleNamespace
from specify_cli.commands.bundle import _download_manifest
manifest_path = write_manifest(tmp_path / "my bundles")
resolved = SimpleNamespace(
entry=SimpleNamespace(id="demo-bundle", download_url=manifest_path.as_uri())
)
with pytest.raises(BundlerError, match="bundle install"):
_download_manifest(resolved, offline=True)
def test_download_manifest_rejects_bare_path(tmp_path: Path):
"""A bare filesystem path download_url is likewise rejected."""
from types import SimpleNamespace
from specify_cli.commands.bundle import _download_manifest
manifest_path = write_manifest(tmp_path / "plain")
resolved = SimpleNamespace(
entry=SimpleNamespace(id="demo-bundle", download_url=str(manifest_path))
)
with pytest.raises(BundlerError, match="bundle install"):
_download_manifest(resolved, offline=True)
def test_local_install_still_resolves_via_positional_path(tmp_path: Path):
"""The supported local route — a positional path, not a download_url —
still resolves the manifest via _local_manifest_source."""
manifest_path = write_manifest(tmp_path / "my bundles")
manifest = _local_manifest_source(str(manifest_path))
assert manifest is not None
assert manifest.bundle.id == "demo-bundle"
def test_download_manifest_rejects_non_https_url_even_offline(tmp_path: Path):
"""A non-HTTPS download_url must report the HTTPS problem, not a misleading
'Network access disabled', even under --offline (scheme is validated before
the offline gate)."""
from types import SimpleNamespace
from specify_cli.commands.bundle import _download_manifest
resolved = SimpleNamespace(
entry=SimpleNamespace(
id="demo-bundle", download_url="http://example.com/bundle.zip"
)
)
with pytest.raises(BundlerError, match="HTTPS"):
_download_manifest(resolved, offline=True)

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

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

@@ -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", []) == []

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

@@ -6424,6 +6424,42 @@ class TestExtensionPriorityCLI:
plain = strip_ansi(result.output)
assert "already has priority 5" in plain
def test_set_priority_repairs_corrupted_bool(self, extension_dir, project_dir):
"""A corrupted boolean priority must be repaired, not skipped.
``isinstance(True, int)`` is True and ``True == 1`` in Python, so a
stored ``True`` priority would short-circuit the ``already has
priority 1`` skip path and never get rewritten to a real int —
contradicting the comment that promises corrupted values are
repaired. The guard must exclude bools (like normalize_priority).
"""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
manager = ExtensionManager(project_dir)
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False, priority=5
)
# Inject a corrupted boolean priority (True == 1).
manager.registry.update("test-ext", {"priority": True})
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["extension", "set-priority", "test-ext", "1"])
assert result.exit_code == 0, result.output
plain = strip_ansi(result.output)
# The corrupted bool must be repaired, not reported as already-set.
assert "already has priority" not in plain
assert "priority changed" in plain
# The stored value is now a real int, not a bool.
reloaded = ExtensionManager(project_dir).registry.get("test-ext")
assert reloaded["priority"] == 1
assert not isinstance(reloaded["priority"], bool)
def test_set_priority_invalid_value(self, extension_dir, project_dir):
"""Test set-priority rejects invalid priority values."""
from typer.testing import CliRunner
@@ -7628,3 +7664,56 @@ class TestConfigManagerNonMappingYaml:
(ext_dir / "jira-config.yml").write_text("just a string\n", encoding="utf-8")
executor = HookExecutor(tmp_path)
assert executor._evaluate_condition("config.x is set", "jira") is False
class TestConfigManagerEnvPrefixCollision:
"""Prefix-colliding env vars must not crash or clobber nested config."""
def test_scalar_then_nested_yields_nested(self, tmp_path, monkeypatch):
"""SPECKIT_X_CONNECTION=x then SPECKIT_X_CONNECTION_URL=y.
The scalar-first order previously raised TypeError ('str' object
does not support item assignment) when the walk indexed into 'x'.
"""
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION", "x")
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION_URL", "y")
cm = ConfigManager(tmp_path, "testext")
assert cm._get_env_config() == {"connection": {"url": "y"}}
def test_nested_then_scalar_does_not_clobber(self, tmp_path, monkeypatch):
"""Reverse order previously returned {'connection': 'x'}, losing url."""
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION_URL", "y")
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION", "x")
cm = ConfigManager(tmp_path, "testext")
assert cm._get_env_config() == {"connection": {"url": "y"}}
def test_colliding_env_does_not_disable_hook_condition(self, tmp_path, monkeypatch):
"""`config.connection.url is set` must stay True under colliding env.
Before the fix the TypeError propagated into should_execute_hook's
blanket `except Exception: return False`, silently disabling the hook.
"""
ext_dir = tmp_path / ".specify" / "extensions" / "testext"
ext_dir.mkdir(parents=True)
(ext_dir / "testext-config.yml").write_text(
"connection:\n url: https://example.com\n", encoding="utf-8"
)
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION", "x")
monkeypatch.setenv("SPECKIT_TESTEXT_CONNECTION_URL", "y")
executor = HookExecutor(tmp_path)
# Exercise the public API: before the fix the TypeError was swallowed
# by should_execute_hook's `except Exception: return False`, so the
# hook was silently disabled (False); after the fix it returns True.
assert executor.should_execute_hook(
{"condition": "config.connection.url is set", "extension": "testext"}
) is True
def test_malformed_env_names_ignored(self, tmp_path, monkeypatch):
"""A name with no key (SPECKIT_X_) or empty parts (consecutive
underscores) must not create an entry under an empty key."""
monkeypatch.setenv("SPECKIT_TESTEXT_", "orphan") # no key at all
monkeypatch.setenv("SPECKIT_TESTEXT_A__B", "z") # empty middle part
cm = ConfigManager(tmp_path, "testext")
cfg = cm._get_env_config()
assert "" not in cfg
assert cfg == {"a": {"b": "z"}}

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

@@ -4060,6 +4060,40 @@ class TestPresetSetPriority:
plain = strip_ansi(result.output)
assert "already has priority 5" in plain
def test_set_priority_repairs_corrupted_bool(self, project_dir, pack_dir):
"""A corrupted boolean priority must be repaired, not skipped.
``isinstance(True, int)`` is True and ``True == 1`` in Python, so a
stored ``True`` priority would short-circuit the ``already has
priority 1`` skip path and never get rewritten to a real int —
contradicting the comment that promises corrupted values are
repaired. The guard must exclude bools (like normalize_priority).
"""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5", priority=5)
# Inject a corrupted boolean priority (True == 1).
manager.registry.update("test-pack", {"priority": True})
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["preset", "set-priority", "test-pack", "1"])
assert result.exit_code == 0, result.output
plain = strip_ansi(result.output)
# The corrupted bool must be repaired, not reported as already-set.
assert "already has priority" not in plain
assert "priority changed" in plain
# The stored value is now a real int, not a bool.
reloaded = PresetManager(project_dir).registry.get("test-pack")
assert reloaded["priority"] == 1
assert not isinstance(reloaded["priority"], bool)
def test_set_priority_invalid_value(self, project_dir, pack_dir):
"""Test set-priority rejects invalid priority values."""
from typer.testing import CliRunner

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"
@@ -619,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,
@@ -636,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,
@@ -663,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:
"""
@@ -678,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,
@@ -687,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:
"""
@@ -704,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,
@@ -721,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"
@@ -731,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:
"""
@@ -740,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,
@@ -755,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,
@@ -855,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,
@@ -873,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

@@ -2794,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
@@ -3777,6 +3872,56 @@ steps:
assert "retry-loop:tick:1" in state.step_results
assert "retry-loop:tick:2" in state.step_results
def test_loop_with_bool_max_iterations_uses_default_cap(self, project_dir):
"""A boolean max_iterations must fall back to the default cap of 10,
not be treated as the int 1 (bool-is-int trap).
``max_iterations: true`` would otherwise slip past the int check
(``isinstance(True, int)`` is True and ``True < 1`` is False) and
cap the loop at ``range(True - 1) == range(0)`` — a single
iteration. ``execute()`` does not auto-validate, so the engine's own
guard is the only line of defence here.
"""
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
from specify_cli.workflows.base import RunStatus
import sys
counter_file = project_dir / ".counter"
counter_file.write_text("0", encoding="utf-8")
py = sys.executable
script_file = project_dir / "_tick.py"
script_file.write_text(
f"import pathlib; p = pathlib.Path(r'{counter_file}')\n"
"n = int(p.read_text()) + 1; p.write_text(str(n))\n"
"print('pending', end='')\n",
encoding="utf-8",
)
yaml_str = f"""
schema_version: "1.0"
workflow:
id: "while-bool-max-iterations"
name: "While Bool Max Iterations"
version: "1.0.0"
steps:
- id: retry-loop
type: while
condition: "{{{{ 'done' not in steps.tick.output.stdout }}}}"
max_iterations: true
steps:
- id: tick
type: shell
run: '"{py}" "{script_file}"'
"""
definition = WorkflowDefinition.from_string(yaml_str)
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
assert state.status == RunStatus.COMPLETED
# Falls back to the default cap of 10, not range(True - 1) == 1 run.
assert counter_file.read_text(encoding="utf-8").strip() == "10"
def test_do_while_loop_runs_to_max_when_condition_stays_true(self, project_dir):
"""Do-while loop must still run to max_iterations when the condition
never becomes false.
@@ -4651,12 +4796,59 @@ class TestWorkflowRegistry:
registry2 = WorkflowRegistry(project_dir)
assert registry2.is_installed("test-wf")
@pytest.mark.parametrize("bad_content", ["[]", '{"schema_version": "1.0"}'])
def test_load_tolerates_misshaped_registry(self, project_dir, bad_content):
"""A JSON-valid but mis-shaped registry file must not crash every method.
A list root, or a dict lacking a 'workflows' mapping, previously made
is_installed/get/list/remove/add raise TypeError/KeyError. Mirrors the
shape guard StepRegistry._load already has.
"""
from specify_cli.workflows.catalog import WorkflowRegistry
reg_path = project_dir / ".specify" / "workflows" / "workflow-registry.json"
reg_path.parent.mkdir(parents=True, exist_ok=True)
reg_path.write_text(bad_content, encoding="utf-8")
registry = WorkflowRegistry(project_dir)
assert registry.data == {
"schema_version": WorkflowRegistry.SCHEMA_VERSION,
"workflows": {},
}
# None of these should raise on the recovered-default shape.
assert registry.is_installed("x") is False
assert registry.get("x") is None
assert registry.list() == {} # list() always returns a dict
registry.remove("x")
registry.add("x", {"name": "X"})
assert registry.is_installed("x")
# ===== Workflow Catalog Tests =====
class TestWorkflowCatalog:
"""Test WorkflowCatalog catalog resolution."""
def test_search_with_non_string_fields(self, project_dir, monkeypatch):
"""Non-string workflow fields (null/int name/description) must not
raise TypeError in search — StepCatalog.search already coerces these."""
from specify_cli.workflows.catalog import WorkflowCatalog
catalog = WorkflowCatalog(project_dir)
monkeypatch.setattr(catalog, "_get_merged_workflows", lambda **kw: {
"42": {
"id": 42,
"name": None,
"description": 99,
"_catalog_name": "test",
"_install_allowed": True,
},
})
assert len(catalog.search()) == 1
assert len(catalog.search(query="42")) == 1
assert len(catalog.search(query="missing")) == 0
def test_default_catalogs(self, project_dir, monkeypatch):
from specify_cli.workflows.catalog import WorkflowCatalog
@@ -7049,3 +7241,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

View File

@@ -96,3 +96,21 @@ def test_validate_remote_url_rejects_host_less_urls(url):
def test_validate_remote_url_accepts_normal_https_url():
# Sanity: a real host with a port still passes.
adapters._validate_remote_url("team", "https://example.com:8080/c.json")
@pytest.mark.parametrize(
"url",
[
"https://[::1", # unclosed IPv6 bracket
"https://[not-an-ip]/c.json",
],
)
def test_validate_remote_url_rejects_malformed_url_cleanly(url):
"""A malformed URL must raise BundlerError, not a raw ValueError.
``urlparse``/``hostname`` raise ``ValueError`` on a malformed authority
(e.g. an unclosed IPv6 bracket). The validator's contract is to raise
BundlerError for any bad URL, so the raw ValueError must not escape to the
caller. Bundler sibling of #3369."""
with pytest.raises(BundlerError):
adapters._validate_remote_url("team", url)