Compare commits

..

36 Commits

Author SHA1 Message Date
github-actions[bot]
217730a8cd chore: bump version to 0.10.3 2026-06-16 14:38:02 +00:00
Manfred Riem
33fefde268 Update Superpowers Bridge extension to v1.6.0 (#2998)
* Update Superpowers Bridge extension to v1.6.0

Update superb extension submitted by @RbBtSn0w:
- extensions/catalog.community.json (version, download_url, description, provides, updated_at)
- docs/community/extensions.md community extensions table

Closes #2973

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

* Align superb catalog author and tags with v1.6.0 manifest

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

---------

Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-16 09:34:35 -05:00
Manfred Riem
70f9242be9 Add Improve Extension to community catalog (#2997)
Add improve extension submitted by @d0whc3r to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #2972

Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-16 09:16:56 -05:00
Manfred Riem
7c1d4212db Update Product Forge extension to v1.7.0 (#2996)
Update product-forge extension submitted by @VaiYav:
- extensions/catalog.community.json (version, download_url, description, tags, documentation, updated_at)
- docs/community/extensions.md community extensions table

Closes #2967

Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-16 09:03:35 -05:00
Manfred Riem
4f5c4971c0 Update Linear Integration extension to v0.5.0 (#2995)
Update linear extension submitted by @ashbrener:
- extensions/catalog.community.json (version, download_url, updated_at)

Closes #2953

Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-16 08:35:05 -05:00
Manfred Riem
13b8db2d87 Update Superpowers Implementation Bridge extension to v1.0.3 (#2993)
Update speckit-superpowers-bridge extension submitted by @lihan3238:
- extensions/catalog.community.json (version, download_url, updated_at)

Closes #2945

Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-16 07:52:07 -05:00
Ben Lawson
68980c9a4e Update Ralph community extension to v1.1.1 (#2861) 2026-06-16 06:54:26 -05:00
Manfred Riem
1b0556c711 Update Linear Integration extension to v0.4.0 (#2942)
Update linear extension submitted by @ashbrener:
- extensions/catalog.community.json (version, download_url, changelog, updated_at)

Closes #2931

Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-11 10:10:24 -05:00
Manfred Riem
f2710f32cf Update DocGuard — CDD Enforcement to v0.26.0 (#2941)
Update docguard extension submitted by @raccioly:
- extensions/catalog.community.json (version, download_url, updated_at)

Closes #2928

Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-11 09:18:11 -05:00
Manfred Riem
4384338ec1 Add SpecKit Companion extension to community catalog (#2937)
* Add SpecKit Companion extension to community catalog

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

Closes #2926

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

* Address review: multi-line tools format, add vscode tag

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

---------

Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-11 08:16:10 -05:00
Manfred Riem
dd9d84e7bc chore: release 0.10.2, begin 0.10.3.dev0 development (#2936)
* chore: bump version to 0.10.2

* chore: begin 0.10.3.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-11 07:59:01 -05:00
Manfred Riem
77af08ba22 Add Research Harness extension to community catalog (#2935)
Add harness extension submitted by @formin to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #2925

Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-11 07:53:59 -05:00
Manfred Riem
f5d47720b9 Add Coding Standards Drift Control extension to community catalog (#2934)
Add coding-standards-drift-control extension submitted by @benizzio to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #2923

Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-11 07:44:07 -05:00
Quratulain-bilal
4e899d3002 Add Spec Trace extension to community catalog (#2527)
* Add Spec Trace extension to community catalog

* docs(catalog): mark Spec Trace as Read+Write

The /speckit.trace.build command writes .specify/trace.md, so the
catalog row's Effect column was wrong. Aligning with the extension's
documented behavior.

* docs(community): add Spec Trace row to extensions.md

The public community extensions table moved from README.md to
docs/community/extensions.md per the repo convention documented in
.github/skills/add-community-extension/SKILL.md. Adding the Spec Trace
row alphabetically between Spec Sync and Spec Validate so the doc stays
in sync with the catalog entry already added.

* fix(catalog): use literal Unicode characters in Spec Trace description

Copilot's review on this PR noted that the Spec Trace entry was the
only one in catalog.community.json using JSON Unicode escape sequences
(\u2192 for the arrow, \u2014 for the em-dash). Every other entry
that uses those characters writes them as literal multi-byte UTF-8
(18 entries with literal em-dash, 5 with literal arrow), so the
escaped form made this row harder to read and review in plain text
and stood out as the only inconsistency in the file.

Replacing the escapes with the literal characters keeps the entry
visually consistent with the rest of the catalog and decodes to the
same string at runtime, so no consumer changes.

* chore(catalog): set Spec Trace timestamps to catalog-add date

Per add-community-extension SKILL.md, a new entry's created_at/updated_at
should reflect the date it is added to the catalog, and the top-level
catalog updated_at must be refreshed on any add. Set the Spec Trace
entry and the catalog-level updated_at to 2026-06-09.

* docs(community): categorize Spec Trace as code

Spec Trace analyzes the test suite (source) and produces a coverage/
traceability report, matching the documented 'code' category (reviews/
validates source) rather than 'process' (orchestrates workflow across
phases). Aligns with the sibling SpecTest row.
2026-06-11 07:34:36 -05:00
Ali jawwad
63a2a17305 fix(extensions): preserve argument-hint in extension Claude SKILL.md (#2916)
Extension-provided commands that declare `argument-hint:` in their
frontmatter had that field dropped from the generated Claude
`.claude/skills/<name>/SKILL.md`, while core template commands keep it.
The extension skill generator built the frontmatter via the shared
build_skill_frontmatter() (name/description/compatibility/metadata only)
and never forwarded argument-hint.

Carry argument-hint from the parsed source command frontmatter into the
skill frontmatter dict before serialization, gated on the integration
exposing inject_argument_hint so only argument-hint-aware agents (Claude)
receive the key and build_skill_frontmatter's shared shape stays unchanged
for every other agent. The value is injected into the dict rather than via
the string-based inject_argument_hint helper, so a folded multi-line
description cannot be split into invalid YAML.

Add regression tests covering a folding description (Claude) and the
non-Claude gate (kimi).

Closes #2903

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 07:28:39 -05:00
darion-yaphet
36ad3cde1b fix(presets): harden preset URL installs against unsafe redirects (#2911)
* Harden preset URL installs against unsafe redirects

Preset URL installs already rejected non-HTTPS source URLs, but the authenticated opener follows redirects. Validate the final response URL before writing the ZIP, preserve GitHub release asset URL resolution after the preset command module split, stream the response to disk, and keep catalog config serialization on safe YAML output.

Constraint: open_url follows redirects, so source URL validation alone does not constrain the downloaded target

Rejected: Keep response.read() for simplicity | large preset downloads should not be buffered entirely in memory

Confidence: high

Scope-risk: narrow

Directive: Keep preset URL policy aligned with workflow installer redirect validation

Tested: uvx ruff check src/specify_cli/__init__.py src/specify_cli/presets/__init__.py src/specify_cli/presets/_commands.py tests/test_presets.py

Tested: uv run pytest tests/test_presets.py -q

Not-tested: Real network redirect integration against a live HTTP server

Co-authored-by: OmX <omx@oh-my-codex.dev>

* Reject malformed preset download URLs

Preset downloads should fail early when a URL lacks a hostname, even if the scheme is HTTPS. The redirect error now describes any disallowed target instead of implying that only non-HTTPS redirects are blocked.

* Prevent credentialed preset redirects from downgrading transport

Preset URL downloads already checked the final URL after urllib followed redirects, but that was too late for authenticated requests because same-host redirects could preserve Authorization during the redirect itself. The authenticated HTTP helper now supports an opt-in redirect validator, and preset downloads use it to reject disallowed redirect targets before following them. The redirect auth handlers also stop preserving credentials across HTTPS to non-HTTPS downgrades as defense in depth.

* test(presets): 修复 URL 解析测试 mock 缺少 redirect_validator 参数

重定向安全加固为 open_url 新增 redirect_validator 参数,
两处 fake_open_url mock 签名未同步导致 TypeError。
补齐参数后全部 3717 个测试通过。

---------

Co-authored-by: OmX <omx@oh-my-codex.dev>
2026-06-11 07:21:50 -05:00
Manfred Riem
5ae7ff53d0 fix: skip recovered files during refresh_managed overwrite check (#2918) (#2919)
_is_managed() in install_shared_infra now consults manifest.is_recovered()
before treating a hash-matching file as managed. Files marked recovered
(pre-existing on disk, not installed by Spec Kit) are no longer overwritten
by integration use/switch even when their hash matches the manifest entry.

This closes the gap documented in the manifest API: callers using
refresh_managed MUST check is_recovered first.

Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-10 16:39:57 -05:00
낮해달밤
902b98654d Update multi-model-review extension to v0.1.1 (#2900) 2026-06-10 07:47:39 -05:00
Manfred Riem
40e48ed22c feat: add category and effect as first-class fields in extension schema (#2899)
* feat: add category and effect as first-class fields in extension schema

Add `category` and `effect` as optional fields in the extension schema
(`extension.yml`) and community catalog (`catalog.community.json`).

Schema changes:
- Valid categories: docs, code, process, integration, visibility
- Valid effects: read-only, read-write
- Both fields are optional (backward-compatible with existing extensions)
- Validation raises ValidationError for invalid values when present

Propagation:
- Added `category` and `effect` to all 108 entries in catalog.community.json
  (populated from the existing docs/community/extensions.md table)
- Updated extension template with commented category/effect fields
- Updated add-community-extension skill with new JSON template fields
- Updated `specify extension info` CLI output to display category/effect
- Added properties to ExtensionManifest class

Tests:
- test_valid_category: all 5 category values pass
- test_valid_effect: both effect values pass
- test_invalid_category: invalid value raises ValidationError
- test_invalid_effect: invalid value raises ValidationError
- test_category_and_effect_optional: omitting fields still works

Closes #2874

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

* fix: make category free-form, keep effect validated

Category is a free-form string (only validated as non-empty when present),
while effect remains restricted to 'read-only' or 'read-write'.

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

* fix: address PR review feedback

- Add type guard before 'in' check for effect to prevent TypeError on
  unhashable YAML values (list/dict)
- Comment out category/effect in template so authors must opt in
- Use VALID_EFFECTS constant in test instead of hard-coded values

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

* fix: update category docstring to reflect free-form semantics

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

* docs: clarify canonical extension effect values

---------

Co-authored-by: Manfred Riem <mnriem@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-06-10 07:44:27 -05:00
Ash Brener
45b88f62be chore(catalog): add Jira Integration (Sync Engine) extension (#2895)
* chore(catalog): add Jira Integration (Sync Engine) extension

Adds a new community-catalog listing for `spec-kit-jira-sync`
(ashbrener/spec-kit-jira-sync), a reconcile-engine bridge that mirrors
spec-kit specs into Jira (Epic per repo, Story per spec, Subtask per
phase): idempotent, drift-aware, fail-closed.

Catalog id is `jira-sync` because the `jira` id is already taken by an
unrelated extension; display name "Jira Integration (Sync Engine)"
disambiguates from the existing "Jira Integration" listing.

Touches the two catalog surfaces:
1. extensions/catalog.community.json - the new "jira-sync" entry,
   inserted after the existing "jira" entry. Field shape matches the
   sibling "linear" entry exactly.
2. docs/community/extensions.md - the table row, after the existing
   Jira Integration row.

JSON validated; diff is the single entry + the one table row.

* catalog(jira-sync): neutral capability-focused description (address Copilot review)

Drop the comparative/absolute framing ('A real …', 'never corrupts your board')
flagged by Copilot; keep the factual, tested capability descriptors (idempotent,
drift-aware, fail-closed). Applies to both the catalog entry and the docs table row.

* chore(catalog): bump jira-sync to v0.2.0 (re-mode + engine unification)

* fix(catalog): jira-sync download_url .tar.gz -> .zip (installer is ZIP-only)

The spec-kit extension installer saves {id}-{version}.zip and extracts via
zipfile.ZipFile (src/specify_cli/extensions.py) — a .tar.gz asset downloads but
fails extraction. Matches every other catalog entry's /archive/refs/tags/vX.zip
convention. Addresses the Copilot review on PR #2895.

---------

Co-authored-by: Ash Brener <ashley@midletearth.com>
2026-06-10 07:43:12 -05:00
Manfred Riem
7c610a38cd chore: release 0.10.1, begin 0.10.2.dev0 development (#2910)
* chore: bump version to 0.10.1

* chore: begin 0.10.2.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-09 17:13:20 -05:00
Manfred Riem
a72ba95460 Update DocGuard — CDD Enforcement extension to v0.25.1 (#2909)
Update docguard extension submitted by @raccioly:
- extensions/catalog.community.json (version, download_url, updated_at)

Closes #2907

Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-09 17:10:53 -05:00
Thorsten Hindermann
fa93572e27 Update a11y-governance preset to v0.3.0 (#2867)
* Update a11y-governance preset to v0.3.0

* Fix a11y-governance catalog updated_at
2026-06-09 16:28:45 -05:00
Wondr
0b82a1ddf1 docs: document spec persistence models (#2856) 2026-06-09 15:52:59 -05:00
Ash Brener
d3f872f484 chore(catalog): bump Linear Integration to v0.3.0 (repo renamed to spec-kit-linear-sync) (#2893)
* chore(catalog): bump linear to v0.3.0 + spec-kit-linear-sync URLs

The Linear extension repo was renamed ashbrener/spec-kit-linear -> spec-kit-linear-sync
and shipped v0.3.0. Update the community catalog entry's download_url (was pinned to
v0.2.0), repository/homepage/documentation/changelog URLs, and version. extension id
stays 'linear' (commands unchanged); old GitHub URLs redirect.

* docs(community): point Linear extension table row at spec-kit-linear-sync

---------

Co-authored-by: Ash Brener <ashley@midletearth.com>
2026-06-09 08:40:01 -05:00
Ricardo Accioly
8373a60107 chore: update DocGuard extension to v0.25.0 (#2707)
Bump the docguard community catalog entry 0.9.11 -> 0.25.0, point the
download at the v0.25.0 release asset, and update the description to
reflect the single pinned runtime dependency (@babel/parser, added in
v0.24 for AST-based validation). Sync the docs/community table row to
match. Rebased onto current main to clear the prior merge conflict.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 08:27:39 -05:00
Ali jawwad
9c4fa31cec chore: remove unused open_github_url/_StripAuthOnRedirect from _github_http.py (#2883)
open_github_url() was orphaned when #2393 moved download authentication
to the config-driven registry in authentication/http.py; its dedicated
_StripAuthOnRedirect handler was referenced only by open_github_url
itself and duplicated the live implementation in authentication/http.py.

Remove both, keep the live resolve_github_release_asset_api_url() and
the tested build_github_request()/GITHUB_HOSTS utilities, and update
the module docstring to match what the module does today.

No runtime behavior change.

Closes #2876

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 08:06:40 -05:00
Quratulain-bilal
de88c23bb6 fix(catalogs): validate extension and preset catalog payload shape (#2621)
* fix(catalogs): validate extension and preset catalog payload shape

`ExtensionCatalog._fetch_single_catalog` and
`PresetCatalog._fetch_single_catalog` only check that the `extensions` /
`presets` key is *present* in the parsed catalog JSON. They don't check
that the value is a JSON object, and they don't check that the root is
a JSON object at all. A malformed (or compromised) upstream catalog
returning:

    {"schema_version": "1.0", "extensions": []}

passes both `"extensions" not in catalog_data` and the subsequent
`response.read()` JSON parse, gets cached on disk, and then crashes
deep inside `_get_merged_extensions` (resp. `_get_merged_packs`) with:

    AttributeError: 'list' object has no attribute 'items'

instead of the existing user-facing
`ExtensionError("Invalid catalog format from <url>")` /
`PresetError("Invalid preset catalog format")` that the surrounding
code is clearly trying to produce.

The sibling integration-catalog reader already validates this — see
`src/specify_cli/integrations/catalog.py` where the fetch path
explicitly checks both `isinstance(catalog_data, dict)` and
`isinstance(catalog_data.get("integrations"), dict)` before returning.
This change mirrors that pattern in the extension and preset readers so
the three catalog fetchers stay consistent and a malformed upstream
surfaces as the user-facing error instead of a raw Python traceback.

Adds parametrized regression tests covering:
- root payload is not a JSON object (list, str, int, null)
- root is a dict but `extensions` / `presets` value is the wrong type
  (list, str, null, int)

All eight bad-payload shapes now raise the expected catalog error.

* fix(catalogs): skip non-mapping entries during extension and preset merge

Addresses Copilot review feedback on this PR.

`_fetch_single_catalog` now validates that the ``extensions`` / ``presets``
value is a mapping, but it doesn't (and shouldn't) validate every entry
inside that mapping. A payload like:

    {"schema_version": "1.0", "extensions": {"good": {...}, "bad": []}}

passes the fetch-level guard, then later crashes inside
``_get_merged_extensions`` (resp. ``_get_merged_packs``) at
``{**ext_data, ...}`` with ``TypeError: 'list' object is not a mapping``.

The sibling integration-catalog reader at
``src/specify_cli/integrations/catalog.py:245`` handles this with a
per-entry ``isinstance(integ_data, dict)`` skip during merge, so one
malformed entry doesn't poison an otherwise valid catalog. This change
mirrors that pattern in the extension and preset mergers and adds
regression tests asserting that valid entries continue to merge while
malformed siblings are silently dropped.

* fix(catalogs): validate cached extension and preset payload shape

Addresses Copilot review feedback on this PR (round 2).

The earlier commits in this branch added payload-shape validation on the
network fetch path. The cache-hit path still returned
``json.loads(cache_file.read_text())`` directly without re-checking the
shape, so a cache poisoned by an older spec-kit version (or a manual
edit, or an upstream that briefly served a bad payload before the
network guards landed) would re-crash every invocation of
``_get_merged_extensions`` / ``_get_merged_packs`` with
``AttributeError: 'list' object has no attribute 'items'`` despite the
cache being "valid" by age.

Extracts the shape validation into ``_validate_catalog_payload`` on both
``ExtensionCatalog`` and ``PresetCatalog``, and calls it from both the
cache-load and network-fetch branches of ``_fetch_single_catalog``. If
the cached payload fails validation, the cache read is treated like a
``json.JSONDecodeError`` — the cached value is discarded and the
function falls through to the network fetch, which refreshes the cache
with a clean payload on success. Never propagates ``AttributeError`` to
the caller.

Regression tests parametrize the four root-bad-type variants plus three
``extensions``/``presets``-bad-type variants per file, asserting that a
poisoned cache silently recovers via network refetch and returns the
freshly-fetched payload.

* fix(catalogs): include URL in missing-keys error to match sibling branches

Addresses Copilot review feedback on this PR (round 3).

``_validate_catalog_payload`` advertises in its docstring that the
catalog URL is included in error messages "so the user can tell which
catalog in a multi-catalog stack is malformed" — but the missing-keys
branch raised ``PresetError("Invalid preset catalog format")`` without
the URL, breaking that contract and making multi-catalog debugging
harder. The root-bad-type and nested-bad-type branches in the same
helper already include the URL; this commit brings the middle branch
in line.

For consistency, the same fix is applied to the legacy single-catalog
fetch paths in ``ExtensionCatalog.fetch_catalog`` and
``PresetCatalog.fetch_catalog`` (where the URL was likewise dropped
from the missing-keys error).

The existing regex matchers in the regression tests target the
``"Invalid (preset )?catalog format"`` prefix, which is preserved
verbatim before the ``from <url>`` suffix — no test changes needed.

* fix(catalogs): broaden cache except tuples and reuse validator in fetch_catalog

Addresses Copilot review feedback on this PR (round 4):

1. ``ExtensionCatalog.fetch_catalog`` and ``PresetCatalog.fetch_catalog``
   — the legacy single-catalog methods — still only checked key
   presence. A payload like ``42`` (root non-object) crashed with
   ``TypeError: argument of type 'int' is not iterable`` during the
   ``"schema_version" in catalog_data`` check, and an entry mapping of
   the wrong type crashed downstream. Both now reuse
   ``_validate_catalog_payload`` so the network-side behaviour of the
   legacy methods stays consistent with the multi-catalog
   ``_fetch_single_catalog`` path. (Copilot #3335623482, #3335623556.)

2. The cache-read ``except`` tuples in ``_fetch_single_catalog`` and
   ``fetch_catalog`` were too narrow. ``read_text`` can raise
   ``OSError`` (permissions / disk / handle limit) or ``UnicodeError``
   (cache file written by an older client in a different encoding)
   in addition to ``json.JSONDecodeError``. Without those in the
   tuple, an unreadable cache crashed the caller instead of falling
   through to the network refetch the cache contract documents. Both
   sites now catch ``(json.JSONDecodeError, OSError, UnicodeError,
   <DomainError>)``. (Copilot #3335623588, #3335623608.)

3. While here, pinned ``encoding="utf-8"`` on every cache ``read_text``
   call so cache files written by an older Windows client (with a
   non-UTF-8 default locale) decode the same way on a newer client.

Regression tests:

  - ``test_fetch_catalog_rejects_malformed_payload`` — 7 parametrized
    payloads per file covering root-non-object + nested-bad-type
    variants asserting ``fetch_catalog`` raises the named domain error.
  - ``test_fetch_catalog_recovers_from_unreadable_cache`` — writes
    ``b"\xff\xfe\x00not-utf-8"`` to the cache file and asserts
    ``fetch_catalog`` silently falls through to the mocked network and
    returns the freshly-fetched payload.

* fix(catalogs): harden cache-validity checks and pin UTF-8 on writes

The cache-best-effort contract added in 7f44b25 was incomplete on two
points raised by Copilot:

1. The cache-validity helpers (is_cache_valid /
   _is_url_cache_valid, plus the inline metadata-age check inside
   _fetch_single_catalog for per-URL caches) read the metadata file
   without specifying an encoding and only caught
   json.JSONDecodeError / ValueError / KeyError /
   TypeError. A metadata file written by a tool using the system
   locale codec, or one whose handle is briefly unavailable, would
   raise UnicodeDecodeError / OSError and propagate past the
   read-side try/except in fetch_catalog — the very crash the
   read-side guard was meant to prevent. The validity checks now read
   with encoding="utf-8" and treat OSError / UnicodeError
   as cache-invalid, matching the documented contract.

2. The network-fetch path wrote the cache and metadata files with bare
   write_text(...), picking up the platform default encoding. The
   read path was already pinned to UTF-8 (and the
   integrations/catalog.py:193-203 sibling writes UTF-8 too), so
   on hosts whose default codec isn't UTF-8 the write/read pair could
   disagree and force an unnecessary refetch on every invocation. All
   four write_text calls now pass encoding="utf-8" so the
   cache survives a round trip on any platform.

Also rewords the misleading # Fetch from network comment in
extensions.fetch_catalog — it sat above the cache-check block,
which read as if the cache step had been skipped.

Tests
-----

Adds two parametrized regression tests per catalog:

* test_fetch_catalog_recovers_from_unreadable_metadata plants
  non-UTF-8 bytes in the metadata file, asserts is_cache_valid()
  returns False (rather than raising), and confirms
  fetch_catalog falls through to the network instead of crashing.

* test_fetch_catalog_writes_cache_as_utf8 round-trips a payload
  containing a non-ASCII identifier (café) through the public
  fetch path and reads the cache back with
  read_text(encoding="utf-8"), catching encoding drift at the
  byte level rather than relying on the system codec to happen to be
  UTF-8.

Both pairs follow the established sibling-file symmetry — the
extension and preset suites stay in lock-step.

* test(catalogs): assert UTF-8 write encoding by recording write_text kwargs

Copilot's review on this PR caught that test_fetch_catalog_writes_cache_as_utf8
claimed to validate UTF-8 at the byte level but actually only round-tripped a
non-ASCII string through json.dumps/read_text. Because json.dumps defaults to
ensure_ascii=True, 'café' was serialized as the all-ASCII escape 'caf\u00e9'
before reaching write_text — the bytes on disk were identical regardless of the
encoding kwarg, so a locale-encoded write would have round-tripped just fine.
The drift guard the test name advertised was not actually being enforced.

Rewriting these tests to observe the production code's argument directly:
each test now monkey-patches pathlib.Path.write_text with a recorder that
captures the encoding kwarg for every call, runs the production fetch, and
asserts every write into the cache directory passed encoding='utf-8'. That is
the substantive thing the regression guard cares about — non-ASCII payload
tricks were the wrong lever to pull, because json.dumps was masking the
encoding choice before write_text ever ran.

Both tests verified locally against the current production code (492 passed in
the extensions+presets suites) and confirmed to fail against a synthetic
no-encoding write (the recorder records None instead of 'utf-8', the assertion
catches it). Same change applied symmetrically to test_extensions.py and
test_presets.py to keep the sibling files in lockstep with the production
code paths in extensions.py and presets.py.

* fix(catalogs): catch AttributeError on non-mapping cache metadata; drop stale line refs

Copilot's review on the previous push pointed out that the
cache-validity helpers still had a gap: metadata.get("cached_at", "")
assumes metadata is a dict, but json.loads happily parses a
file containing [] / "oops" / 42 / true / null into
a non-mapping. The except tuple covered json.JSONDecodeError,
OSError, UnicodeError, ValueError, KeyError and
TypeError but not AttributeError, so a valid-JSON-but-non-dict
metadata payload would still crash the caller instead of degrading to
"cache invalid" as the docstring promised.

This affected four cache-validity sites — symmetric across the two
catalog modules:

* extensions.py — inline per-URL metadata-age check in
  _fetch_single_catalog
* extensions.py — is_cache_valid (legacy default-URL path)
* presets.py — _is_url_cache_valid
* presets.py — is_cache_valid

All four except tuples now include AttributeError with a comment
naming the exact failure (metadata.get(...) on a non-mapping) so
the next reader doesn't have to reconstruct the reasoning.

Separately, Copilot flagged that several comments hard-coded a line
range from a sibling file
(integrations/catalog.py:193-203) — those references will go stale
the moment that file changes. Replaced the hard-coded ranges with
file-only references (integrations/catalog.py) so the pointer
stays accurate as that file evolves. Same change applied to both
modules.

Tests
-----

test_is_cache_valid_handles_non_mapping_metadata is added to both
test_extensions.py and test_presets.py, parametrized over the
five JSON non-mapping root types ([], "oops", 42,
true, null). Each variant plants the metadata file with that
exact content and asserts is_cache_valid() returns False
without raising. The parametrize covers every JSON type the public
spec allows at the root, so a regression that drops AttributeError
from any except tuple is caught against every observable shape rather
than relying on the next reviewer to remember the .get /
non-mapping interaction.

pytest tests/test_extensions.py tests/test_presets.py — 502
passed (was 492 before; the parametrize adds five vectors per file).

* fix(catalogs): make cache writes best-effort to match read-side contract
2026-06-09 07:22:49 -05:00
Pascal THUET
f65d9f9382 feat(integration): add status reporting (#2674)
* feat(integration): add status reporting

* docs(integration): include status in query command docstring

* fix(integration): handle Windows extended-length paths in status containment

On Windows, os.readlink() (and sometimes Path.resolve()) return paths with
the \\?\ extended-length prefix. Comparing such a target against a plain
project root via Path.relative_to() spuriously fails, so an in-project
dangling symlink was classified as `invalid` instead of `missing` — failing
test_status_treats_dangling_symlink_as_missing and the windows-style variant
on the Windows CI runners.

Centralize the containment check in _is_within_project() and strip the
\\?\ / \\?\UNC\ prefix from both sides before relative_to(). Add portable
regression tests for the prefix-stripping helper and the containment contract.

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>

* test(integration): restore top-level pytest import after rebase

A three-way merge / rebase onto main silently dropped the module-level
`import pytest` from test_integration_subcommand.py: main reorganized the
import block without it (using only a local `import pytest as _pytest`),
while this branch added top-level fixtures and `pytest.skip`/`pytest.raises`
usage. The overlapping import-hunk edits resolved by dropping the import,
breaking collection with `NameError: name 'pytest' is not defined` on every
runner. Re-add the import in the third-party group.

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

* test(integration): fix Windows UNC path assertion in status helper test

`test_strip_extended_length_prefix_normalizes_windows_paths` compared the
str() form of the helper's output against a hand-built string. On Windows,
pathlib renders a UNC root with a trailing separator (`\\server\share\`),
so the exact string match failed there (`\\server\share\` != `\\server\share`)
even though `_strip_extended_length_prefix` behaves correctly — the trailing
separator is irrelevant to the `relative_to` containment check it feeds.

Compare Path objects (semantic equality) instead of exact strings so the
assertion holds on both POSIX and Windows. No production code change needed.

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

* fix(integration): make shared-manifest remediation specify --integration

The fallback `_manifest_suggestion` for the shared `speckit` manifest (used
when no usable default integration is recorded) suggested
`specify init --here --force`, which can trigger interactive integration
selection. For CI/agent consumers of `integration status`, surface an
explicit `--integration <key>` placeholder, matching the file's existing
`<key>` suggestion style.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-09 07:20:43 -05:00
Manfred Riem
ad9f047aaa chore: release 0.10.0, begin 0.10.1.dev0 development (#2904)
* chore: bump version to 0.10.0

* chore: begin 0.10.1.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-09 06:21:04 -05:00
Copilot
927f54feea feat: make git extension opt-in and remove --no-git at v0.10.0 (#2873)
* feat(init)!: make git extension opt-in and remove --no-git at v0.10.0

- Remove --no-git parameter from specify init command
- Remove git extension auto-installation from init flow
- Git repository initialization (git init) still runs when git is available
- Remove --no-git from all test invocations across the test suite
- Update docs to reflect opt-in git extension behavior
- Replace TestGitExtensionAutoInstall with TestGitExtensionOptIn tests

BREAKING CHANGE: specify init no longer auto-installs the git extension.
Use `specify extension add git` to install it explicitly.
The --no-git flag has been removed.

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

* refactor(scripts): remove git operations from core scripts

Git functionality is now entirely managed by the git extension.
Core scripts only handle directory-based feature creation and numbering.

- Remove has_git(), check_feature_branch(), git branch creation from core
- Simplify number detection to use only spec directory scanning
- Remove HAS_GIT output from get_feature_paths()
- Remove git remote fetching and branch querying
- Keep BRANCH_NAME output key for backward compatibility

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

* refactor: remove all git operations from core

- Remove is_git_repo() and init_git_repo() dead code from _utils.py
- Remove --branch-numbering from init command
- Remove git from 'specify check' (now extension-only)
- Update docs: git is optional prerequisite, check command description
- Fix tests to reflect no-git-in-core reality (fallback to main)

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

* refactor(scripts): remove directory scanning and branch fallback from core

Core scripts now resolve feature context exclusively from:
1. SPECIFY_FEATURE env var (set by git extension)
2. .specify/feature.json (persisted by specify command)

Removed find_feature_dir_by_prefix() and directory scanning heuristics —
these are the git extension's responsibility. Scripts error clearly when
no feature context is available.

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

* feat: introduce feature_numbering, deprecate branch_numbering in init-options

- specify command template now reads feature_numbering (preferred) with
  fallback to branch_numbering (deprecated) from init-options.json
- Git extension reads git-config.yml > feature_numbering > branch_numbering
- init now writes feature_numbering: sequential to init-options.json
- Deprecation warning emitted when branch_numbering is used as fallback

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

* fix: remove trailing whitespace in common.ps1

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

* feat(scripts): persist SPECIFY_FEATURE_DIRECTORY env var to feature.json

When SPECIFY_FEATURE_DIRECTORY is set, get_feature_paths() now writes the
value to .specify/feature.json so future sessions without the env var can
still resolve the feature directory. The write is idempotent — it skips
when the file already contains the same value.

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

* fix: address review feedback — error messages and docs

- Update error messages in common.sh and common.ps1 to reference
  SPECIFY_FEATURE_DIRECTORY instead of SPECIFY_FEATURE (which no longer
  resolves feature directories)
- Fix get_current_branch comment (returns empty string, not error)
- Update upgrade.md to reference SPECIFY_FEATURE_DIRECTORY with correct
  example paths
- Update local-development.md troubleshooting: replace stale 'Git step
  skipped' row with actionable git extension guidance

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

* fix(scripts): harden feature.json persistence

- Use json_escape in printf fallback when jq is unavailable (common.sh)
- Replace utf8NoBOM encoding with UTF8Encoding($false) for PowerShell
  5.1 compatibility (common.ps1)

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

* refactor(scripts): remove dead feature_json_matches_feature_dir functions

These guards are no longer needed since the branch-name validation they
protected against has been removed from check-prerequisites.

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

* refactor(git-ext): rename create-new-feature to create-new-feature-branch

The git extension's script only creates the git branch — rename it to
reflect that responsibility. The core create-new-feature.sh/.ps1 handles
feature directory creation and feature.json persistence.

Also includes fixes from review feedback:
- common.sh: _persist_feature_json uses json_escape fallback
- common.ps1: Save-FeatureJson uses UTF8Encoding for PS 5.1 compat
- common.ps1: case-sensitive path stripping on non-Windows
- create-new-feature.sh/ps1: output both SPECIFY_FEATURE and
  SPECIFY_FEATURE_DIRECTORY
- setup-tasks.sh: fix stale 'Validate branch' comment

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

* fix(tests): update references to renamed git extension scripts

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

* fix(tests): remove duplicate EXT_CREATE_FEATURE assignments

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

---------

Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-09 06:13:07 -05:00
adaumann
90832d19bf [Preset] UpdateFiction book writing v1.9.0 - Illustration support (#2821)
* Update preset-fiction-book-writing to community catalog

- Preset ID: fiction-book-writing
- Version: 1.5.0
- Author: Andreas Daumann
- Description: Spec-Driven Development for novel and long-form fiction. Replaces software engineering terminology with storytelling craft: specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports 8 POV modes, all major plot structure frameworks, 5 humanized-AI prose profiles, and exports to DOCX/EPUB/LaTeX via pandoc. V1.5.0: Support interactive, audiobooks, series, workflow corrections

* Add fiction-book-writing preset to community catalog

- Preset ID: fiction-book-writing
- Version: 1.6.0
- Author: Andreas Daumann
- Description: Added support for 12 languages, export with templates, cover builder, bio builder, workflow fixes

* Update presets/catalog.community.json

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

* fixed update_at for fiction-book-writing preset

* Update README.md

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

* fixed description for fiction-book-writing

* Update Fiction Book Writing to community catalog

- Preset ID: fiction-book-writing
- Version: 1.9.0
- Author: Andreas Daumann
- Description: Update added illustration support

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-06-08 15:45:01 -05:00
WOLIKIMCHENG
d8a81b23b5 test(workflows): cover executable override fallback preflight (#2843) 2026-06-08 15:33:37 -05:00
Gary
a0305fc511 Add GitHub Copilot CLI guidance to readme (#2891)
* Update README with GitHub Copilot CLI details

Added mention of GitHub Copilot CLI for agent selection based on docs at https://docs.github.com/en/copilot/how-tos/copilot-cli/use-copilot-cli/invoke-custom-agents#use-custom-agents

* Fix typo in README regarding GitHub Copilot CLI
2026-06-08 14:17:15 -05:00
Manfred Riem
d977feea01 Update Security Review extension to v1.5.3 (#2898)
* Update Security Review extension to v1.5.3

Update security-review extension submitted by @DyanGalih:
- extensions/catalog.community.json (version, download_url, updated_at)

Closes #2869

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

* Fix author field to match extension.yml manifest

Update security-review author from 'DyanGalih' to 'Spec-Kit Security Team'
to match the extension's extension.yml declaration.

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

---------

Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-08 10:20:20 -05:00
Manfred Riem
c53a08802c Update Architecture Guard extension to v1.8.17 (#2897)
Update architecture-guard extension submitted by @DyanGalih:
- extensions/catalog.community.json (version, download_url, documentation, updated_at)

Closes #2868

Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-08 10:06:44 -05:00
78 changed files with 4741 additions and 1861 deletions

View File

@@ -70,6 +70,8 @@ Use the existing entries as the format template. Required fields:
"documentation": "<documentation>", "documentation": "<documentation>",
"changelog": "<changelog>", "changelog": "<changelog>",
"license": "<license>", "license": "<license>",
"category": "<category>",
"effect": "<effect>",
"requires": { "requires": {
"speckit_version": "<speckit_version>" "speckit_version": "<speckit_version>"
}, },
@@ -87,6 +89,9 @@ Use the existing entries as the format template. Required fields:
} }
``` ```
**Category** — free-form string; common values: `docs`, `code`, `process`, `integration`, `visibility`
**Effect** — one of: `read-only`, `read-write`
If the extension has optional tool dependencies, add a `"tools"` array inside `"requires"`: If the extension has optional tool dependencies, add a `"tools"` array inside `"requires"`:
```json ```json
@@ -113,8 +118,8 @@ Determine the category and effect from the extension's behavior:
| <Name> | <Description> | `<category>` | <Effect> | [<repo-name>](<repository-url>) | | <Name> | <Description> | `<category>` | <Effect> | [<repo-name>](<repository-url>) |
``` ```
**Category**one of: `docs`, `code`, `process`, `integration`, `visibility` **Category**free-form; common values: `docs`, `code`, `process`, `integration`, `visibility`
**Effect**`Read-only` (produces reports only) or `Read+Write` (modifies project files) **Effect** write canonical values `read-only` or `read-write` in `extension.yml` and `catalog.community.json`; use `Read-only`/`Read+Write` only for the docs table display
### 6. Commit, push, and open PR ### 6. Commit, push, and open PR

View File

@@ -2,6 +2,64 @@
<!-- insert new changelog below this comment --> <!-- insert new changelog below this comment -->
## [0.10.3] - 2026-06-16
### Changed
- Update Superpowers Bridge extension to v1.6.0 (#2998)
- Add Improve Extension to community catalog (#2997)
- Update Product Forge extension to v1.7.0 (#2996)
- Update Linear Integration extension to v0.5.0 (#2995)
- Update Superpowers Implementation Bridge extension to v1.0.3 (#2993)
- Update Ralph community extension to v1.1.1 (#2861)
- Update Linear Integration extension to v0.4.0 (#2942)
- Update DocGuard — CDD Enforcement to v0.26.0 (#2941)
- Add SpecKit Companion extension to community catalog (#2937)
- chore: release 0.10.2, begin 0.10.3.dev0 development (#2936)
## [0.10.2] - 2026-06-11
### Changed
- Add Research Harness extension to community catalog (#2935)
- Add Coding Standards Drift Control extension to community catalog (#2934)
- Add Spec Trace extension to community catalog (#2527)
- fix(extensions): preserve argument-hint in extension Claude SKILL.md (#2916)
- fix(presets): harden preset URL installs against unsafe redirects (#2911)
- fix: skip recovered files during refresh_managed overwrite check (#2918) (#2919)
- Update multi-model-review extension to v0.1.1 (#2900)
- feat: add category and effect as first-class fields in extension schema (#2899)
- chore(catalog): add Jira Integration (Sync Engine) extension (#2895)
- chore: release 0.10.1, begin 0.10.2.dev0 development (#2910)
## [0.10.1] - 2026-06-09
### Changed
- Update DocGuard — CDD Enforcement extension to v0.25.1 (#2909)
- Update a11y-governance preset to v0.3.0 (#2867)
- docs: document spec persistence models (#2856)
- chore(catalog): bump Linear Integration to v0.3.0 (repo renamed to spec-kit-linear-sync) (#2893)
- chore: update DocGuard extension to v0.25.0 (#2707)
- chore: remove unused open_github_url/_StripAuthOnRedirect from _github_http.py (#2883)
- fix(catalogs): validate extension and preset catalog payload shape (#2621)
- feat(integration): add status reporting (#2674)
- chore: release 0.10.0, begin 0.10.1.dev0 development (#2904)
## [0.10.0] - 2026-06-09
### Changed
- feat: make git extension opt-in and remove --no-git at v0.10.0 (#2873)
- [Preset] UpdateFiction book writing v1.9.0 - Illustration support (#2821)
- test(workflows): cover executable override fallback preflight (#2843)
- Add GitHub Copilot CLI guidance to readme (#2891)
- Update Security Review extension to v1.5.3 (#2898)
- Update Architecture Guard extension to v1.8.17 (#2897)
- feat(extensions): per-event hook lists with priority ordering (#2798)
- feat!: remove legacy --ai, --ai-commands-dir, and --ai-skills flags (0.10.0) (#2872)
- chore: release 0.9.5, begin 0.9.6.dev0 development (#2875)
## [0.9.5] - 2026-06-05 ## [0.9.5] - 2026-06-05
### Changed ### Changed

View File

@@ -79,7 +79,7 @@ Bare `specify self upgrade` executes immediately, matching the no-prompt behavio
### 3. Establish project principles ### 3. Establish project principles
Launch your coding agent in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead. Launch your coding agent in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead; GitHub Copilot CLI uses `/agents` to select the agent or address it directly in a prompt.
Use the **`/speckit.constitution`** command to create your project's governing principles and development guidelines that will guide all subsequent development. Use the **`/speckit.constitution`** command to create your project's governing principles and development guidelines that will guide all subsequent development.

View File

@@ -7,7 +7,7 @@
The following community-contributed extensions are available in [`catalog.community.json`](https://github.com/github/spec-kit/blob/main/extensions/catalog.community.json): The following community-contributed extensions are available in [`catalog.community.json`](https://github.com/github/spec-kit/blob/main/extensions/catalog.community.json):
**Categories:** **Categories** (common values, but any string is allowed):
- `docs` — reads, validates, or generates spec artifacts - `docs` — reads, validates, or generates spec artifacts
- `code` — reviews, validates, or modifies source code - `code` — reviews, validates, or modifies source code
@@ -15,10 +15,13 @@ The following community-contributed extensions are available in [`catalog.commun
- `integration` — syncs with external platforms - `integration` — syncs with external platforms
- `visibility` — reports on project health or progress - `visibility` — reports on project health or progress
**Effect:** **Effect** (canonical `extension.yml`/catalog values):
- `Read-only` — produces reports without modifying files - `read-only` — produces reports without modifying files (displayed as `Read-only` in the table)
- `Read+Write` — modifies files, creates artifacts, or updates specs - `read-write` — modifies files, creates artifacts, or updates specs (displayed as `Read+Write` in the table)
> [!TIP]
> Extension authors can declare `category` and `effect` in their `extension.yml` under the `extension:` block. These fields are also available in `catalog.community.json` for tooling and the CLI (`specify extension info`).
| Extension | Purpose | Category | Effect | URL | | Extension | Purpose | Category | Effect | URL |
|-----------|---------|----------|--------|-----| |-----------|---------|----------|--------|-----|
@@ -41,22 +44,25 @@ The following community-contributed extensions are available in [`catalog.commun
| CI Guard | Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps | `process` | Read-only | [spec-kit-ci-guard](https://github.com/Quratulain-bilal/spec-kit-ci-guard) | | CI Guard | Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps | `process` | Read-only | [spec-kit-ci-guard](https://github.com/Quratulain-bilal/spec-kit-ci-guard) |
| Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) | | Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) |
| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | | Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |
| Coding Standards Drift Control | Generate coding-standards drift reports and remediation tasks for active Spec Kit features | `code` | Read+Write | [spec-kit-coding-standards-drift-control](https://github.com/benizzio/spec-kit-coding-standards-drift-control) |
| Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) | | Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) |
| Confluence Extension | Create a doc in Confluence summarizing the specifications and planning files | `integration` | Read+Write | [spec-kit-confluence](https://github.com/aaronrsun/spec-kit-confluence) | | Confluence Extension | Create a doc in Confluence summarizing the specifications and planning files | `integration` | Read+Write | [spec-kit-confluence](https://github.com/aaronrsun/spec-kit-confluence) |
| Cost Tracker | Track real LLM dollar cost across SDD workflows — per-feature budgets, per-integration comparison, and finance-ready exports | `visibility` | Read+Write | [spec-kit-cost](https://github.com/Quratulain-bilal/spec-kit-cost) | | 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) |
| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) | | DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. One pinned runtime dependency; pure Node.js otherwise. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
| Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) | | 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) | | 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) | | FixIt Extension | Spec-aware bug fixing — maps bugs to spec artifacts, proposes a plan, applies minimal changes | `code` | Read+Write | [spec-kit-fixit](https://github.com/speckit-community/spec-kit-fixit) |
| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) | | Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |
| GitHub Issues Integration 1 | Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability | `integration` | Read+Write | [spec-kit-github-issues](https://github.com/Fatima367/spec-kit-github-issues) | | GitHub Issues Integration 1 | Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability | `integration` | Read+Write | [spec-kit-github-issues](https://github.com/Fatima367/spec-kit-github-issues) |
| GitHub Issues Integration 2 | Creates and syncs local specs from an existing GitHub issue | `integration` | Read+Write | [spec-kit-issue](https://github.com/aaronrsun/spec-kit-issue) | | GitHub Issues Integration 2 | Creates and syncs local specs from an existing GitHub issue | `integration` | Read+Write | [spec-kit-issue](https://github.com/aaronrsun/spec-kit-issue) |
| Improve Extension | Audits any codebase as a senior advisor and writes prioritized, self-contained spec prompts under specs/ that the spec-kit lifecycle can process | `process` | Read+Write | [spec-kit-improve](https://github.com/d0whc3r/spec-kit-improve) |
| Interactive HTML Preview | Generate self-contained interactive HTML prototypes from Spec Kit artifacts | `docs` | Read+Write | [spec-kit-preview](https://github.com/bigsmartben/spec-kit-preview) | | Interactive HTML Preview | Generate self-contained interactive HTML prototypes from Spec Kit artifacts | `docs` | Read+Write | [spec-kit-preview](https://github.com/bigsmartben/spec-kit-preview) |
| Intelligent Agent Orchestrator | Cross-catalog agent discovery and intelligent prompt-to-command routing | `process` | Read+Write | [spec-kit-orchestrator](https://github.com/pragya247/spec-kit-orchestrator) | | Intelligent Agent Orchestrator | Cross-catalog agent discovery and intelligent prompt-to-command routing | `process` | Read+Write | [spec-kit-orchestrator](https://github.com/pragya247/spec-kit-orchestrator) |
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) | | Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) | | Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
| Jira Integration (Sync Engine) | Idempotent, drift-aware, fail-closed reconcile engine mirroring spec-kit specs into Jira (Epic per repo, Story per spec, Subtask per phase) | `integration` | Read+Write | [spec-kit-jira-sync](https://github.com/ashbrener/spec-kit-jira-sync) |
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) | | Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
| Linear Integration | Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional). | `integration` | Read+Write | [spec-kit-linear](https://github.com/ashbrener/spec-kit-linear) | | Linear Integration | Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional). | `integration` | Read+Write | [spec-kit-linear-sync](https://github.com/ashbrener/spec-kit-linear-sync) |
| MAQA — Multi-Agent & Quality Assurance | Coordinator → feature → QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins. Optional CI gate. | `process` | Read+Write | [spec-kit-maqa-ext](https://github.com/GenieRobot/spec-kit-maqa-ext) | | MAQA — Multi-Agent & Quality Assurance | Coordinator → feature → QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins. Optional CI gate. | `process` | Read+Write | [spec-kit-maqa-ext](https://github.com/GenieRobot/spec-kit-maqa-ext) |
| MAQA Azure DevOps Integration | Azure DevOps Boards integration for MAQA — syncs User Stories and Task children as features progress | `integration` | Read+Write | [spec-kit-maqa-azure-devops](https://github.com/GenieRobot/spec-kit-maqa-azure-devops) | | MAQA Azure DevOps Integration | Azure DevOps Boards integration for MAQA — syncs User Stories and Task children as features progress | `integration` | Read+Write | [spec-kit-maqa-azure-devops](https://github.com/GenieRobot/spec-kit-maqa-azure-devops) |
| MAQA CI/CD Gate | Auto-detects GitHub Actions, CircleCI, GitLab CI, and Bitbucket Pipelines. Blocks QA handoff until pipeline is green. | `process` | Read+Write | [spec-kit-maqa-ci](https://github.com/GenieRobot/spec-kit-maqa-ci) | | MAQA CI/CD Gate | Auto-detects GitHub Actions, CircleCI, GitLab CI, and Bitbucket Pipelines. Blocks QA handoff until pipeline is green. | `process` | Read+Write | [spec-kit-maqa-ci](https://github.com/GenieRobot/spec-kit-maqa-ci) |
@@ -79,7 +85,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) | | Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) |
| PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) | | PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) |
| Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) | | Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) |
| Product Forge | Full product lifecycle from research to release — express/lite/standard/v-model tracks, living spec + traceability, structured journeys → E2E, monorepo, and selectable doc-structure strategies | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) | | Product Forge | Full product-lifecycle orchestrator for Spec Kit: research → product-spec → plan → tasks → implement → verify → test → release-readiness, across express/lite/standard/v-model modes with human-in-the-loop gates. | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) |
| Product Spec Extension | Generates PRFAQ, Lean PRD, stakeholder summaries, and technical designs from engineering specs | `docs` | Read+Write | [spec-kit-product](https://github.com/d0whc3r/spec-kit-product) | | Product Spec Extension | Generates PRFAQ, Lean PRD, stakeholder summaries, and technical designs from engineering specs | `docs` | Read+Write | [spec-kit-product](https://github.com/d0whc3r/spec-kit-product) |
| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) | | Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) |
| Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) | | Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) |
@@ -88,6 +94,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss-Projects/spec-kit-ralph) | | Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss-Projects/spec-kit-ralph) |
| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) | | Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) |
| Red Team | Adversarial review of specs before /speckit.plan — parallel lens agents surface risks that clarify/analyze structurally can't (prompt injection, integrity gaps, cross-spec drift, silent failures). Produces a structured findings report; no auto-edits to specs. | `docs` | Read+Write | [spec-kit-red-team](https://github.com/ashbrener/spec-kit-red-team) | | Red Team | Adversarial review of specs before /speckit.plan — parallel lens agents surface risks that clarify/analyze structurally can't (prompt injection, integrity gaps, cross-spec drift, silent failures). Produces a structured findings report; no auto-edits to specs. | `docs` | Read+Write | [spec-kit-red-team](https://github.com/ashbrener/spec-kit-red-team) |
| Research Harness | State-externalizing research harness: budgeted exploration, evidence curation, and claim verification for spec-driven development | `process` | Read+Write | [spec-kit-harness](https://github.com/formin/spec-kit-harness) |
| Repository Index | Generate index for existing repo for overview, architecture and module level. | `docs` | Read-only | [spec-kit-repoindex](https://github.com/liuyiyu/spec-kit-repoindex) | | Repository Index | Generate index for existing repo for overview, architecture and module level. | `docs` | Read-only | [spec-kit-repoindex](https://github.com/liuyiyu/spec-kit-repoindex) |
| Reqnroll BDD | Adds Reqnroll BDD planning, Gherkin generation, traceability, safe task injection, handoff, and verification to Spec Kit | `process` | Read+Write | [spec-kit-reqnroll-bdd](https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd) | | Reqnroll BDD | Adds Reqnroll BDD planning, Gherkin generation, traceability, safe task injection, handoff, and verification to Spec Kit | `process` | Read+Write | [spec-kit-reqnroll-bdd](https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd) |
| Retro Extension | Sprint retrospective analysis with metrics, spec accuracy assessment, and improvement suggestions | `process` | Read+Write | [spec-kit-retro](https://github.com/arunt14/spec-kit-retro) | | Retro Extension | Sprint retrospective analysis with metrics, spec accuracy assessment, and improvement suggestions | `process` | Read+Write | [spec-kit-retro](https://github.com/arunt14/spec-kit-retro) |
@@ -107,13 +114,15 @@ The following community-contributed extensions are available in [`catalog.commun
| Spec Refine | Update specs in-place, propagate changes to plan and tasks, and diff impact across artifacts | `process` | Read+Write | [spec-kit-refine](https://github.com/Quratulain-bilal/spec-kit-refine) | | Spec Refine | Update specs in-place, propagate changes to plan and tasks, and diff impact across artifacts | `process` | Read+Write | [spec-kit-refine](https://github.com/Quratulain-bilal/spec-kit-refine) |
| Spec Scope | Effort estimation and scope tracking — estimate work, detect creep, and budget time per phase | `process` | Read-only | [spec-kit-scope-](https://github.com/Quratulain-bilal/spec-kit-scope-) | | Spec Scope | Effort estimation and scope tracking — estimate work, detect creep, and budget time per phase | `process` | Read-only | [spec-kit-scope-](https://github.com/Quratulain-bilal/spec-kit-scope-) |
| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | | Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) |
| Spec Trace | Build a requirement → test traceability matrix from spec.md and the test suite — surface untested requirements and orphan tests | `code` | Read+Write | [spec-kit-trace](https://github.com/Quratulain-bilal/spec-kit-trace) |
| Spec Validate | Comprehension validation, review gating, and approval state for spec-kit artifacts — staged quizzes, peer review SLA, and a hard gate before /speckit.implement | `process` | Read+Write | [spec-kit-spec-validate](https://github.com/aeltayeb/spec-kit-spec-validate) | | Spec Validate | Comprehension validation, review gating, and approval state for spec-kit artifacts — staged quizzes, peer review SLA, and a hard gate before /speckit.implement | `process` | Read+Write | [spec-kit-spec-validate](https://github.com/aeltayeb/spec-kit-spec-validate) |
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure | `process` | Read+Write | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) | | Spec2Cloud | Spec-driven workflow tuned for shipping to Azure | `process` | Read+Write | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
| SpecKit Companion | Live spec-driven progress — lifecycle capture, status, resume, and a turbo pipeline profile | `visibility` | Read+Write | [speckit-companion](https://github.com/alfredoperez/speckit-companion) |
| SpecTest | Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements | `code` | Read+Write | [spec-kit-spectest](https://github.com/Quratulain-bilal/spec-kit-spectest) | | SpecTest | Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements | `code` | Read+Write | [spec-kit-spectest](https://github.com/Quratulain-bilal/spec-kit-spectest) |
| Squad Bridge | Bootstrap and synchronize a Squad agent team from your Speckit spec and tasks. | `process` | Read+Write | [spec-kit-squad](https://github.com/jwill824/spec-kit-squad) | | Squad Bridge | Bootstrap and synchronize a Squad agent team from your Speckit spec and tasks. | `process` | Read+Write | [spec-kit-squad](https://github.com/jwill824/spec-kit-squad) |
| Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) | | Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) |
| Status Report | Project status, feature progress, and next-action recommendations for spec-driven workflows | `visibility` | Read-only | [Open-Agent-Tools/spec-kit-status](https://github.com/Open-Agent-Tools/spec-kit-status) | | Status Report | Project status, feature progress, and next-action recommendations for spec-driven workflows | `visibility` | Read-only | [Open-Agent-Tools/spec-kit-status](https://github.com/Open-Agent-Tools/spec-kit-status) |
| Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) | | Superpowers Bridge | Bridges selected Superpowers disciplines into Spec Kit as evidence-first trust gates for agent workflows. | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) |
| Superpowers Implementation Bridge | Thin orchestrator between Spec Kit (design) and Superpowers (implementation). Cross-agent. | `process` | Read+Write | [speckit-superpowers-bridge](https://github.com/lihan3238/speckit-superpowers-bridge) | | Superpowers Implementation Bridge | Thin orchestrator between Spec Kit (design) and Superpowers (implementation). Cross-agent. | `process` | Read+Write | [speckit-superpowers-bridge](https://github.com/lihan3238/speckit-superpowers-bridge) |
| Superspec | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) | | Superspec | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) |
| Team Assign | Assign tasks.md items to human engineers, split into subtasks, and generate a per-engineer workboard | `process` | Read+Write | [spec-kit-team-assign](https://github.com/tarunkumarbhati/spec-kit-team-assign) | | Team Assign | Assign tasks.md items to human engineers, split into subtasks, and generate a per-engineer workboard | `process` | Read+Write | [spec-kit-team-assign](https://github.com/tarunkumarbhati/spec-kit-team-assign) |

View File

@@ -7,7 +7,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Preset | Purpose | Provides | Requires | URL | | Preset | Purpose | Provides | Requires | URL |
|--------|---------|----------|----------|-----| |--------|---------|----------|----------|-----|
| A11Y Governance | Adds WCAG 2.2 AA accessibility checks, bilingual DE/EN delivery, CEFR-B2 readability, CLI accessibility, and inclusive-content guidance | 9 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) | | A11Y Governance | Adds WCAG 2.2 AA accessibility checks, bilingual DE/EN delivery, CEFR-B2 readability, CLI accessibility, inclusive-content guidance, and didactic inline-code-comment review | 10 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
| Agent Parity Governance | Keeps shared AI-agent instructions aligned and adds agent-neutral Spec Kit model-routing guidance across project-defined agent guidance surfaces | 9 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) | | Agent Parity Governance | Keeps shared AI-agent instructions aligned and adds agent-neutral Spec Kit model-routing guidance across project-defined agent guidance surfaces | 9 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | | AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
| Architecture Governance | Adds secure architecture governance: trust boundaries, threat modeling, STRIDE/CAPEC, S-ADRs, Zero Trust applicability, and OWASP SAMM | 11 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) | | Architecture Governance | Adds secure architecture governance: trust boundaries, threat modeling, STRIDE/CAPEC, S-ADRs, Zero Trust applicability, and OWASP SAMM | 11 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
@@ -15,7 +15,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) | | Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) |
| Cross-Platform Governance | Adds Bash/PowerShell parity, dry-run/WhatIf parity, Unix man-page expectations, PowerShell comment-based help, and Verb-Noun Cmdlet discipline | 8 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) | | Cross-Platform Governance | Adds Bash/PowerShell parity, dry-run/WhatIf parity, Unix man-page expectations, PowerShell comment-based help, and Verb-Noun Cmdlet discipline | 8 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) | | Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose principles. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. | 25 templates, 33 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) | | Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose principles. Supports interactive elements like brainstorming, interview, roleplay, and extras like statistics, cover builder, illustration builder, and bio command. Export with templates for KDP, D2D, etc. | 26 templates, 34 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
| Game Narrative Writing | Spec-Driven Development for interactive game narrative pre-production for video games. Authors write in a portable generic format, Twine/Sugarcube (.twee) or Ink (.ink). Covers choice-IF, visual novels, and branching dialogue. Supports Tier 1 mechanic hooks (flag, counter, inventory, timer, trust, currency, npc_state, ending_condition), multi-ending design, series carry-over variable registry, and NPC-focused character architecture. | 22 templates, 36 commands, 2 scripts | — | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-writing) | | Game Narrative Writing | Spec-Driven Development for interactive game narrative pre-production for video games. Authors write in a portable generic format, Twine/Sugarcube (.twee) or Ink (.ink). Covers choice-IF, visual novels, and branching dialogue. Supports Tier 1 mechanic hooks (flag, counter, inventory, timer, trust, currency, npc_state, ending_condition), multi-ending design, series carry-over variable registry, and NPC-focused character architecture. | 22 templates, 36 commands, 2 scripts | — | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-writing) |
| iSAQB Architecture Governance | Adds general iSAQB/CPSA-F and arc42 architecture governance: goals, context, building blocks, runtime and deployment views, quality scenarios, ADRs, risks, and technical debt | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) | | iSAQB Architecture Governance | Adds general iSAQB/CPSA-F and arc42 architecture governance: goals, context, building blocks, runtime and deployment views, quality scenarios, ADRs, risks, and technical debt | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
| Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) | | Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) |

View File

@@ -11,6 +11,11 @@ Spec-Driven Development is a structured process that emphasizes:
- **Multi-step refinement** rather than one-shot code generation from prompts - **Multi-step refinement** rather than one-shot code generation from prompts
- **Heavy reliance** on advanced AI model capabilities for specification interpretation - **Heavy reliance** on advanced AI model capabilities for specification interpretation
Spec Kit does not prescribe how teams preserve or mutate `spec.md`, `plan.md`,
and `tasks.md` after requirements change. See
[Spec Persistence Models](spec-persistence.md) for three common ways to manage
those artifacts over time.
## Development Phases ## Development Phases
| Phase | Focus | Key Activities | | Phase | Focus | Key Activities |

View File

@@ -0,0 +1,107 @@
# Spec Persistence Models
Spec Kit intentionally leaves teams in control of what happens to `spec.md`,
`plan.md`, and `tasks.md` after requirements change. The toolkit gives you a
repeatable workflow, but it does not force one artifact maintenance strategy.
This page names three common models so teams can make that choice explicit.
None is the default, and none is required by Spec Kit.
## Two Separate Questions
Spec-driven development has a temporal question: how long should the
specification matter? One
[overview of SDD tooling](https://martinfowler.com/articles/exploring-gen-ai/sdd-3-tools.html)
frames that lifecycle in three levels:
- **Spec-first**: write a spec before coding, then allow it to be discarded.
- **Spec-anchored**: keep the spec after implementation and use it for future
changes.
- **Spec-as-source**: treat the spec as the only human-edited source and
regenerate implementation artifacts from it.
Spec Kit also exposes a second question: what happens to the artifact set when
requirements change? The models below describe that mutation strategy.
## Flow-Back Spec
Use flow-back when `spec.md`, `plan.md`, `tasks.md`, and the implementation are
all allowed to inform each other.
In this model, edits can begin in any artifact. A developer might update
`tasks.md` during implementation, revise `plan.md` after a technical discovery,
or adjust `spec.md` after a product clarification. The team then reconciles the
artifact set manually so the final project history still makes sense.
Flow-back works well when:
- the team is small enough to notice and reconcile drift quickly
- implementation discoveries are expected to reshape the original plan
- speed matters more than preserving each intermediate decision as immutable
history
The main risk is silent divergence. If the team changes lower-level artifacts
without reflecting the decision back into `spec.md`, future contributors may
not know which artifact to trust.
## Flow-Forward Spec
Use flow-forward when each feature directory should remain a historical record.
In this model, completed artifacts are treated as immutable. When requirements
change, the team creates a new feature directory instead of mutating the
existing `spec.md`, `plan.md`, or `tasks.md`. The older directory remains useful
for audit, comparison, or explaining how the project reached its current state.
Flow-forward works well when:
- auditability and traceability matter
- features are well-scoped and rarely revisited in place
- the team wants a clear sequence of requirement changes over time
The main tradeoff is duplication. Related decisions can be spread across
multiple feature directories, so teams need naming, linking, or review habits
that make the lineage easy to follow.
## Living Spec
Use living spec when `spec.md` is the contract and the other artifacts are
derived from it.
In this model, teams update `spec.md` first and then regenerate or revise
`plan.md` and `tasks.md` from that source. The plan and task list are still
valuable, but they are treated as disposable derivations rather than permanent
sources of truth.
Living spec works well when:
- the product contract is stable enough to own the workflow
- the team is comfortable regenerating derived artifacts after spec changes
- consistency between requirements and implementation matters more than keeping
every intermediate plan intact
The main risk is losing useful implementation rationale if derived artifacts are
discarded without preserving important decisions elsewhere.
## Choosing a Model
The model is a team convention, not a CLI setting. A project can even use
different models in different areas, as long as contributors know which one
applies.
| Model | Mutation rule | Best fit | Watch out for |
|---|---|---|---|
| Flow-back spec | Edit any artifact, then reconcile | Fast iteration and close collaboration | Silent drift between artifacts |
| Flow-forward spec | Create a new feature directory for new requirements | Audit trails and historical clarity | Duplicate or fragmented context |
| Living spec | Edit `spec.md`; regenerate derived artifacts | Spec as contract | Lost rationale in regenerated files |
If your team has not chosen a model yet, start by answering two questions:
1. Should completed feature directories be historical records or editable work
areas?
2. Is `spec.md` the single source of truth, or are `plan.md` and `tasks.md`
allowed to become co-equal sources?
Once those answers are clear, document the convention in your project
constitution or team onboarding notes so future contributors know how to handle
changes.

View File

@@ -6,7 +6,7 @@
- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Pi Coding Agent](https://pi.dev) - AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Pi Coding Agent](https://pi.dev)
- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pipx.pypa.io/) for persistent installation - [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pipx.pypa.io/) for persistent installation
- [Python 3.11+](https://www.python.org/downloads/) - [Python 3.11+](https://www.python.org/downloads/)
- [Git](https://git-scm.com/downloads) - [Git](https://git-scm.com/downloads) _(optional — required only when the git extension is enabled)_
## Installation ## Installation

View File

@@ -162,7 +162,7 @@ rm -rf .venv dist build *.egg-info
|---------|-----| |---------|-----|
| `ModuleNotFoundError: typer` | Run `uv pip install -e .` | | `ModuleNotFoundError: typer` | Run `uv pip install -e .` |
| Scripts not executable (Linux) | Re-run init or `chmod +x scripts/*.sh` | | Scripts not executable (Linux) | Re-run init or `chmod +x scripts/*.sh` |
| Git step skipped | You passed `--no-git` or Git not installed | | Git commands unavailable | Install the git extension with `specify extension add git` |
| Wrong script type downloaded | Pass `--script sh` or `--script ps` explicitly | | Wrong script type downloaded | Pass `--script sh` or `--script ps` explicitly |
| TLS errors on corporate network | Configure your environment's certificate store or proxy. The `--skip-tls` flag is deprecated and has no effect. | | TLS errors on corporate network | Configure your environment's certificate store or proxy. The `--skip-tls` flag is deprecated and has no effect. |

View File

@@ -15,16 +15,13 @@ specify init [<project_name>]
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) | | `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
| `--here` | Initialize in the current directory instead of creating a new one | | `--here` | Initialize in the current directory instead of creating a new one |
| `--force` | Force merge/overwrite when initializing in an existing directory | | `--force` | Force merge/overwrite when initializing in an existing directory |
| `--no-git` | Skip git repository initialization |
| `--ignore-agent-tools` | Skip checks for AI coding agent CLI tools | | `--ignore-agent-tools` | Skip checks for AI coding agent CLI tools |
| `--preset <id>` | Install a preset during initialization | | `--preset <id>` | Install a preset during initialization |
| `--branch-numbering` | Branch numbering strategy: `sequential` (default) or `timestamp` |
Creates a new Spec Kit project with the necessary directory structure, templates, scripts, and AI coding agent integration files. Creates a new Spec Kit project with the necessary directory structure, templates, scripts, and AI coding agent integration files.
> [!NOTE] > [!NOTE]
> The git extension is currently enabled by default during `specify init`. > Git repository initialization and branching are managed by the **git extension**, which is not installed by default. Run `specify extension add git` after init to enable git workflows.
> Starting in `v0.10.0`, it will require explicit opt-in. To add it after init, run `specify extension add git`.
Use `<project_name>` to create a new directory, or `--here` (or `.`) to initialize in the current directory. If the directory already has files, use `--force` to merge without confirmation. Use `<project_name>` to create a new directory, or `--here` (or `.`) to initialize in the current directory. If the directory already has files, use `--force` to merge without confirmation.
@@ -45,14 +42,8 @@ specify init --here --force --integration copilot
# Use PowerShell scripts (Windows/cross-platform) # Use PowerShell scripts (Windows/cross-platform)
specify init my-project --integration copilot --script ps specify init my-project --integration copilot --script ps
# Skip git initialization
specify init my-project --integration copilot --no-git
# Install a preset during initialization # Install a preset during initialization
specify init my-project --integration copilot --preset compliance specify init my-project --integration copilot --preset compliance
# Use timestamp-based branch numbering (useful for distributed teams)
specify init my-project --integration copilot --branch-numbering timestamp
``` ```
### Environment Variables ### Environment Variables
@@ -67,7 +58,7 @@ specify init my-project --integration copilot --branch-numbering timestamp
specify check specify check
``` ```
Checks that required tools are available on your system: `git` and any CLI-based AI coding agents. IDE-based agents are skipped since they don't require a CLI tool. Checks that CLI-based AI coding agents are available on your system. IDE-based agents are skipped since they don't require a CLI tool.
This command stays offline. If a command behaves like an older Spec Kit version or an expected CLI feature is missing, run `specify self check` to check whether your local CLI is behind the latest release. This command stays offline. If a command behaves like an older Spec Kit version or an expected CLI feature is missing, run `specify self check` to check whether your local CLI is behind the latest release.

View File

@@ -126,6 +126,27 @@ specify integration upgrade [<key>]
Reinstalls an installed integration with updated templates and commands (e.g., after upgrading Spec Kit). Defaults to the default integration; if a key is provided, it must be one of the installed integrations. Detects locally modified files and blocks the upgrade unless `--force` is used. Stale files from the previous install that are no longer needed are removed automatically. Shared templates stay aligned with the default integration even when upgrading a non-default integration. Reinstalls an installed integration with updated templates and commands (e.g., after upgrading Spec Kit). Defaults to the default integration; if a key is provided, it must be one of the installed integrations. Detects locally modified files and blocks the upgrade unless `--force` is used. Stale files from the previous install that are no longer needed are removed automatically. Shared templates stay aligned with the default integration even when upgrading a non-default integration.
## Report Integration Status
```bash
specify integration status
specify integration status --json
```
Reports the current project's integration status without changing files. The
status report includes the default integration, installed integrations,
multi-install safety, missing managed files, modified managed files, invalid
manifest paths, shared Spec Kit infrastructure health, unchecked manifests, and
the target integration for default-sensitive shared templates. The JSON form is
intended for CI and coding agents that need stable machine-readable status data;
it also reports the raw recorded integrations and the integration manifests that
were checked when state repair heuristics differ from the recorded file.
The command exits 0 when the report status is `ok` or `warning`; it exits 1
only when the report status is `error`. In JSON output, `multi_install_safe`
is `null` when no installed integration set can be evaluated, such as when the
integration state is missing, unreadable, lacks a valid recorded integration
list, or records no installed integrations.
## Integration-Specific Options ## Integration-Specific Options
Some integrations accept additional options via `--integration-options`: Some integrations accept additional options via `--integration-options`:

View File

@@ -41,6 +41,8 @@
items: items:
- name: What is SDD? - name: What is SDD?
href: concepts/sdd.md href: concepts/sdd.md
- name: Spec Persistence Models
href: concepts/spec-persistence.md
# Development workflows # Development workflows
- name: Development - name: Development

View File

@@ -257,70 +257,38 @@ rm speckit.old-command-name.md
# Restart your IDE # Restart your IDE
``` ```
### Scenario 4: "I'm working on a project without Git" ### Scenario 4: "I don't want the git extension"
If you initialized your project with `--no-git`, you can still upgrade: The git extension is now opt-in, so upgrades do not install it unless you add it explicitly.
```bash ```bash
# Manually back up files you customized # Manually back up files you customized
cp .specify/memory/constitution.md /tmp/constitution-backup.md cp .specify/memory/constitution.md .specify/memory/constitution.backup.md
# Run upgrade # Run upgrade
specify init --here --force --integration copilot --no-git specify init --here --force --integration copilot
# Restore customizations # Restore customizations
mv /tmp/constitution-backup.md .specify/memory/constitution.md mv .specify/memory/constitution.backup.md .specify/memory/constitution.md
``` ```
The `--no-git` flag skips git initialization but doesn't affect file updates. If you later decide you want the git extension's commands and hooks, install it explicitly:
---
## Using `--no-git` Flag
The `--no-git` flag tells Spec Kit to **skip git repository initialization**. This is useful when:
- You manage version control differently (Mercurial, SVN, etc.)
- Your project is part of a larger monorepo with existing git setup
- You're experimenting and don't want version control yet
**During initial setup:**
```bash ```bash
specify init my-project --integration copilot --no-git specify extension add git
``` ```
**During upgrade:** Projects that do not use Git can still work with Spec Kit by setting `SPECIFY_FEATURE_DIRECTORY` to the feature directory path before planning commands:
```bash
specify init --here --force --integration copilot --no-git
```
### What `--no-git` does NOT do
❌ Does NOT prevent file updates
❌ Does NOT skip slash command installation
❌ Does NOT affect template merging
It **only** skips running `git init` and creating the initial commit.
### Working without Git
If you use `--no-git`, you'll need to manage feature directories manually:
**Set the `SPECIFY_FEATURE` environment variable** before using planning commands:
```bash ```bash
# Bash/Zsh # Bash/Zsh
export SPECIFY_FEATURE="001-my-feature" export SPECIFY_FEATURE_DIRECTORY="specs/001-my-feature"
# PowerShell # PowerShell
$env:SPECIFY_FEATURE = "001-my-feature" $env:SPECIFY_FEATURE_DIRECTORY = "specs/001-my-feature"
``` ```
This tells Spec Kit which feature directory to use when creating specs, plans, and tasks. Alternatively, run the `/speckit.specify` command which creates `.specify/feature.json` automatically.
**Why this matters:** Without git, Spec Kit can't detect your current branch name to determine the active feature. The environment variable provides that context manually.
--- ---

File diff suppressed because it is too large Load Diff

View File

@@ -94,7 +94,7 @@ When Git is not installed or the directory is not a Git repository:
The extension bundles cross-platform scripts: The extension bundles cross-platform scripts:
- `scripts/bash/create-new-feature.sh` — Bash implementation - `scripts/bash/create-new-feature-branch.sh` — Bash implementation (branch creation only)
- `scripts/bash/git-common.sh` — Shared Git utilities (Bash) - `scripts/bash/git-common.sh` — Shared Git utilities (Bash)
- `scripts/powershell/create-new-feature.ps1` — PowerShell implementation - `scripts/powershell/create-new-feature-branch.ps1` — PowerShell implementation (branch creation only)
- `scripts/powershell/git-common.ps1` — Shared Git utilities (PowerShell) - `scripts/powershell/git-common.ps1` — Shared Git utilities (PowerShell)

View File

@@ -31,8 +31,9 @@ If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variabl
Determine the branch numbering strategy by checking configuration in this order: Determine the branch numbering strategy by checking configuration in this order:
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value 1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value
2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility) 2. Check `.specify/init-options.json` for `feature_numbering` value (inherit from core)
3. Default to `sequential` if neither exists 3. Check `.specify/init-options.json` for `branch_numbering` value (deprecated, backward compatibility — will be removed in a future release)
4. Default to `sequential` if none of the above exist
## Execution ## Execution
@@ -43,10 +44,10 @@ Generate a concise short name (2-4 words) for the branch:
Run the appropriate script based on your platform: Run the appropriate script based on your platform:
- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "<short-name>" "<feature description>"` - **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature-branch.sh --json --short-name "<short-name>" "<feature description>"`
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "<short-name>" "<feature description>"` - **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature-branch.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "<short-name>" "<feature description>"` - **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature-branch.ps1 -Json -ShortName "<short-name>" "<feature description>"`
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"` - **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature-branch.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
**IMPORTANT**: **IMPORTANT**:
- Do NOT pass `--number` — the script determines the correct next number automatically - Do NOT pass `--number` — the script determines the correct next number automatically

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Git extension: create-new-feature.sh # Git extension: create-new-feature-branch.sh
# Adapted from core scripts/bash/create-new-feature.sh for extension layout. # Creates a git feature branch only. The feature directory and spec file
# are created by the core create-new-feature.sh script.
# Sources common.sh from the project's installed scripts, falling back to # Sources common.sh from the project's installed scripts, falling back to
# git-common.sh for minimal git helpers. # git-common.sh for minimal git helpers.

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env pwsh #!/usr/bin/env pwsh
# Git extension: create-new-feature.ps1 # Git extension: create-new-feature-branch.ps1
# Adapted from core scripts/powershell/create-new-feature.ps1 for extension layout. # Creates a git feature branch only. The feature directory and spec file
# are created by the core create-new-feature.ps1 script.
# Sources common.ps1 from the project's installed scripts, falling back to # Sources common.ps1 from the project's installed scripts, falling back to
# git-common.ps1 for minimal git helpers. # git-common.ps1 for minimal git helpers.
[CmdletBinding()] [CmdletBinding()]
@@ -19,7 +20,7 @@ param(
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
if ($Help) { if ($Help) {
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>" Write-Host "Usage: ./create-new-feature-branch.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
Write-Host "" Write-Host ""
Write-Host "Options:" Write-Host "Options:"
Write-Host " -Json Output in JSON format" Write-Host " -Json Output in JSON format"
@@ -37,7 +38,7 @@ if ($Help) {
} }
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) { if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>" Write-Error "Usage: ./create-new-feature-branch.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
exit 1 exit 1
} }

View File

@@ -13,6 +13,14 @@ extension:
# CUSTOMIZE: Brief description (under 200 characters) # CUSTOMIZE: Brief description (under 200 characters)
description: "Brief description of what your extension does" description: "Brief description of what your extension does"
# CUSTOMIZE: Extension category — describes what the extension operates on
# Common values: docs, code, process, integration, visibility
# category: "process"
# CUSTOMIZE: Extension effect — whether it modifies project files
# One of: read-only | read-write
# effect: "read-write"
# CUSTOMIZE: Your name or organization name # CUSTOMIZE: Your name or organization name
author: "Your Name" author: "Your Name"

View File

@@ -1,16 +1,16 @@
{ {
"schema_version": "1.0", "schema_version": "1.0",
"updated_at": "2026-06-03T00:00:00Z", "updated_at": "2026-06-05T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
"presets": { "presets": {
"a11y-governance": { "a11y-governance": {
"name": "A11Y Governance", "name": "A11Y Governance",
"id": "a11y-governance", "id": "a11y-governance",
"version": "0.2.0", "version": "0.3.0",
"description": "Adds accessibility, bilingual DE/EN delivery, CEFR-B2 readability, and inclusive-content governance to Spec Kit.", "description": "Adds accessibility, bilingual DE/EN delivery, CEFR-B2 readability, inclusive-content governance, and didactic inline-code-comment review to Spec Kit.",
"author": "Thorsten Hindermann", "author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-a11y-governance", "repository": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.2.0.zip", "download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.3.0.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-a11y-governance", "homepage": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/main/README.md", "documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/main/README.md",
"license": "MIT", "license": "MIT",
@@ -18,7 +18,7 @@
"speckit_version": ">=0.8.0" "speckit_version": ">=0.8.0"
}, },
"provides": { "provides": {
"templates": 9, "templates": 10,
"commands": 3 "commands": 3
}, },
"tags": [ "tags": [
@@ -29,7 +29,7 @@
"inclusion" "inclusion"
], ],
"created_at": "2026-04-27T00:00:00Z", "created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-04-27T00:00:00Z" "updated_at": "2026-06-05T00:00:00Z"
}, },
"agent-parity-governance": { "agent-parity-governance": {
"name": "Agent Parity Governance", "name": "Agent Parity Governance",
@@ -224,11 +224,11 @@
"fiction-book-writing": { "fiction-book-writing": {
"name": "Fiction Book Writing", "name": "Fiction Book Writing",
"id": "fiction-book-writing", "id": "fiction-book-writing",
"version": "1.8.1", "version": "1.9.0",
"description": "Spec-Driven Development for novel and long-form fiction. 33 AI commands from idea to submission: story bible governance, 9 POV modes, all major plot structure frameworks, scene-by-scene drafting with quality gates, audiobook pipeline (SSML/ElevenLabs), cover design, sensitivity review, pacing and prose statistics, and pandoc-based export to DOCX/EPUB/LaTeX. Two style modes: author voice sample extraction or humanized-AI prose with 5 craft profiles. 12 languages supported. Support for offline semantic search.", "description": "Spec-Driven Development for novel and long-form fiction. 34 AI commands from idea to submission: story bible governance, 9 POV modes, all major plot structure frameworks, scene-by-scene drafting with quality gates, audiobook pipeline (SSML/ElevenLabs), cover design, illustrations, sensitivity review, pacing and prose statistics, and pandoc-based export to DOCX/EPUB/LaTeX. Two style modes: author voice sample extraction or humanized-AI prose with 5 craft profiles. 12 languages supported. Support for offline semantic search.",
"author": "Andreas Daumann", "author": "Andreas Daumann",
"repository": "https://github.com/adaumann/speckit-preset-fiction-book-writing", "repository": "https://github.com/adaumann/speckit-preset-fiction-book-writing",
"download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.8.1.zip", "download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.9.0.zip",
"homepage": "https://github.com/adaumann/speckit-preset-fiction-book-writing", "homepage": "https://github.com/adaumann/speckit-preset-fiction-book-writing",
"documentation": "https://github.com/adaumann/speckit-preset-fiction-book-writing/blob/main/fiction-book-writing/README.md", "documentation": "https://github.com/adaumann/speckit-preset-fiction-book-writing/blob/main/fiction-book-writing/README.md",
"license": "MIT", "license": "MIT",
@@ -236,8 +236,8 @@
"speckit_version": ">=0.5.0" "speckit_version": ">=0.5.0"
}, },
"provides": { "provides": {
"templates": 25, "templates": 26,
"commands": 33, "commands": 34,
"scripts": 2 "scripts": 2
}, },
"tags": [ "tags": [
@@ -256,7 +256,7 @@
"language-support" "language-support"
], ],
"created_at": "2026-04-09T08:00:00Z", "created_at": "2026-04-09T08:00:00Z",
"updated_at": "2026-05-24T08:00:00Z" "updated_at": "2026-06-02T08:00:00Z"
}, },
"game-narrative-writing": { "game-narrative-writing": {
"name": "Game Narrative Writing", "name": "Game Narrative Writing",

View File

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

View File

@@ -111,9 +111,6 @@ if $PATHS_ONLY; then
exit 0 exit 0
fi fi
# Validate branch name
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
# Validate required directories and files # Validate required directories and files
if [[ ! -d "$FEATURE_DIR" ]]; then if [[ ! -d "$FEATURE_DIR" ]]; then
echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2 echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2

View File

@@ -24,8 +24,8 @@ find_specify_root() {
return 1 return 1
} }
# Get repository root, prioritizing .specify directory over git # Get repository root, prioritizing .specify directory
# This prevents using a parent git repo when spec-kit is initialized in a subdirectory # This prevents using a parent repository when spec-kit is initialized in a subdirectory
get_repo_root() { get_repo_root() {
# First, look for .specify directory (spec-kit's own marker) # First, look for .specify directory (spec-kit's own marker)
local specify_root local specify_root
@@ -34,123 +34,24 @@ get_repo_root() {
return return
fi fi
# Fallback to git if no .specify found # Final fallback to script location
if git rev-parse --show-toplevel >/dev/null 2>&1; then
git rev-parse --show-toplevel
return
fi
# Final fallback to script location for non-git repos
local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
(cd "$script_dir/../../.." && pwd) (cd "$script_dir/../../.." && pwd)
} }
# Get current branch, with fallback for non-git repositories # Get current feature name from explicit state only.
# Returns the feature identifier or empty string if none is set.
# Feature state is set by SPECIFY_FEATURE (from create-new-feature or
# the git extension) or implicitly via .specify/feature.json.
get_current_branch() { get_current_branch() {
# First check if SPECIFY_FEATURE environment variable is set
if [[ -n "${SPECIFY_FEATURE:-}" ]]; then if [[ -n "${SPECIFY_FEATURE:-}" ]]; then
echo "$SPECIFY_FEATURE" echo "$SPECIFY_FEATURE"
return return
fi fi
# Then check git if available at the spec-kit root (not parent) # No explicit feature set — caller must handle this via feature.json
local repo_root=$(get_repo_root) # in get_feature_paths(). Return empty to signal "unknown".
if has_git; then echo ""
git -C "$repo_root" rev-parse --abbrev-ref HEAD
return
fi
# For non-git repos, try to find the latest feature directory
local specs_dir="$repo_root/specs"
if [[ -d "$specs_dir" ]]; then
local latest_feature=""
local highest=0
local latest_timestamp=""
for dir in "$specs_dir"/*; do
if [[ -d "$dir" ]]; then
local dirname=$(basename "$dir")
if [[ "$dirname" =~ ^([0-9]{8}-[0-9]{6})- ]]; then
# Timestamp-based branch: compare lexicographically
local ts="${BASH_REMATCH[1]}"
if [[ "$ts" > "$latest_timestamp" ]]; then
latest_timestamp="$ts"
latest_feature=$dirname
fi
elif [[ "$dirname" =~ ^([0-9]{3,})- ]]; then
local number=${BASH_REMATCH[1]}
number=$((10#$number))
if [[ "$number" -gt "$highest" ]]; then
highest=$number
# Only update if no timestamp branch found yet
if [[ -z "$latest_timestamp" ]]; then
latest_feature=$dirname
fi
fi
fi
fi
done
if [[ -n "$latest_feature" ]]; then
echo "$latest_feature"
return
fi
fi
echo "main" # Final fallback
}
# Check if we have git available at the spec-kit root level
# Returns true only if git is installed and the repo root is inside a git work tree
# Handles both regular repos (.git directory) and worktrees/submodules (.git file)
has_git() {
# First check if git command is available (before calling get_repo_root which may use git)
command -v git >/dev/null 2>&1 || return 1
local repo_root=$(get_repo_root)
# Check if .git exists (directory or file for worktrees/submodules)
[ -e "$repo_root/.git" ] || return 1
# Verify it's actually a valid git work tree
git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1
}
# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
spec_kit_effective_branch_name() {
local raw="$1"
if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then
printf '%s\n' "${BASH_REMATCH[2]}"
else
printf '%s\n' "$raw"
fi
}
check_feature_branch() {
local raw="$1"
local has_git_repo="$2"
# For non-git repos, we can't enforce branch naming but still provide output
if [[ "$has_git_repo" != "true" ]]; then
echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2
return 0
fi
local branch
branch=$(spec_kit_effective_branch_name "$raw")
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
local is_sequential=false
if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
is_sequential=true
fi
if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
echo "ERROR: Not on a feature branch. Current branch: $raw" >&2
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2
return 1
fi
return 0
} }
# Safely read .specify/feature.json's "feature_directory" value. # Safely read .specify/feature.json's "feature_directory" value.
@@ -185,105 +86,66 @@ read_feature_json_feature_directory() {
return 0 return 0
} }
# Returns 0 when .specify/feature.json lists feature_directory that exists as a directory # Persist a feature_directory value to .specify/feature.json.
# and matches the resolved active FEATURE_DIR (so __SPECKIT_COMMAND_PLAN__ can skip git branch pattern checks). # Writes only when the file is missing or the value differs from what's stored.
# Delegates parsing to read_feature_json_feature_directory, which is safe under `set -e`. # Accepts the raw (possibly relative) path — callers should pass the original
feature_json_matches_feature_dir() { # user-supplied value, not the normalized absolute path.
_persist_feature_json() {
local repo_root="$1" local repo_root="$1"
local active_feature_dir="$2" local feature_dir_value="$2"
local fj="$repo_root/.specify/feature.json"
local _fd # Strip repo_root prefix if the value is absolute and under repo_root
_fd=$(read_feature_json_feature_directory "$repo_root") if [[ "$feature_dir_value" == "$repo_root/"* ]]; then
feature_dir_value="${feature_dir_value#"$repo_root/"}"
[[ -n "$_fd" ]] || return 1
[[ "$_fd" != /* ]] && _fd="$repo_root/$_fd"
[[ -d "$_fd" ]] || return 1
local norm_json norm_active
norm_json="$(cd -- "$_fd" 2>/dev/null && pwd -P)" || return 1
norm_active="$(cd -- "$active_feature_dir" 2>/dev/null && pwd -P)" || return 1
[[ "$norm_json" == "$norm_active" ]]
}
# Find feature directory by numeric prefix instead of exact branch match
# This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature)
find_feature_dir_by_prefix() {
local repo_root="$1"
local branch_name
branch_name=$(spec_kit_effective_branch_name "$2")
local specs_dir="$repo_root/specs"
# Extract prefix from branch (e.g., "004" from "004-whatever" or "20260319-143022" from timestamp branches)
local prefix=""
if [[ "$branch_name" =~ ^([0-9]{8}-[0-9]{6})- ]]; then
prefix="${BASH_REMATCH[1]}"
elif [[ "$branch_name" =~ ^([0-9]{3,})- ]]; then
prefix="${BASH_REMATCH[1]}"
else
# If branch doesn't have a recognized prefix, fall back to exact match
echo "$specs_dir/$branch_name"
return
fi fi
# Search for directories in specs/ that start with this prefix # Read current value (if any) and skip write when unchanged
local matches=() local current_val
if [[ -d "$specs_dir" ]]; then current_val=$(read_feature_json_feature_directory "$repo_root")
for dir in "$specs_dir"/"$prefix"-*; do if [[ "$current_val" == "$feature_dir_value" ]]; then
if [[ -d "$dir" ]]; then return 0
matches+=("$(basename "$dir")")
fi
done
fi fi
# Handle results # Ensure .specify/ directory exists
if [[ ${#matches[@]} -eq 0 ]]; then mkdir -p "$repo_root/.specify"
# No match found - return the branch name path (will fail later with clear error)
echo "$specs_dir/$branch_name" # Write feature.json — prefer jq for safe JSON, fall back to printf
elif [[ ${#matches[@]} -eq 1 ]]; then if command -v jq >/dev/null 2>&1; then
# Exactly one match - perfect! jq -cn --arg fd "$feature_dir_value" '{feature_directory:$fd}' > "$fj"
echo "$specs_dir/${matches[0]}"
else else
# Multiple matches - this shouldn't happen with proper naming convention printf '{"feature_directory":"%s"}\n' "$(json_escape "$feature_dir_value")" > "$fj"
echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&2
echo "Please ensure only one spec directory exists per prefix." >&2
return 1
fi fi
} }
get_feature_paths() { get_feature_paths() {
local repo_root=$(get_repo_root) local repo_root=$(get_repo_root)
local current_branch=$(get_current_branch) local current_branch=$(get_current_branch)
local has_git_repo="false"
if has_git; then
has_git_repo="true"
fi
# Resolve feature directory. Priority: # Resolve feature directory. Priority:
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override) # 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
# 2. .specify/feature.json "feature_directory" key (persisted by __SPECKIT_COMMAND_SPECIFY__) # 2. .specify/feature.json "feature_directory" key (persisted by specify command)
# 3. Branch-name-based prefix lookup (legacy fallback) # 3. Error — no feature context available
local feature_dir local feature_dir
if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then
feature_dir="$SPECIFY_FEATURE_DIRECTORY" feature_dir="$SPECIFY_FEATURE_DIRECTORY"
# Normalize relative paths to absolute under repo root # Normalize relative paths to absolute under repo root
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir" [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
# Persist to feature.json so future sessions without the env var still work
_persist_feature_json "$repo_root" "$SPECIFY_FEATURE_DIRECTORY"
elif [[ -f "$repo_root/.specify/feature.json" ]]; then elif [[ -f "$repo_root/.specify/feature.json" ]]; then
# Shared, set -e-safe parser: jq -> python3 -> grep/sed. Returns empty on
# missing/unparseable/unset so we fall through to the branch-prefix lookup.
local _fd local _fd
_fd=$(read_feature_json_feature_directory "$repo_root") _fd=$(read_feature_json_feature_directory "$repo_root")
if [[ -n "$_fd" ]]; then if [[ -n "$_fd" ]]; then
feature_dir="$_fd" feature_dir="$_fd"
# Normalize relative paths to absolute under repo root # Normalize relative paths to absolute under repo root
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir" [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then else
echo "ERROR: Failed to resolve feature directory" >&2 echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory." >&2
return 1 return 1
fi fi
elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then else
echo "ERROR: Failed to resolve feature directory" >&2 echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json." >&2
return 1 return 1
fi fi
@@ -291,7 +153,6 @@ get_feature_paths() {
# via crafted branch names or paths containing special characters # via crafted branch names or paths containing special characters
printf 'REPO_ROOT=%q\n' "$repo_root" printf 'REPO_ROOT=%q\n' "$repo_root"
printf 'CURRENT_BRANCH=%q\n' "$current_branch" printf 'CURRENT_BRANCH=%q\n' "$current_branch"
printf 'HAS_GIT=%q\n' "$has_git_repo"
printf 'FEATURE_DIR=%q\n' "$feature_dir" printf 'FEATURE_DIR=%q\n' "$feature_dir"
printf 'FEATURE_SPEC=%q\n' "$feature_dir/spec.md" printf 'FEATURE_SPEC=%q\n' "$feature_dir/spec.md"
printf 'IMPL_PLAN=%q\n' "$feature_dir/plan.md" printf 'IMPL_PLAN=%q\n' "$feature_dir/plan.md"

View File

@@ -57,9 +57,9 @@ while [ $i -le $# ]; do
echo "" echo ""
echo "Options:" echo "Options:"
echo " --json Output in JSON format" echo " --json Output in JSON format"
echo " --dry-run Compute branch name and paths without creating branches, directories, or files" echo " --dry-run Compute feature name and paths without creating directories or files"
echo " --allow-existing-branch Switch to branch if it already exists instead of failing" echo " --allow-existing-branch Reuse an existing feature directory if it already exists"
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch" echo " --short-name <name> Provide a custom short name (2-4 words) for the feature"
echo " --number N Specify branch number manually (overrides auto-detection)" echo " --number N Specify branch number manually (overrides auto-detection)"
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
echo " --help, -h Show this help message" echo " --help, -h Show this help message"
@@ -113,94 +113,18 @@ get_highest_from_specs() {
echo "$highest" echo "$highest"
} }
# Function to get highest number from git branches
get_highest_from_branches() {
git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number
}
# Extract the highest sequential feature number from a list of ref names (one per line).
# Shared by get_highest_from_branches and get_highest_from_remote_refs.
_extract_highest_number() {
local highest=0
while IFS= read -r name; do
[ -z "$name" ] && continue
if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0")
number=$((10#$number))
if [ "$number" -gt "$highest" ]; then
highest=$number
fi
fi
done
echo "$highest"
}
# Function to get highest number from remote branches without fetching (side-effect-free)
get_highest_from_remote_refs() {
local highest=0
for remote in $(git remote 2>/dev/null); do
local remote_highest
remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number)
if [ "$remote_highest" -gt "$highest" ]; then
highest=$remote_highest
fi
done
echo "$highest"
}
# Function to check existing branches (local and remote) and return next available number.
# When skip_fetch is true, queries remotes via ls-remote (read-only) instead of fetching.
check_existing_branches() {
local specs_dir="$1"
local skip_fetch="${2:-false}"
if [ "$skip_fetch" = true ]; then
# Side-effect-free: query remotes via ls-remote
local highest_remote=$(get_highest_from_remote_refs)
local highest_branch=$(get_highest_from_branches)
if [ "$highest_remote" -gt "$highest_branch" ]; then
highest_branch=$highest_remote
fi
else
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
git fetch --all --prune >/dev/null 2>&1 || true
local highest_branch=$(get_highest_from_branches)
fi
# Get highest number from ALL specs (not just matching short name)
local highest_spec=$(get_highest_from_specs "$specs_dir")
# Take the maximum of both
local max_num=$highest_branch
if [ "$highest_spec" -gt "$max_num" ]; then
max_num=$highest_spec
fi
# Return next number
echo $((max_num + 1))
}
# Function to clean and format a branch name # Function to clean and format a branch name
clean_branch_name() { clean_branch_name() {
local name="$1" local name="$1"
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
} }
# Resolve repository root using common.sh functions which prioritize .specify over git # Resolve repository root using common.sh functions which prioritize .specify
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh" source "$SCRIPT_DIR/common.sh"
REPO_ROOT=$(get_repo_root) REPO_ROOT=$(get_repo_root)
# Check if git is available at this repo root (not a parent)
if has_git; then
HAS_GIT=true
else
HAS_GIT=false
fi
cd "$REPO_ROOT" cd "$REPO_ROOT"
SPECS_DIR="$REPO_ROOT/specs" SPECS_DIR="$REPO_ROOT/specs"
@@ -276,23 +200,10 @@ if [ "$USE_TIMESTAMP" = true ]; then
FEATURE_NUM=$(date +%Y%m%d-%H%M%S) FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
else else
# Determine branch number # Determine branch number from existing feature directories
if [ -z "$BRANCH_NUMBER" ]; then if [ -z "$BRANCH_NUMBER" ]; then
if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
# Dry-run: query remotes via ls-remote (side-effect-free, no fetch) BRANCH_NUMBER=$((HIGHEST + 1))
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
elif [ "$DRY_RUN" = true ]; then
# Dry-run without git: local spec dirs only
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$((HIGHEST + 1))
elif [ "$HAS_GIT" = true ]; then
# Check existing branches on remotes
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
else
# Fall back to local directory check
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$((HIGHEST + 1))
fi
fi fi
# Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal) # Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal)
@@ -326,43 +237,13 @@ FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
SPEC_FILE="$FEATURE_DIR/spec.md" SPEC_FILE="$FEATURE_DIR/spec.md"
if [ "$DRY_RUN" != true ]; then if [ "$DRY_RUN" != true ]; then
if [ "$HAS_GIT" = true ]; then if [ -d "$FEATURE_DIR" ] && [ "$ALLOW_EXISTING" != true ]; then
branch_create_error="" if [ "$USE_TIMESTAMP" = true ]; then
if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then >&2 echo "Error: Feature directory '$FEATURE_DIR' already exists. Rerun to get a new timestamp or use a different --short-name."
current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" else
# Check if branch already exists >&2 echo "Error: Feature directory '$FEATURE_DIR' already exists. Please use a different feature name or specify a different number with --number."
if git branch --list "$BRANCH_NAME" | grep -q .; then
if [ "$ALLOW_EXISTING" = true ]; then
# If we're already on the branch, continue without another checkout.
if [ "$current_branch" = "$BRANCH_NAME" ]; then
:
# Otherwise switch to the existing branch instead of failing.
elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then
>&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again."
if [ -n "$switch_branch_error" ]; then
>&2 printf '%s\n' "$switch_branch_error"
fi
exit 1
fi
elif [ "$USE_TIMESTAMP" = true ]; then
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
exit 1
else
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
exit 1
fi
else
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'."
if [ -n "$branch_create_error" ]; then
>&2 printf '%s\n' "$branch_create_error"
else
>&2 echo "Please check your git configuration and try again."
fi
exit 1
fi
fi fi
else exit 1
>&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
fi fi
mkdir -p "$FEATURE_DIR" mkdir -p "$FEATURE_DIR"
@@ -377,8 +258,12 @@ if [ "$DRY_RUN" != true ]; then
fi fi
fi fi
# Inform the user how to persist the feature variable in their own shell # Persist to .specify/feature.json so downstream commands can find the feature
_persist_feature_json "$REPO_ROOT" "$FEATURE_DIR"
# Inform the user how to set feature state in their own shell
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2 printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
printf '# export SPECIFY_FEATURE_DIRECTORY=%q\n' "$FEATURE_DIR" >&2
fi fi
if $JSON_MODE; then if $JSON_MODE; then
@@ -409,5 +294,6 @@ else
echo "FEATURE_NUM: $FEATURE_NUM" echo "FEATURE_NUM: $FEATURE_NUM"
if [ "$DRY_RUN" != true ]; then if [ "$DRY_RUN" != true ]; then
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
printf '# export SPECIFY_FEATURE_DIRECTORY=%q\n' "$FEATURE_DIR"
fi fi
fi fi

View File

@@ -32,11 +32,6 @@ _paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature p
eval "$_paths_output" eval "$_paths_output"
unset _paths_output unset _paths_output
# If feature.json pins an existing feature directory, branch naming is not required.
if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
fi
# Ensure the feature directory exists # Ensure the feature directory exists
mkdir -p "$FEATURE_DIR" mkdir -p "$FEATURE_DIR"
@@ -75,17 +70,15 @@ if $JSON_MODE; then
--arg impl_plan "$IMPL_PLAN" \ --arg impl_plan "$IMPL_PLAN" \
--arg specs_dir "$FEATURE_DIR" \ --arg specs_dir "$FEATURE_DIR" \
--arg branch "$CURRENT_BRANCH" \ --arg branch "$CURRENT_BRANCH" \
--arg has_git "$HAS_GIT" \ '{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch}'
'{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch,HAS_GIT:$has_git}'
else else
printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s","HAS_GIT":"%s"}\n' \ printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s"}\n' \
"$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$HAS_GIT")" "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")"
fi fi
else else
echo "FEATURE_SPEC: $FEATURE_SPEC" echo "FEATURE_SPEC: $FEATURE_SPEC"
echo "IMPL_PLAN: $IMPL_PLAN" echo "IMPL_PLAN: $IMPL_PLAN"
echo "SPECS_DIR: $FEATURE_DIR" echo "SPECS_DIR: $FEATURE_DIR"
echo "BRANCH: $CURRENT_BRANCH" echo "BRANCH: $CURRENT_BRANCH"
echo "HAS_GIT: $HAS_GIT"
fi fi

View File

@@ -27,12 +27,7 @@ _paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature p
eval "$_paths_output" eval "$_paths_output"
unset _paths_output unset _paths_output
# Validate branch # Validate required files
# If feature.json pins an existing feature directory, branch naming is not required.
if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
fi
if [[ ! -f "$IMPL_PLAN" ]]; then if [[ ! -f "$IMPL_PLAN" ]]; then
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2 echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
echo "Run $(format_speckit_command plan "$REPO_ROOT") first to create the implementation plan." >&2 echo "Run $(format_speckit_command plan "$REPO_ROOT") first to create the implementation plan." >&2

View File

@@ -81,11 +81,6 @@ if ($PathsOnly) {
exit 0 exit 0
} }
# Validate branch name
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit:$paths.HAS_GIT)) {
exit 1
}
# Validate required directories and files # Validate required directories and files
if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) { if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) {
Write-Output "ERROR: Feature directory not found: $($paths.FEATURE_DIR)" Write-Output "ERROR: Feature directory not found: $($paths.FEATURE_DIR)"

View File

@@ -24,8 +24,8 @@ function Find-SpecifyRoot {
} }
} }
# Get repository root, prioritizing .specify directory over git # Get repository root, prioritizing .specify directory
# This prevents using a parent git repo when spec-kit is initialized in a subdirectory # This prevents using a parent repository when spec-kit is initialized in a subdirectory
function Get-RepoRoot { function Get-RepoRoot {
# First, look for .specify directory (spec-kit's own marker) # First, look for .specify directory (spec-kit's own marker)
$specifyRoot = Find-SpecifyRoot $specifyRoot = Find-SpecifyRoot
@@ -33,263 +33,81 @@ function Get-RepoRoot {
return $specifyRoot return $specifyRoot
} }
# Fallback to git if no .specify found # Final fallback to script location
try {
$result = git rev-parse --show-toplevel 2>$null
if ($LASTEXITCODE -eq 0) {
return $result
}
} catch {
# Git command failed
}
# Final fallback to script location for non-git repos
# Use -LiteralPath to handle paths with wildcard characters # Use -LiteralPath to handle paths with wildcard characters
return (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "../../..")).Path return (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "../../..")).Path
} }
function Get-CurrentBranch { function Get-CurrentBranch {
# First check if SPECIFY_FEATURE environment variable is set # Return feature name from explicit state only.
# Feature state is set by SPECIFY_FEATURE (from create-new-feature or
# the git extension) or implicitly via .specify/feature.json.
if ($env:SPECIFY_FEATURE) { if ($env:SPECIFY_FEATURE) {
return $env:SPECIFY_FEATURE return $env:SPECIFY_FEATURE
} }
# Then check git if available at the spec-kit root (not parent) # No explicit feature set - return empty to signal "unknown".
$repoRoot = Get-RepoRoot return ""
if (Test-HasGit) { }
# Persist a feature_directory value to .specify/feature.json.
# Writes only when the file is missing or the value differs from what's stored.
function Save-FeatureJson {
param(
[Parameter(Mandatory = $true)][string]$RepoRoot,
[Parameter(Mandatory = $true)][string]$FeatureDirectory
)
# Strip repo root prefix if the value is absolute and under repo root.
# Use case-insensitive comparison on Windows only (case-sensitive filesystems elsewhere).
$prefix = $RepoRoot + [System.IO.Path]::DirectorySeparatorChar
if ($null -ne $IsWindows) { $onWin = $IsWindows } else { $onWin = $true }
if ($onWin) {
$cmp = [System.StringComparison]::OrdinalIgnoreCase
} else {
$cmp = [System.StringComparison]::Ordinal
}
if ($FeatureDirectory.StartsWith($prefix, $cmp)) {
$FeatureDirectory = $FeatureDirectory.Substring($prefix.Length)
}
$fjPath = Join-Path (Join-Path $RepoRoot '.specify') 'feature.json'
# Read current value and skip write when unchanged
if (Test-Path -LiteralPath $fjPath -PathType Leaf) {
try { try {
$result = git -C $repoRoot rev-parse --abbrev-ref HEAD 2>$null $raw = Get-Content -LiteralPath $fjPath -Raw
if ($LASTEXITCODE -eq 0) { $cfg = $raw | ConvertFrom-Json
return $result if ($cfg.feature_directory -eq $FeatureDirectory) {
return
} }
} catch { } catch {
# Git command failed # File is corrupt or unreadable - overwrite it
} }
} }
# For non-git repos, try to find the latest feature directory # Ensure .specify/ directory exists
$specsDir = Join-Path $repoRoot "specs" $specifyDir = Join-Path $RepoRoot '.specify'
if (-not (Test-Path -LiteralPath $specifyDir -PathType Container)) {
if (Test-Path $specsDir) { New-Item -ItemType Directory -Path $specifyDir -Force | Out-Null
$latestFeature = ""
$highest = 0
$latestTimestamp = ""
Get-ChildItem -Path $specsDir -Directory | ForEach-Object {
if ($_.Name -match '^(\d{8}-\d{6})-') {
# Timestamp-based branch: compare lexicographically
$ts = $matches[1]
if ($ts -gt $latestTimestamp) {
$latestTimestamp = $ts
$latestFeature = $_.Name
}
} elseif ($_.Name -match '^(\d{3,})-') {
$num = [long]$matches[1]
if ($num -gt $highest) {
$highest = $num
# Only update if no timestamp branch found yet
if (-not $latestTimestamp) {
$latestFeature = $_.Name
}
}
}
}
if ($latestFeature) {
return $latestFeature
}
}
# Final fallback
return "main"
}
# Check if we have git available at the spec-kit root level
# Returns true only if git is installed and the repo root is inside a git work tree
# Handles both regular repos (.git directory) and worktrees/submodules (.git file)
function Test-HasGit {
# First check if git command is available (before calling Get-RepoRoot which may use git)
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
return $false
}
$repoRoot = Get-RepoRoot
# Check if .git exists (directory or file for worktrees/submodules)
# Use -LiteralPath to handle paths with wildcard characters
if (-not (Test-Path -LiteralPath (Join-Path $repoRoot ".git"))) {
return $false
}
# Verify it's actually a valid git work tree
try {
$null = git -C $repoRoot rev-parse --is-inside-work-tree 2>$null
return ($LASTEXITCODE -eq 0)
} catch {
return $false
}
}
# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
function Get-SpecKitEffectiveBranchName {
param([string]$Branch)
if ($Branch -match '^([^/]+)/([^/]+)$') {
return $Matches[2]
}
return $Branch
}
function Test-FeatureBranch {
param(
[string]$Branch,
[bool]$HasGit = $true
)
# For non-git repos, we can't enforce branch naming but still provide output
if (-not $HasGit) {
Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation"
return $true
} }
$raw = $Branch # Write feature.json
$Branch = Get-SpecKitEffectiveBranchName $raw $json = @{ feature_directory = $FeatureDirectory } | ConvertTo-Json -Compress
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
# Accept sequential prefix (3+ digits) but exclude malformed timestamps [System.IO.File]::WriteAllText($fjPath, $json, $utf8NoBom)
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
$hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
$isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') {
[Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw")
[Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name")
return $false
}
return $true
}
# True when .specify/feature.json pins an existing feature directory that matches the
# active FEATURE_DIR from Get-FeaturePathsEnv (so __SPECKIT_COMMAND_PLAN__ can skip git branch pattern checks).
function Test-FeatureJsonMatchesFeatureDir {
param(
[Parameter(Mandatory = $true)][string]$RepoRoot,
[Parameter(Mandatory = $true)][string]$ActiveFeatureDir
)
$featureJson = Join-Path (Join-Path $RepoRoot '.specify') 'feature.json'
if (-not (Test-Path -LiteralPath $featureJson -PathType Leaf)) {
return $false
}
try {
$raw = Get-Content -LiteralPath $featureJson -Raw
$cfg = $raw | ConvertFrom-Json
} catch {
return $false
}
$fd = $cfg.feature_directory
if ([string]::IsNullOrWhiteSpace([string]$fd)) {
return $false
}
if (-not [System.IO.Path]::IsPathRooted($fd)) {
$fd = Join-Path $RepoRoot $fd
}
if (-not (Test-Path -LiteralPath $fd -PathType Container)) {
return $false
}
# Resolve both paths to canonical absolute form. Prefer Resolve-Path (follows
# symlinks and is the canonical PS way); fall back to [Path]::GetFullPath when
# Resolve-Path can't produce a value. Mirrors the pattern used by Find-SpecifyRoot.
$resolvedJson = Resolve-Path -LiteralPath $fd -ErrorAction SilentlyContinue
if ($resolvedJson) {
$normJson = $resolvedJson.Path
} else {
$normJson = [System.IO.Path]::GetFullPath($fd)
}
$resolvedActive = Resolve-Path -LiteralPath $ActiveFeatureDir -ErrorAction SilentlyContinue
if ($resolvedActive) {
$normActive = $resolvedActive.Path
} else {
$normActive = [System.IO.Path]::GetFullPath($ActiveFeatureDir)
}
# Use case-insensitive compare only on Windows; POSIX filesystems are case-sensitive.
# PowerShell 5.1 is Windows-only and does not define $IsWindows, so treat its
# absence as "we're on Windows".
if ($null -ne $IsWindows) {
$onWindows = $IsWindows
} else {
$onWindows = $true
}
if ($onWindows) {
$comparison = [System.StringComparison]::OrdinalIgnoreCase
} else {
$comparison = [System.StringComparison]::Ordinal
}
return [string]::Equals($normJson, $normActive, $comparison)
}
# Resolve specs/<feature-dir> by numeric/timestamp prefix (mirrors scripts/bash/common.sh find_feature_dir_by_prefix).
function Find-FeatureDirByPrefix {
param(
[Parameter(Mandatory = $true)][string]$RepoRoot,
[Parameter(Mandatory = $true)][string]$Branch
)
$specsDir = Join-Path $RepoRoot 'specs'
$branchName = Get-SpecKitEffectiveBranchName $Branch
$prefix = $null
if ($branchName -match '^(\d{8}-\d{6})-') {
$prefix = $Matches[1]
} elseif ($branchName -match '^(\d{3,})-') {
$prefix = $Matches[1]
} else {
return (Join-Path $specsDir $branchName)
}
$dirMatches = @()
if (Test-Path -LiteralPath $specsDir -PathType Container) {
$dirMatches = @(Get-ChildItem -LiteralPath $specsDir -Filter "$prefix-*" -Directory -ErrorAction SilentlyContinue)
}
if ($dirMatches.Count -eq 0) {
return (Join-Path $specsDir $branchName)
}
if ($dirMatches.Count -eq 1) {
return $dirMatches[0].FullName
}
$names = ($dirMatches | ForEach-Object { $_.Name }) -join ' '
[Console]::Error.WriteLine("ERROR: Multiple spec directories found with prefix '$prefix': $names")
[Console]::Error.WriteLine('Please ensure only one spec directory exists per prefix.')
return $null
}
# Branch-based prefix resolution; mirrors bash get_feature_paths failure (stderr + exit 1).
function Get-FeatureDirFromBranchPrefixOrExit {
param(
[Parameter(Mandatory = $true)][string]$RepoRoot,
[Parameter(Mandatory = $true)][string]$CurrentBranch
)
$resolved = Find-FeatureDirByPrefix -RepoRoot $RepoRoot -Branch $CurrentBranch
if ($null -eq $resolved) {
[Console]::Error.WriteLine('ERROR: Failed to resolve feature directory')
exit 1
}
return $resolved
} }
function Get-FeaturePathsEnv { function Get-FeaturePathsEnv {
$repoRoot = Get-RepoRoot $repoRoot = Get-RepoRoot
$currentBranch = Get-CurrentBranch $currentBranch = Get-CurrentBranch
$hasGit = Test-HasGit
# Resolve feature directory. Priority: # Resolve feature directory. Priority:
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override) # 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
# 2. .specify/feature.json "feature_directory" key (persisted by __SPECKIT_COMMAND_SPECIFY__) # 2. .specify/feature.json "feature_directory" key (persisted by specify command)
# 3. Branch-name-based prefix lookup (same as scripts/bash/common.sh) # 3. Error - no feature context available
$featureJson = Join-Path $repoRoot '.specify/feature.json' $featureJson = Join-Path $repoRoot '.specify/feature.json'
if ($env:SPECIFY_FEATURE_DIRECTORY) { if ($env:SPECIFY_FEATURE_DIRECTORY) {
$featureDir = $env:SPECIFY_FEATURE_DIRECTORY $featureDir = $env:SPECIFY_FEATURE_DIRECTORY
@@ -297,6 +115,8 @@ function Get-FeaturePathsEnv {
if (-not [System.IO.Path]::IsPathRooted($featureDir)) { if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
$featureDir = Join-Path $repoRoot $featureDir $featureDir = Join-Path $repoRoot $featureDir
} }
# Persist to feature.json so future sessions without the env var still work
Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $env:SPECIFY_FEATURE_DIRECTORY
} elseif (Test-Path $featureJson) { } elseif (Test-Path $featureJson) {
$featureJsonRaw = Get-Content -LiteralPath $featureJson -Raw $featureJsonRaw = Get-Content -LiteralPath $featureJson -Raw
try { try {
@@ -312,16 +132,17 @@ function Get-FeaturePathsEnv {
$featureDir = Join-Path $repoRoot $featureDir $featureDir = Join-Path $repoRoot $featureDir
} }
} else { } else {
$featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch [Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory.")
exit 1
} }
} else { } else {
$featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch [Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json.")
exit 1
} }
[PSCustomObject]@{ [PSCustomObject]@{
REPO_ROOT = $repoRoot REPO_ROOT = $repoRoot
CURRENT_BRANCH = $currentBranch CURRENT_BRANCH = $currentBranch
HAS_GIT = $hasGit
FEATURE_DIR = $featureDir FEATURE_DIR = $featureDir
FEATURE_SPEC = Join-Path $featureDir 'spec.md' FEATURE_SPEC = Join-Path $featureDir 'spec.md'
IMPL_PLAN = Join-Path $featureDir 'plan.md' IMPL_PLAN = Join-Path $featureDir 'plan.md'

View File

@@ -21,9 +21,9 @@ if ($Help) {
Write-Host "" Write-Host ""
Write-Host "Options:" Write-Host "Options:"
Write-Host " -Json Output in JSON format" Write-Host " -Json Output in JSON format"
Write-Host " -DryRun Compute branch name and paths without creating branches, directories, or files" Write-Host " -DryRun Compute feature name and paths without creating directories or files"
Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing" Write-Host " -AllowExistingBranch Reuse an existing feature directory if it already exists"
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch" Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the feature"
Write-Host " -Number N Specify branch number manually (overrides auto-detection)" Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
Write-Host " -Help Show this help message" Write-Host " -Help Show this help message"
@@ -67,111 +67,17 @@ function Get-HighestNumberFromSpecs {
return $highest return $highest
} }
# Extract the highest sequential feature number from a list of branch/ref names.
# Shared by Get-HighestNumberFromBranches and Get-HighestNumberFromRemoteRefs.
function Get-HighestNumberFromNames {
param([string[]]$Names)
[long]$highest = 0
foreach ($name in $Names) {
if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') {
[long]$num = 0
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
$highest = $num
}
}
}
return $highest
}
function Get-HighestNumberFromBranches {
param()
try {
$branches = git branch -a 2>$null
if ($LASTEXITCODE -eq 0 -and $branches) {
$cleanNames = $branches | ForEach-Object {
$_.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
}
return Get-HighestNumberFromNames -Names $cleanNames
}
} catch {
Write-Verbose "Could not check Git branches: $_"
}
return 0
}
function Get-HighestNumberFromRemoteRefs {
[long]$highest = 0
try {
$remotes = git remote 2>$null
if ($remotes) {
foreach ($remote in $remotes) {
$env:GIT_TERMINAL_PROMPT = '0'
$refs = git ls-remote --heads $remote 2>$null
$env:GIT_TERMINAL_PROMPT = $null
if ($LASTEXITCODE -eq 0 -and $refs) {
$refNames = $refs | ForEach-Object {
if ($_ -match 'refs/heads/(.+)$') { $matches[1] }
} | Where-Object { $_ }
$remoteHighest = Get-HighestNumberFromNames -Names $refNames
if ($remoteHighest -gt $highest) { $highest = $remoteHighest }
}
}
}
} catch {
Write-Verbose "Could not query remote refs: $_"
}
return $highest
}
# Return next available branch number. When SkipFetch is true, queries remotes
# via ls-remote (read-only) instead of fetching.
function Get-NextBranchNumber {
param(
[string]$SpecsDir,
[switch]$SkipFetch
)
if ($SkipFetch) {
# Side-effect-free: query remotes via ls-remote
$highestBranch = Get-HighestNumberFromBranches
$highestRemote = Get-HighestNumberFromRemoteRefs
$highestBranch = [Math]::Max($highestBranch, $highestRemote)
} else {
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
try {
git fetch --all --prune 2>$null | Out-Null
} catch {
# Ignore fetch errors
}
$highestBranch = Get-HighestNumberFromBranches
}
# Get highest number from ALL specs (not just matching short name)
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
# Take the maximum of both
$maxNum = [Math]::Max($highestBranch, $highestSpec)
# Return next number
return $maxNum + 1
}
function ConvertTo-CleanBranchName { function ConvertTo-CleanBranchName {
param([string]$Name) param([string]$Name)
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', '' return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
} }
# Load common functions (includes Get-RepoRoot, Test-HasGit, Resolve-Template) # Load common functions (includes Get-RepoRoot and Resolve-Template)
. "$PSScriptRoot/common.ps1" . "$PSScriptRoot/common.ps1"
# Use common.ps1 functions which prioritize .specify over git # Use common.ps1 functions which prioritize .specify
$repoRoot = Get-RepoRoot $repoRoot = Get-RepoRoot
# Check if git is available at this repo root (not a parent)
$hasGit = Test-HasGit
Set-Location $repoRoot Set-Location $repoRoot
$specsDir = Join-Path $repoRoot 'specs' $specsDir = Join-Path $repoRoot 'specs'
@@ -244,21 +150,9 @@ if ($Timestamp) {
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss' $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
$branchName = "$featureNum-$branchSuffix" $branchName = "$featureNum-$branchSuffix"
} else { } else {
# Determine branch number # Determine branch number from existing feature directories
if ($Number -eq 0) { if ($Number -eq 0) {
if ($DryRun -and $hasGit) { $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
# Dry-run: query remotes via ls-remote (side-effect-free, no fetch)
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch
} elseif ($DryRun) {
# Dry-run without git: local spec dirs only
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
} elseif ($hasGit) {
# Check existing branches on remotes
$Number = Get-NextBranchNumber -SpecsDir $specsDir
} else {
# Fall back to local directory check
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
}
} }
$featureNum = ('{0:000}' -f $Number) $featureNum = ('{0:000}' -f $Number)
@@ -291,58 +185,13 @@ $featureDir = Join-Path $specsDir $branchName
$specFile = Join-Path $featureDir 'spec.md' $specFile = Join-Path $featureDir 'spec.md'
if (-not $DryRun) { if (-not $DryRun) {
if ($hasGit) { if ((Test-Path -LiteralPath $featureDir -PathType Container) -and -not $AllowExistingBranch) {
$branchCreated = $false if ($Timestamp) {
$branchCreateError = '' Write-Error "Error: Feature directory '$featureDir' already exists. Rerun to get a new timestamp or use a different -ShortName."
try { } else {
$branchCreateError = git checkout -q -b $branchName 2>&1 | Out-String Write-Error "Error: Feature directory '$featureDir' already exists. Please use a different feature name or specify a different number with -Number."
if ($LASTEXITCODE -eq 0) {
$branchCreated = $true
}
} catch {
$branchCreateError = $_.Exception.Message
} }
exit 1
if (-not $branchCreated) {
$currentBranch = ''
try { $currentBranch = (git rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {}
# Check if branch already exists
$existingBranch = git branch --list $branchName 2>$null
if ($existingBranch) {
if ($AllowExistingBranch) {
# If we're already on the branch, continue without another checkout.
if ($currentBranch -eq $branchName) {
# Already on the target branch -- nothing to do
} else {
# Otherwise switch to the existing branch instead of failing.
$switchBranchError = git checkout -q $branchName 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) {
if ($switchBranchError) {
Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())"
} else {
Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again."
}
exit 1
}
}
} elseif ($Timestamp) {
Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
exit 1
} else {
Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
exit 1
}
} else {
if ($branchCreateError) {
Write-Error "Error: Failed to create git branch '$branchName'.`n$($branchCreateError.Trim())"
} else {
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
}
exit 1
}
}
} else {
Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName"
} }
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
@@ -359,8 +208,12 @@ if (-not $DryRun) {
} }
} }
# Set the SPECIFY_FEATURE environment variable for the current session # Persist to .specify/feature.json so downstream commands can find the feature
Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $featureDir
# Set environment variables for the current session
$env:SPECIFY_FEATURE = $branchName $env:SPECIFY_FEATURE = $branchName
$env:SPECIFY_FEATURE_DIRECTORY = $featureDir
} }
if ($Json) { if ($Json) {
@@ -368,7 +221,6 @@ if ($Json) {
BRANCH_NAME = $branchName BRANCH_NAME = $branchName
SPEC_FILE = $specFile SPEC_FILE = $specFile
FEATURE_NUM = $featureNum FEATURE_NUM = $featureNum
HAS_GIT = $hasGit
} }
if ($DryRun) { if ($DryRun) {
$obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true $obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true
@@ -378,8 +230,8 @@ if ($Json) {
Write-Output "BRANCH_NAME: $branchName" Write-Output "BRANCH_NAME: $branchName"
Write-Output "SPEC_FILE: $specFile" Write-Output "SPEC_FILE: $specFile"
Write-Output "FEATURE_NUM: $featureNum" Write-Output "FEATURE_NUM: $featureNum"
Write-Output "HAS_GIT: $hasGit"
if (-not $DryRun) { if (-not $DryRun) {
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName" Write-Output "SPECIFY_FEATURE set to: $branchName"
Write-Output "SPECIFY_FEATURE_DIRECTORY set to: $featureDir"
} }
} }

View File

@@ -23,13 +23,6 @@ if ($Help) {
# Get all paths and variables from common functions # Get all paths and variables from common functions
$paths = Get-FeaturePathsEnv $paths = Get-FeaturePathsEnv
# If feature.json pins an existing feature directory, branch naming is not required.
if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFeatureDir $paths.FEATURE_DIR)) {
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) {
exit 1
}
}
# Ensure the feature directory exists # Ensure the feature directory exists
New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
@@ -61,7 +54,6 @@ if ($Json) {
IMPL_PLAN = $paths.IMPL_PLAN IMPL_PLAN = $paths.IMPL_PLAN
SPECS_DIR = $paths.FEATURE_DIR SPECS_DIR = $paths.FEATURE_DIR
BRANCH = $paths.CURRENT_BRANCH BRANCH = $paths.CURRENT_BRANCH
HAS_GIT = $paths.HAS_GIT
} }
$result | ConvertTo-Json -Compress $result | ConvertTo-Json -Compress
} else { } else {
@@ -69,5 +61,4 @@ if ($Json) {
Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)" Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)"
Write-Output "SPECS_DIR: $($paths.FEATURE_DIR)" Write-Output "SPECS_DIR: $($paths.FEATURE_DIR)"
Write-Output "BRANCH: $($paths.CURRENT_BRANCH)" Write-Output "BRANCH: $($paths.CURRENT_BRANCH)"
Write-Output "HAS_GIT: $($paths.HAS_GIT)"
} }

View File

@@ -16,16 +16,9 @@ if ($Help) {
# Source common functions # Source common functions
. "$PSScriptRoot/common.ps1" . "$PSScriptRoot/common.ps1"
# Get feature paths and validate branch # Get feature paths
$paths = Get-FeaturePathsEnv $paths = Get-FeaturePathsEnv
# If feature.json pins an existing feature directory, branch naming is not required.
if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFeatureDir $paths.FEATURE_DIR)) {
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) {
exit 1
}
}
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) { if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)") [Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
$planCommand = Format-SpecKitCommand -CommandName 'plan' -RepoRoot $paths.REPO_ROOT $planCommand = Format-SpecKitCommand -CommandName 'plan' -RepoRoot $paths.REPO_ROOT

View File

@@ -69,8 +69,6 @@ from ._utils import (
_display_project_path, _display_project_path,
check_tool as check_tool, check_tool as check_tool,
handle_vscode_settings as handle_vscode_settings, handle_vscode_settings as handle_vscode_settings,
init_git_repo as init_git_repo,
is_git_repo as is_git_repo,
merge_json_files as merge_json_files, merge_json_files as merge_json_files,
run_command as run_command, run_command as run_command,
) )
@@ -453,9 +451,6 @@ def check():
tracker = StepTracker("Check Available Tools") tracker = StepTracker("Check Available Tools")
tracker.add("git", "Git version control")
git_ok = check_tool("git", tracker=tracker)
agent_results = {} agent_results = {}
for agent_key, agent_config in AGENT_CONFIG.items(): for agent_key, agent_config in AGENT_CONFIG.items():
if agent_key == "generic": if agent_key == "generic":
@@ -483,9 +478,6 @@ def check():
console.print("\n[bold green]Specify CLI is ready to use![/bold green]") console.print("\n[bold green]Specify CLI is ready to use![/bold green]")
if not git_ok:
console.print("[dim]Tip: Install git for repository management[/dim]")
if not any(agent_results.values()): if not any(agent_results.values()):
console.print("[dim]Tip: Install a coding agent for the best experience[/dim]") console.print("[dim]Tip: Install a coding agent for the best experience[/dim]")
@@ -692,16 +684,44 @@ def preset_add(
elif from_url: elif from_url:
# Validate URL scheme before downloading # Validate URL scheme before downloading
from ipaddress import ip_address
from urllib.parse import urlparse as _urlparse from urllib.parse import urlparse as _urlparse
_parsed = _urlparse(from_url) _parsed = _urlparse(from_url)
_is_localhost = _parsed.hostname in ("localhost", "127.0.0.1", "::1")
if _parsed.scheme != "https" and not (_parsed.scheme == "http" and _is_localhost): def _is_allowed_download_url(parsed_url):
console.print(f"[red]Error:[/red] URL must use HTTPS (got {_parsed.scheme}://). HTTP is only allowed for localhost.") host = parsed_url.hostname
if not host:
return False
is_loopback = host == "localhost"
if not is_loopback:
try:
is_loopback = ip_address(host).is_loopback
except ValueError:
# Host is not an IP literal (e.g., a regular hostname); treat as non-loopback.
pass
return parsed_url.scheme == "https" or (parsed_url.scheme == "http" and is_loopback)
def _validate_download_redirect(old_url, new_url):
if not _is_allowed_download_url(_urlparse(new_url)):
import urllib.error
raise urllib.error.URLError(
"redirect target must use HTTPS with a hostname, "
"or HTTP for localhost/loopback"
)
if not _is_allowed_download_url(_parsed):
console.print(
"[red]Error:[/red] URL must use HTTPS with a hostname, "
"or HTTP for localhost/loopback."
)
raise typer.Exit(1) raise typer.Exit(1)
console.print(f"Installing preset from [cyan]{from_url}[/cyan]...") console.print(f"Installing preset from [cyan]{from_url}[/cyan]...")
import urllib.error import urllib.error
import tempfile import tempfile
import shutil
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
zip_path = Path(tmpdir) / "preset.zip" zip_path = Path(tmpdir) / "preset.zip"
@@ -715,8 +735,25 @@ def preset_add(
from_url = _resolved_from_url from_url = _resolved_from_url
_preset_extra_headers = {"Accept": "application/octet-stream"} _preset_extra_headers = {"Accept": "application/octet-stream"}
with _open_url(from_url, timeout=60, extra_headers=_preset_extra_headers) as response: with _open_url(
zip_path.write_bytes(response.read()) from_url,
timeout=60,
extra_headers=_preset_extra_headers,
redirect_validator=_validate_download_redirect,
) as response:
final_url = response.geturl() if hasattr(response, "geturl") else from_url
if not _is_allowed_download_url(_urlparse(final_url)):
console.print(
"[red]Error:[/red] Preset URL redirected to a disallowed URL: "
f"{final_url}. Redirect targets must use HTTPS with a hostname, "
"or HTTP for localhost/loopback."
)
raise typer.Exit(1)
with zip_path.open("wb") as output:
try:
shutil.copyfileobj(response, output)
except TypeError:
output.write(response.read())
except urllib.error.URLError as e: except urllib.error.URLError as e:
console.print(f"[red]Error:[/red] Failed to download: {e}") console.print(f"[red]Error:[/red] Failed to download: {e}")
raise typer.Exit(1) raise typer.Exit(1)
@@ -1194,7 +1231,7 @@ def preset_catalog_add(
}) })
config["catalogs"] = catalogs config["catalogs"] = catalogs
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8") config_path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
install_label = "install allowed" if install_allowed else "discovery only" install_label = "install allowed" if install_allowed else "discovery only"
console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})") console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})")
@@ -1234,7 +1271,7 @@ def preset_catalog_remove(
raise typer.Exit(1) raise typer.Exit(1)
config["catalogs"] = catalogs config["catalogs"] = catalogs
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8") config_path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
console.print(f"[green]✓[/green] Removed catalog '{name}'") console.print(f"[green]✓[/green] Removed catalog '{name}'")
if not catalogs: if not catalogs:
@@ -1983,7 +2020,11 @@ def extension_info(
author = ext_manifest.data.get("extension", {}).get("author") author = ext_manifest.data.get("extension", {}).get("author")
if author: if author:
console.print(f"[dim]Author:[/dim] {author}") console.print(f"[dim]Author:[/dim] {author}")
console.print() if ext_manifest.category:
console.print(f"[dim]Category:[/dim] {ext_manifest.category}")
if ext_manifest.effect:
console.print(f"[dim]Effect:[/dim] {ext_manifest.effect}")
console.print()
if ext_manifest.commands: if ext_manifest.commands:
console.print("[bold]Commands:[/bold]") console.print("[bold]Commands:[/bold]")
@@ -2033,6 +2074,12 @@ def _print_extension_info(ext_info: dict, manager):
console.print(f"[dim]Author:[/dim] {ext_info.get('author', 'Unknown')}") console.print(f"[dim]Author:[/dim] {ext_info.get('author', 'Unknown')}")
console.print(f"[dim]License:[/dim] {ext_info.get('license', 'Unknown')}") console.print(f"[dim]License:[/dim] {ext_info.get('license', 'Unknown')}")
# Category and Effect
if ext_info.get('category'):
console.print(f"[dim]Category:[/dim] {ext_info['category']}")
if ext_info.get('effect'):
console.print(f"[dim]Effect:[/dim] {ext_info['effect']}")
# Source catalog # Source catalog
if ext_info.get("_catalog_name"): if ext_info.get("_catalog_name"):
install_allowed = ext_info.get("_install_allowed", True) install_allowed = ext_info.get("_install_allowed", True)

View File

@@ -1,9 +1,11 @@
"""Shared GitHub-authenticated HTTP helpers. """Shared GitHub HTTP request helpers.
Used by both ExtensionCatalog and PresetCatalog to attach Provides ``build_github_request()`` for attaching GITHUB_TOKEN / GH_TOKEN
GITHUB_TOKEN / GH_TOKEN credentials to requests targeting credentials to requests targeting GitHub-hosted domains, and
GitHub-hosted domains, while preventing token leakage to ``resolve_github_release_asset_api_url()`` — used by extensions, presets,
third-party hosts on redirects. and workflow URL resolution — to translate browser release-download URLs
into GitHub REST API asset URLs. Authenticated downloads themselves go
through the config-driven helpers in :mod:`specify_cli.authentication.http`.
""" """
import os import os
@@ -54,28 +56,6 @@ def build_github_request(url: str) -> urllib.request.Request:
return urllib.request.Request(url, headers=headers) return urllib.request.Request(url, headers=headers)
class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
"""Redirect handler that drops the Authorization header when leaving GitHub.
Prevents token leakage to CDNs or other third-party hosts that GitHub
may redirect to (e.g. S3 for release asset downloads, objects.githubusercontent.com).
Auth is preserved as long as the redirect target remains within GITHUB_HOSTS.
"""
def redirect_request(self, req, fp, code, msg, headers, newurl):
original_auth = req.get_header("Authorization")
new_req = super().redirect_request(req, fp, code, msg, headers, newurl)
if new_req is not None:
hostname = (urlparse(newurl).hostname or "").lower()
if hostname in GITHUB_HOSTS:
if original_auth:
new_req.add_unredirected_header("Authorization", original_auth)
else:
new_req.headers.pop("Authorization", None)
new_req.unredirected_hdrs.pop("Authorization", None)
return new_req
def resolve_github_release_asset_api_url( def resolve_github_release_asset_api_url(
download_url: str, download_url: str,
open_url_fn: Callable, open_url_fn: Callable,
@@ -147,20 +127,3 @@ def resolve_github_release_asset_api_url(
return str(asset["url"]) return str(asset["url"])
return None return None
def open_github_url(url: str, timeout: int = 10):
"""Open a URL with GitHub auth, stripping the header on cross-host redirects.
When the request carries an Authorization header, a custom redirect
handler drops that header if the redirect target is not a GitHub-owned
domain, preventing token leakage to CDNs or other third-party hosts
that GitHub may redirect to (e.g. S3 for release asset downloads).
"""
req = build_github_request(url)
if not req.get_header("Authorization"):
return urllib.request.urlopen(req, timeout=timeout)
opener = urllib.request.build_opener(_StripAuthOnRedirect)
return opener.open(req, timeout=timeout)

View File

@@ -77,51 +77,6 @@ def check_tool(tool: str, tracker=None) -> bool:
return found return found
def is_git_repo(path: Path | None = None) -> bool:
"""Check if the specified path is inside a git repository."""
if path is None:
path = Path.cwd()
if not path.is_dir():
return False
try:
subprocess.run(
["git", "rev-parse", "--is-inside-work-tree"],
check=True,
capture_output=True,
cwd=path,
)
return True
except (subprocess.CalledProcessError, FileNotFoundError):
return False
def init_git_repo(project_path: Path, quiet: bool = False) -> tuple[bool, str | None]:
"""Initialize a git repository in the specified path."""
try:
original_cwd = Path.cwd()
os.chdir(project_path)
if not quiet:
console.print("[cyan]Initializing git repository...[/cyan]")
subprocess.run(["git", "init"], check=True, capture_output=True, text=True)
subprocess.run(["git", "add", "."], check=True, capture_output=True, text=True)
subprocess.run(["git", "commit", "-m", "Initial commit from Specify template"], check=True, capture_output=True, text=True)
if not quiet:
console.print("[green]✓[/green] Git repository initialized")
return True, None
except subprocess.CalledProcessError as e:
error_msg = f"Command: {' '.join(e.cmd)}\nExit code: {e.returncode}"
if e.stderr:
error_msg += f"\nError: {e.stderr.strip()}"
elif e.stdout:
error_msg += f"\nOutput: {e.stdout.strip()}"
if not quiet:
console.print(f"[red]Error initializing git repository:[/red] {e}")
return False, error_msg
finally:
os.chdir(original_cwd)
def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker=None) -> None: def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker=None) -> None:
"""Handle merging or copying of .vscode/settings.json files. """Handle merging or copying of .vscode/settings.json files.

View File

@@ -14,6 +14,7 @@ from __future__ import annotations
import urllib.error import urllib.error
import urllib.request import urllib.request
from fnmatch import fnmatch from fnmatch import fnmatch
from typing import Callable
from urllib.parse import urlparse from urllib.parse import urlparse
from . import get_provider from . import get_provider
@@ -56,22 +57,36 @@ def _hostname_in_hosts(hostname: str, hosts: tuple[str, ...]) -> bool:
return any(p == hostname or fnmatch(hostname, p) for p in hosts) return any(p == hostname or fnmatch(hostname, p) for p in hosts)
class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler): RedirectValidator = Callable[[str, str], None]
"""Drop ``Authorization`` when a redirect leaves the entry's declared hosts."""
def __init__(self, hosts: tuple[str, ...]) -> None:
class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
"""Drop ``Authorization`` when a redirect leaves trusted hosts or downgrades."""
def __init__(
self,
hosts: tuple[str, ...],
redirect_validator: RedirectValidator | None = None,
) -> None:
super().__init__() super().__init__()
self._hosts = hosts self._hosts = hosts
self._redirect_validator = redirect_validator
def redirect_request(self, req, fp, code, msg, headers, newurl): def redirect_request(self, req, fp, code, msg, headers, newurl):
if self._redirect_validator is not None:
self._redirect_validator(req.full_url, newurl)
original_auth = ( original_auth = (
req.get_header("Authorization") req.get_header("Authorization")
or req.unredirected_hdrs.get("Authorization") or req.unredirected_hdrs.get("Authorization")
) )
new_req = super().redirect_request(req, fp, code, msg, headers, newurl) new_req = super().redirect_request(req, fp, code, msg, headers, newurl)
if new_req is not None: if new_req is not None:
hostname = (urlparse(newurl).hostname or "").lower() old_scheme = urlparse(req.full_url).scheme
if _hostname_in_hosts(hostname, self._hosts): new_parsed = urlparse(newurl)
hostname = (new_parsed.hostname or "").lower()
is_https_downgrade = old_scheme == "https" and new_parsed.scheme != "https"
if _hostname_in_hosts(hostname, self._hosts) and not is_https_downgrade:
if original_auth: if original_auth:
new_req.add_unredirected_header("Authorization", original_auth) new_req.add_unredirected_header("Authorization", original_auth)
else: else:
@@ -103,7 +118,12 @@ def build_request(url: str, extra_headers: dict[str, str] | None = None) -> urll
return urllib.request.Request(url, headers=headers) return urllib.request.Request(url, headers=headers)
def open_url(url: str, timeout: int = 10, extra_headers: dict[str, str] | None = None): def open_url(
url: str,
timeout: int = 10,
extra_headers: dict[str, str] | None = None,
redirect_validator: RedirectValidator | None = None,
):
"""Open *url* with config-driven auth, redirect stripping, and fallthrough. """Open *url* with config-driven auth, redirect stripping, and fallthrough.
1. Find ``auth.json`` entries whose hosts match the URL. 1. Find ``auth.json`` entries whose hosts match the URL.
@@ -113,6 +133,8 @@ def open_url(url: str, timeout: int = 10, extra_headers: dict[str, str] | None =
5. Non-auth errors (404, 500, network) raise immediately. 5. Non-auth errors (404, 500, network) raise immediately.
*extra_headers* (e.g. ``Accept``) are merged into every attempt. *extra_headers* (e.g. ``Accept``) are merged into every attempt.
*redirect_validator*, when provided, is called with ``(old_url, new_url)``
before following each redirect and may raise to reject the redirect.
""" """
entries = find_entries_for_url(url, _load_config()) entries = find_entries_for_url(url, _load_config())
@@ -135,7 +157,7 @@ def open_url(url: str, timeout: int = 10, extra_headers: dict[str, str] | None =
continue continue
req = _make_req(provider.auth_headers(token, entry.auth)) req = _make_req(provider.auth_headers(token, entry.auth))
opener = urllib.request.build_opener(_StripAuthOnRedirect(entry.hosts)) opener = urllib.request.build_opener(_StripAuthOnRedirect(entry.hosts, redirect_validator))
try: try:
return opener.open(req, timeout=timeout) return opener.open(req, timeout=timeout)
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
@@ -146,4 +168,7 @@ def open_url(url: str, timeout: int = 10, extra_headers: dict[str, str] | None =
# No entry worked (or none matched) — unauthenticated fallback # No entry worked (or none matched) — unauthenticated fallback
req = _make_req({}) req = _make_req({})
if redirect_validator is not None:
opener = urllib.request.build_opener(_StripAuthOnRedirect((), redirect_validator))
return opener.open(req, timeout=timeout)
return urllib.request.urlopen(req, timeout=timeout) # noqa: S310 return urllib.request.urlopen(req, timeout=timeout) # noqa: S310

View File

@@ -23,7 +23,7 @@ from .._assets import (
get_speckit_version, get_speckit_version,
) )
from .._console import StepTracker, console, select_with_arrows, show_banner from .._console import StepTracker, console, select_with_arrows, show_banner
from .._utils import check_tool, init_git_repo, is_git_repo from .._utils import check_tool
def _stdin_is_interactive() -> bool: def _stdin_is_interactive() -> bool:
@@ -71,7 +71,6 @@ def register(app: typer.Typer) -> None:
project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"), project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"),
script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"), script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"),
ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for coding agent tools like Claude Code"), ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for coding agent tools like Claude Code"),
no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"),
here: bool = typer.Option(False, "--here", help="Initialize project in the current directory instead of creating a new one"), here: bool = typer.Option(False, "--here", help="Initialize project in the current directory instead of creating a new one"),
force: bool = typer.Option(False, "--force", help="Force merge/overwrite when using --here (skip confirmation)"), force: bool = typer.Option(False, "--force", help="Force merge/overwrite when using --here (skip confirmation)"),
skip_tls: bool = typer.Option(False, "--skip-tls", help="Deprecated (no-op). Previously: skip SSL/TLS verification.", hidden=True), skip_tls: bool = typer.Option(False, "--skip-tls", help="Deprecated (no-op). Previously: skip SSL/TLS verification.", hidden=True),
@@ -79,7 +78,6 @@ def register(app: typer.Typer) -> None:
github_token: str = typer.Option(None, "--github-token", help="Deprecated (no-op). Previously: GitHub token for API requests.", hidden=True), github_token: str = typer.Option(None, "--github-token", help="Deprecated (no-op). Previously: GitHub token for API requests.", hidden=True),
offline: bool = typer.Option(False, "--offline", help="Deprecated (no-op). All scaffolding now uses bundled assets.", hidden=True), offline: bool = typer.Option(False, "--offline", help="Deprecated (no-op). All scaffolding now uses bundled assets.", hidden=True),
preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"), preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"),
branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, …, 1000, … — expands past 999 automatically) or 'timestamp' (YYYYMMDD-HHMMSS)"),
integration: str = typer.Option(None, "--integration", help="AI coding agent integration to use (e.g. --integration copilot). See 'specify check' for available integrations."), integration: str = typer.Option(None, "--integration", help="AI coding agent integration to use (e.g. --integration copilot). See 'specify check' for available integrations."),
integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'), integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
): ):
@@ -91,18 +89,16 @@ def register(app: typer.Typer) -> None:
match the installed CLI version. match the installed CLI version.
This command will: This command will:
1. Check that required tools are installed (git is optional) 1. Check that required tools are installed
2. Let you choose your coding agent integration, or default to Copilot 2. Let you choose your coding agent integration, or default to Copilot
in non-interactive sessions in non-interactive sessions
3. Install bundled Spec Kit templates, scripts, workflow, and shared 3. Install bundled Spec Kit templates, scripts, workflow, and shared
project infrastructure project infrastructure
4. Initialize a fresh git repository (if not --no-git and no existing repo) 4. Set up coding agent integration commands and optional presets
5. Set up coding agent integration commands and optional presets
Examples: Examples:
specify init my-project specify init my-project
specify init my-project --integration claude specify init my-project --integration claude
specify init my-project --integration copilot --no-git
specify init --ignore-agent-tools my-project specify init --ignore-agent-tools my-project
specify init . --integration claude # Initialize in current directory specify init . --integration claude # Initialize in current directory
specify init . # Initialize in current directory (interactive integration selection) specify init . # Initialize in current directory (interactive integration selection)
@@ -142,13 +138,6 @@ def register(app: typer.Typer) -> None:
console.print(f"[yellow]Available integrations:[/yellow] {available}") console.print(f"[yellow]Available integrations:[/yellow] {available}")
raise typer.Exit(1) raise typer.Exit(1)
if no_git:
console.print(
"[yellow]⚠️ --no-git is deprecated and will be removed in v0.10.0.[/yellow]\n"
"[yellow]The git extension will no longer be enabled by default "
"— use the [bold]specify extension[/bold] commands to install or enable the git extension if needed.[/yellow]"
)
if project_name == ".": if project_name == ".":
here = True here = True
project_name = None project_name = None
@@ -161,10 +150,7 @@ def register(app: typer.Typer) -> None:
console.print("[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag") console.print("[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag")
raise typer.Exit(1) raise typer.Exit(1)
BRANCH_NUMBERING_CHOICES = {"sequential", "timestamp"}
if branch_numbering and branch_numbering not in BRANCH_NUMBERING_CHOICES:
console.print(f"[red]Error:[/red] Invalid --branch-numbering value '{branch_numbering}'. Choose from: {', '.join(sorted(BRANCH_NUMBERING_CHOICES))}")
raise typer.Exit(1)
dir_existed_before = False dir_existed_before = False
if here: if here:
@@ -253,12 +239,6 @@ def register(app: typer.Typer) -> None:
console.print(Panel("\n".join(setup_lines), border_style="cyan", padding=(1, 2))) console.print(Panel("\n".join(setup_lines), border_style="cyan", padding=(1, 2)))
should_init_git = False
if not no_git:
should_init_git = check_tool("git")
if not should_init_git:
console.print("[yellow]Git not found - will skip repository initialization[/yellow]")
if not ignore_agent_tools: if not ignore_agent_tools:
agent_config = AGENT_CONFIG.get(selected_ai) agent_config = AGENT_CONFIG.get(selected_ai)
if agent_config and agent_config["requires_cli"]: if agent_config and agent_config["requires_cli"]:
@@ -308,15 +288,12 @@ def register(app: typer.Typer) -> None:
for key, label in [ for key, label in [
("chmod", "Ensure scripts executable"), ("chmod", "Ensure scripts executable"),
("constitution", "Constitution setup"), ("constitution", "Constitution setup"),
("git", "Install git extension"),
("workflow", "Install bundled workflow"), ("workflow", "Install bundled workflow"),
("agent-context", "Install agent-context extension"), ("agent-context", "Install agent-context extension"),
("final", "Finalize"), ("final", "Finalize"),
]: ]:
tracker.add(key, label) tracker.add(key, label)
git_default_notice = False
with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live: with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live:
tracker.attach_refresh(lambda: live.update(tracker.render())) tracker.attach_refresh(lambda: live.update(tracker.render()))
try: try:
@@ -369,55 +346,6 @@ def register(app: typer.Typer) -> None:
ensure_constitution_from_template(project_path, tracker=tracker) ensure_constitution_from_template(project_path, tracker=tracker)
if not no_git:
tracker.start("git")
git_messages = []
git_has_error = False
if is_git_repo(project_path):
git_messages.append("existing repo detected")
elif should_init_git:
success, error_msg = init_git_repo(project_path, quiet=True)
if success:
git_messages.append("initialized")
else:
git_has_error = True
if error_msg:
sanitized = error_msg.replace('\n', ' ').strip()
git_messages.append(f"init failed: {sanitized[:120]}")
else:
git_messages.append("init failed")
else:
git_messages.append("git not available")
try:
from ..extensions import ExtensionManager
bundled_path = _locate_bundled_extension("git")
if bundled_path:
manager = ExtensionManager(project_path)
if manager.registry.is_installed("git"):
git_messages.append("extension already installed")
else:
manager.install_from_directory(
bundled_path, get_speckit_version()
)
git_default_notice = True
git_messages.append("extension installed")
else:
git_has_error = True
git_messages.append("bundled extension not found")
except Exception as ext_err:
git_has_error = True
sanitized_ext = str(ext_err).replace('\n', ' ').strip()
git_messages.append(
f"extension install failed: {sanitized_ext[:120]}"
)
summary = "; ".join(git_messages)
if git_has_error:
tracker.error("git", summary)
else:
tracker.complete("git", summary)
else:
tracker.skip("git", "--no-git flag")
try: try:
bundled_wf = _locate_bundled_workflow("speckit") bundled_wf = _locate_bundled_workflow("speckit")
if bundled_wf: if bundled_wf:
@@ -451,9 +379,9 @@ def register(app: typer.Typer) -> None:
init_opts = { init_opts = {
"ai": selected_ai, "ai": selected_ai,
"integration": resolved_integration.key, "integration": resolved_integration.key,
"branch_numbering": branch_numbering or "sequential",
"here": here, "here": here,
"script": selected_script, "script": selected_script,
"feature_numbering": "sequential",
"speckit_version": get_speckit_version(), "speckit_version": get_speckit_version(),
} }
from ..integrations.base import SkillsIntegration as _SkillsPersist from ..integrations.base import SkillsIntegration as _SkillsPersist
@@ -596,18 +524,6 @@ def register(app: typer.Typer) -> None:
console.print() console.print()
console.print(security_notice) console.print(security_notice)
if git_default_notice:
default_change_notice = Panel(
"The git extension is currently enabled by default during [bold]specify init[/bold].\n"
"Starting in [bold]v0.10.0[/bold], this will require explicit opt-in.\n"
"Use [bold]specify extension add git[/bold] after init when needed.",
title="[yellow]Notice: Git Default Changing[/yellow]",
border_style="yellow",
padding=(1, 2),
)
console.print()
console.print(default_change_notice)
steps_lines = [] steps_lines = []
if not here: if not here:
steps_lines.append(f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]") steps_lines.append(f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]")

View File

@@ -41,6 +41,8 @@ _FALLBACK_CORE_COMMAND_NAMES = frozenset({
}) })
EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$") EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$")
VALID_EFFECTS = frozenset({"read-only", "read-write"})
DEFAULT_HOOK_PRIORITY = 10 DEFAULT_HOOK_PRIORITY = 10
REINSTALL_COMMAND = "uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git" REINSTALL_COMMAND = "uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git"
@@ -201,6 +203,21 @@ class ExtensionManifest:
except pkg_version.InvalidVersion: except pkg_version.InvalidVersion:
raise ValidationError(f"Invalid version: {ext['version']}") raise ValidationError(f"Invalid version: {ext['version']}")
# Validate optional category field (free-form string)
if "category" in ext:
if not isinstance(ext["category"], str) or not ext["category"].strip():
raise ValidationError(
"Invalid extension.category: must be a non-empty string"
)
# Validate optional effect field
if "effect" in ext:
if not isinstance(ext["effect"], str) or ext["effect"] not in VALID_EFFECTS:
raise ValidationError(
f"Invalid extension.effect '{ext.get('effect')}': "
f"must be one of {sorted(VALID_EFFECTS)}"
)
# Validate requires section # Validate requires section
requires = self.data["requires"] requires = self.data["requires"]
if "speckit_version" not in requires: if "speckit_version" not in requires:
@@ -374,6 +391,16 @@ class ExtensionManifest:
"""Get extension description.""" """Get extension description."""
return self.data["extension"]["description"] return self.data["extension"]["description"]
@property
def category(self) -> Optional[str]:
"""Get extension category (free-form; common values: docs, code, process, integration, visibility)."""
return self.data["extension"].get("category")
@property
def effect(self) -> Optional[str]:
"""Get extension effect (read-only, read-write)."""
return self.data["extension"].get("effect")
@property @property
def requires_speckit_version(self) -> str: def requires_speckit_version(self) -> str:
"""Get required spec-kit version range.""" """Get required spec-kit version range."""
@@ -1026,6 +1053,22 @@ class ExtensionManager:
description, description,
f"extension:{manifest.id}", f"extension:{manifest.id}",
) )
# Preserve the command's argument-hint in the generated skill,
# mirroring the core template path (ClaudeIntegration.setup injects
# it for built-in commands). The value is added to the frontmatter
# dict before serialization — rather than via the string-based
# inject_argument_hint helper — so that a folded multi-line
# description cannot be split by the inserted line. Gated on the
# integration exposing inject_argument_hint so only argument-hint
# aware agents receive the key, leaving build_skill_frontmatter's
# shared shape unchanged for every other agent.
argument_hint = frontmatter.get("argument-hint")
if (
argument_hint
and integration is not None
and hasattr(integration, "inject_argument_hint")
):
frontmatter_data["argument-hint"] = str(argument_hint)
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
# Derive a human-friendly title from the command name # Derive a human-friendly title from the command name
@@ -1905,6 +1948,44 @@ class ExtensionCatalog(CatalogStackBase):
download_url, self._open_url, timeout=timeout download_url, self._open_url, timeout=timeout
) )
def _validate_catalog_payload(self, catalog_data: Any, url: str) -> None:
"""Validate a parsed catalog payload's shape.
Applied to both network-fetched and cache-loaded payloads so a
once-poisoned cache (older spec-kit version, manual edit, upstream
served a bad payload before the network-side guards were added)
cannot re-crash ``_get_merged_extensions`` on subsequent calls.
Checking only key presence would let a payload like
``{"extensions": []}`` or ``{"extensions": null}`` slip through
here and then crash with ``AttributeError: 'list' object has no
attribute 'items'`` deep inside ``_get_merged_extensions``. The
sibling integration catalog reader already guards both the root
object and the nested mapping (see ``integrations/catalog.py``);
the extension catalog must stay consistent so a malformed payload
surfaces as the user-facing ``Invalid catalog format`` error
instead of a raw Python traceback.
Args:
catalog_data: Parsed JSON payload from the catalog source.
url: Source URL — used in the error message so the user can
tell which catalog in a multi-catalog stack is malformed.
Raises:
ExtensionError: If the payload's shape is invalid.
"""
if not isinstance(catalog_data, dict):
raise ExtensionError(
f"Invalid catalog format from {url}: expected a JSON object"
)
if "schema_version" not in catalog_data or "extensions" not in catalog_data:
raise ExtensionError(f"Invalid catalog format from {url}")
if not isinstance(catalog_data.get("extensions"), dict):
raise ExtensionError(
f"Invalid catalog format from {url}: "
"'extensions' must be a JSON object"
)
def get_active_catalogs(self) -> List[CatalogEntry]: def get_active_catalogs(self) -> List[CatalogEntry]:
"""Get the ordered list of active catalogs. """Get the ordered list of active catalogs.
@@ -2020,21 +2101,51 @@ class ExtensionCatalog(CatalogStackBase):
is_valid = False is_valid = False
if not force_refresh and cache_file.exists() and cache_meta_file.exists(): if not force_refresh and cache_file.exists() and cache_meta_file.exists():
try: try:
metadata = json.loads(cache_meta_file.read_text()) metadata = json.loads(
cache_meta_file.read_text(encoding="utf-8")
)
cached_at = datetime.fromisoformat(metadata.get("cached_at", "")) cached_at = datetime.fromisoformat(metadata.get("cached_at", ""))
if cached_at.tzinfo is None: if cached_at.tzinfo is None:
cached_at = cached_at.replace(tzinfo=timezone.utc) cached_at = cached_at.replace(tzinfo=timezone.utc)
age = (datetime.now(timezone.utc) - cached_at).total_seconds() age = (datetime.now(timezone.utc) - cached_at).total_seconds()
is_valid = age < self.CACHE_DURATION is_valid = age < self.CACHE_DURATION
except (json.JSONDecodeError, ValueError, KeyError, TypeError): except (
# If metadata is invalid or missing expected fields, treat cache as invalid json.JSONDecodeError,
OSError,
UnicodeError,
ValueError,
KeyError,
TypeError,
AttributeError,
):
# Cache validity is best-effort: invalid/missing metadata
# fields, an unreadable metadata file (permissions / disk),
# a wrongly-encoded metadata file (written by a tool using
# the system locale codec), or a metadata payload that
# parses to a non-mapping like ``[]`` or ``"oops"`` (so
# ``metadata.get(...)`` raises ``AttributeError``) all
# degrade to "cache invalid" so the caller falls through
# to a network refetch instead of crashing.
pass pass
# Use cache if valid # Use cache if valid. A previously-cached payload must clear the
# same shape checks as a freshly-fetched one — otherwise a once-
# poisoned cache (older spec-kit version, manual edit, upstream
# served a bad payload before the network-side guards were added)
# would re-crash on every invocation despite the cache being
# "valid" by age. If validation fails on the cached read, fall
# through to the network fetch path so the cache gets refreshed.
if is_valid: if is_valid:
try: try:
return json.loads(cache_file.read_text()) cached_data = json.loads(cache_file.read_text(encoding="utf-8"))
except json.JSONDecodeError: self._validate_catalog_payload(cached_data, entry.url)
return cached_data
except (json.JSONDecodeError, OSError, UnicodeError, ExtensionError):
# Cache is best-effort: a JSON-decode failure, an OS-level
# read failure (permissions / disk / handle limit), or a
# text-encoding failure on a cache file written by an older
# client all fall through to the network fetch path. Only
# the network failure is surfaced to the caller.
pass pass
# Fetch from network # Fetch from network
@@ -2042,16 +2153,32 @@ class ExtensionCatalog(CatalogStackBase):
with self._open_url(entry.url, timeout=10) as response: with self._open_url(entry.url, timeout=10) as response:
catalog_data = json.loads(response.read()) catalog_data = json.loads(response.read())
if "schema_version" not in catalog_data or "extensions" not in catalog_data: self._validate_catalog_payload(catalog_data, entry.url)
raise ExtensionError(f"Invalid catalog format from {entry.url}")
# Save to cache # Save to cache. Both files are explicitly UTF-8 to match the
self.cache_dir.mkdir(parents=True, exist_ok=True) # ``read_text(encoding="utf-8")`` on the read side and the
cache_file.write_text(json.dumps(catalog_data, indent=2)) # ``integrations/catalog.py`` precedent (see the cache write
cache_meta_file.write_text(json.dumps({ # helpers in ``CatalogCache`` there). Without this, platforms
"cached_at": datetime.now(timezone.utc).isoformat(), # whose default encoding isn't UTF-8 would write locale-encoded
"catalog_url": entry.url, # bytes that the read path can't decode, forcing an unnecessary
}, indent=2)) # network refetch on every invocation. The write itself is
# best-effort, matching the read side: an unwritable cache dir
# (read-only checkout, permissions) must not fail a fetch whose
# payload was already fetched and validated.
try:
self.cache_dir.mkdir(parents=True, exist_ok=True)
cache_file.write_text(
json.dumps(catalog_data, indent=2), encoding="utf-8"
)
cache_meta_file.write_text(
json.dumps({
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": entry.url,
}, indent=2),
encoding="utf-8",
)
except OSError:
pass # Cache is best-effort; proceed with fetched data
return catalog_data return catalog_data
@@ -2098,6 +2225,16 @@ class ExtensionCatalog(CatalogStackBase):
continue continue
for ext_id, ext_data in catalog_data.get("extensions", {}).items(): for ext_id, ext_data in catalog_data.get("extensions", {}).items():
# Per-entry guard: ``_fetch_single_catalog`` already validates
# that ``catalog_data["extensions"]`` is a mapping, but it
# does not (and should not) validate every entry shape there
# — one malformed entry shouldn't poison an otherwise valid
# catalog. Skip non-mapping entries here so a payload like
# ``{"extensions": {"foo": [], "bar": {...}}}`` still merges
# the valid entries without crashing on ``**ext_data``.
# Mirrors ``integrations/catalog.py:245``.
if not isinstance(ext_data, dict):
continue
if ext_id not in merged: # Higher-priority catalog wins if ext_id not in merged: # Higher-priority catalog wins
merged[ext_id] = { merged[ext_id] = {
**ext_data, **ext_data,
@@ -2114,6 +2251,12 @@ class ExtensionCatalog(CatalogStackBase):
def is_cache_valid(self) -> bool: def is_cache_valid(self) -> bool:
"""Check if cached catalog is still valid. """Check if cached catalog is still valid.
Returns ``False`` for any read/decoding failure on the metadata
file (missing fields, malformed JSON, permissions / disk errors,
wrong text encoding) so callers fall through to a network refetch
instead of crashing. Treating cache validity as best-effort
matches the contract used by the per-URL cache check below.
Returns: Returns:
True if cache exists and is within cache duration True if cache exists and is within cache duration
""" """
@@ -2121,13 +2264,28 @@ class ExtensionCatalog(CatalogStackBase):
return False return False
try: try:
metadata = json.loads(self.cache_metadata_file.read_text()) metadata = json.loads(
self.cache_metadata_file.read_text(encoding="utf-8")
)
cached_at = datetime.fromisoformat(metadata.get("cached_at", "")) cached_at = datetime.fromisoformat(metadata.get("cached_at", ""))
if cached_at.tzinfo is None: if cached_at.tzinfo is None:
cached_at = cached_at.replace(tzinfo=timezone.utc) cached_at = cached_at.replace(tzinfo=timezone.utc)
age_seconds = (datetime.now(timezone.utc) - cached_at).total_seconds() age_seconds = (datetime.now(timezone.utc) - cached_at).total_seconds()
return age_seconds < self.CACHE_DURATION return age_seconds < self.CACHE_DURATION
except (json.JSONDecodeError, ValueError, KeyError, TypeError): except (
json.JSONDecodeError,
OSError,
UnicodeError,
ValueError,
KeyError,
TypeError,
AttributeError,
):
# ``AttributeError`` covers the case where the metadata file is
# valid JSON but parses to a non-mapping (``[]``, ``"oops"``,
# ``42``) so ``metadata.get(...)`` would otherwise crash. All
# decode/shape failures degrade to "cache invalid" so the
# caller falls through to a network refetch.
return False return False
def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]: def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]:
@@ -2142,36 +2300,62 @@ class ExtensionCatalog(CatalogStackBase):
Raises: Raises:
ExtensionError: If catalog cannot be fetched ExtensionError: If catalog cannot be fetched
""" """
# Check cache first unless force refresh catalog_url = self.get_catalog_url()
# Check the cache first unless ``force_refresh`` was requested,
# then fall through to a network fetch. Match the
# ``_fetch_single_catalog`` cache contract: a poisoned or
# unreadable cache silently falls through to a network refetch
# rather than crashing the caller. ``_validate_catalog_payload``
# is reused here so a cache written by an older client
# (pre-validation) is rejected and refreshed instead of returning
# the stale malformed payload. ``is_cache_valid`` itself swallows
# OSError/UnicodeError on the metadata read, so a cache-validity
# check can't crash this method before the read-side fallback
# runs.
if not force_refresh and self.is_cache_valid(): if not force_refresh and self.is_cache_valid():
try: try:
return json.loads(self.cache_file.read_text()) cached_data = json.loads(self.cache_file.read_text(encoding="utf-8"))
except json.JSONDecodeError: self._validate_catalog_payload(cached_data, catalog_url)
return cached_data
except (json.JSONDecodeError, OSError, UnicodeError, ExtensionError):
pass # Fall through to network fetch pass # Fall through to network fetch
# Fetch from network
catalog_url = self.get_catalog_url()
try: try:
import urllib.error import urllib.error
with self._open_url(catalog_url, timeout=10) as response: with self._open_url(catalog_url, timeout=10) as response:
catalog_data = json.loads(response.read()) catalog_data = json.loads(response.read())
# Validate catalog structure # Validate catalog structure. Reuses the same helper as
if "schema_version" not in catalog_data or "extensions" not in catalog_data: # ``_fetch_single_catalog`` so all three branches (root type,
raise ExtensionError("Invalid catalog format") # missing keys, nested-mapping type) stay consistent.
self._validate_catalog_payload(catalog_data, catalog_url)
# Save to cache # Save to cache. Explicit UTF-8 on both writes mirrors the
self.cache_dir.mkdir(parents=True, exist_ok=True) # ``read_text(encoding="utf-8")`` on the read side and the
self.cache_file.write_text(json.dumps(catalog_data, indent=2)) # ``integrations/catalog.py`` precedent — otherwise platforms
# whose default encoding isn't UTF-8 would write locale-encoded
# bytes the read path can't decode, forcing an unnecessary
# refetch on every invocation. Like the read side, the write
# is best-effort: an unwritable cache dir must not abort a
# fetch whose payload was already fetched and validated.
try:
self.cache_dir.mkdir(parents=True, exist_ok=True)
self.cache_file.write_text(
json.dumps(catalog_data, indent=2), encoding="utf-8"
)
# Save cache metadata # Save cache metadata
metadata = { metadata = {
"cached_at": datetime.now(timezone.utc).isoformat(), "cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": catalog_url, "catalog_url": catalog_url,
} }
self.cache_metadata_file.write_text(json.dumps(metadata, indent=2)) self.cache_metadata_file.write_text(
json.dumps(metadata, indent=2), encoding="utf-8"
)
except OSError:
pass # Cache is best-effort; proceed with fetched data
return catalog_data return catalog_data

View File

@@ -25,17 +25,14 @@ class IntegrationReadError:
schema: int | None = None schema: int | None = None
def try_read_integration_json( def _read_integration_json_data(
project_root: Path, project_root: Path,
) -> tuple[dict[str, Any] | None, IntegrationReadError | None]: ) -> tuple[dict[str, Any] | None, IntegrationReadError | None]:
"""Parse ``.specify/integration.json`` without raising. """Read raw integration state without normalizing or raising.
Returns ``(normalized_state, None)`` on success, ``(None, None)`` when the Returns ``(data, None)`` when the JSON object is readable and supported,
file does not exist, or ``(None, error)`` for any parse / validation ``(None, None)`` when the file is absent, and ``(None, error)`` for parse,
failure. This is the single low-level reader; both the CLI's loud schema, encoding, or filesystem failures.
``_read_integration_json`` and the workflow engine's silent
``_load_project_integration`` consume it so the schema guard and parse
logic cannot drift between them.
""" """
path = project_root / INTEGRATION_JSON path = project_root / INTEGRATION_JSON
# Avoid Path.exists() / Path.is_file() as a pre-check: both return False # Avoid Path.exists() / Path.is_file() as a pre-check: both return False
@@ -70,9 +67,41 @@ def try_read_integration_json(
and schema > INTEGRATION_STATE_SCHEMA and schema > INTEGRATION_STATE_SCHEMA
): ):
return None, IntegrationReadError(kind="schema_too_new", schema=schema) return None, IntegrationReadError(kind="schema_too_new", schema=schema)
return data, None
def try_read_integration_json(
project_root: Path,
) -> tuple[dict[str, Any] | None, IntegrationReadError | None]:
"""Parse ``.specify/integration.json`` without raising.
Returns ``(normalized_state, None)`` on success, ``(None, None)`` when the
file does not exist, or ``(None, error)`` for any parse / validation
failure. This helper delegates file I/O and raw JSON validation to
``_read_integration_json_data`` so callers that need raw state can share
the same low-level reader instead of duplicating parse logic.
"""
data, error = _read_integration_json_data(project_root)
if data is None:
return None, error
return normalize_integration_state(data), None return normalize_integration_state(data), None
def try_read_integration_json_with_raw(
project_root: Path,
) -> tuple[dict[str, Any] | None, dict[str, Any] | None, IntegrationReadError | None]:
"""Parse ``integration.json`` and return normalized plus raw state.
Returns ``(normalized_state, raw_state, None)`` when the file is readable,
``(None, None, None)`` when it is absent, and ``(None, None, error)`` for
parse, schema, encoding, or filesystem failures.
"""
data, error = _read_integration_json_data(project_root)
if data is None:
return None, None, error
return normalize_integration_state(data), data, None
def clean_integration_key(key: Any) -> str | None: def clean_integration_key(key: Any) -> str | None:
"""Return a stripped integration key, or None for empty/non-string values.""" """Return a stripped integration key, or None for empty/non-string values."""
if not isinstance(key, str) or not key.strip(): if not isinstance(key, str) or not key.strip():

View File

@@ -0,0 +1,663 @@
"""Read-only status reporting for project integration state."""
from __future__ import annotations
import hashlib
import re
import stat
from pathlib import Path
from typing import Any
from .integration_state import (
INTEGRATION_JSON,
INTEGRATION_STATE_SCHEMA,
IntegrationReadError,
default_integration_key,
installed_integration_keys,
try_read_integration_json_with_raw,
)
from .integrations import INTEGRATION_REGISTRY
from .integrations.manifest import IntegrationManifest
_MANIFEST_READ_ERRORS = (ValueError, OSError)
_MANIFEST_KEY_RE = re.compile(r"^[A-Za-z0-9._-]+$")
_WINDOWS_RESERVED_MANIFEST_BASENAMES = {
"CON",
"PRN",
"AUX",
"NUL",
*(f"COM{i}" for i in range(1, 10)),
*(f"LPT{i}" for i in range(1, 10)),
}
_SHARED_MANIFEST_KEY = "speckit"
def _finding(
severity: str,
code: str,
message: str,
*,
integration: str | None = None,
path: str | None = None,
suggestion: str | None = None,
) -> dict[str, str]:
item = {
"severity": severity,
"code": code,
"message": message,
}
if integration:
item["integration"] = integration
if path:
item["path"] = path
if suggestion:
item["suggestion"] = suggestion
return item
def _status(findings: list[dict[str, str]]) -> str:
if any(item["severity"] == "error" for item in findings):
return "error"
if findings:
return "warning"
return "ok"
def _with_error_detail(message: str, error: IntegrationReadError) -> str:
if error.detail:
return f"{message} Detail: {error.detail}"
return message
def _integration_state_error_message(error: IntegrationReadError) -> str:
if error.kind == "decode":
return _with_error_detail(
f"{INTEGRATION_JSON} contains invalid JSON or is not valid UTF-8.",
error,
)
if error.kind == "os":
return _with_error_detail(f"Could not read {INTEGRATION_JSON}.", error)
if error.kind == "not_object":
return f"{INTEGRATION_JSON} must contain a JSON object, got {error.detail}."
if error.kind == "schema_too_new":
return (
f"{INTEGRATION_JSON} uses integration state schema {error.schema}, "
f"which is newer than this CLI supports; supported schema: {INTEGRATION_STATE_SCHEMA}."
)
return f"Could not inspect {INTEGRATION_JSON}."
def _sha256_file(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(8192), b""):
h.update(chunk)
return h.hexdigest()
def _strip_extended_length_prefix(path: Path) -> Path:
"""Drop the Windows ``\\\\?\\`` extended-length prefix for path comparison.
``os.readlink`` and ``Path.resolve`` can return extended-length paths on
Windows (e.g. ``\\\\?\\C:\\proj``). Comparing such a path against a plain
``C:\\proj`` root via :meth:`Path.relative_to` would spuriously fail, so we
normalise both sides through this helper before containment checks.
"""
raw = str(path)
if raw.startswith("\\\\?\\UNC\\"):
return Path("\\\\" + raw[len("\\\\?\\UNC\\"):])
if raw.startswith("\\\\?\\"):
return Path(raw[len("\\\\?\\"):])
return path
def _is_within_project(project_root_resolved: Path, candidate: Path) -> bool:
"""Return ``True`` when *candidate* stays within *project_root_resolved*.
Both paths are stripped of any Windows extended-length prefix first so that
a target produced by ``os.readlink`` (which may be ``\\\\?\\``-prefixed) is
still recognised as living inside an unprefixed project root.
"""
try:
_strip_extended_length_prefix(candidate).relative_to(
_strip_extended_length_prefix(project_root_resolved)
)
except ValueError:
return False
return True
def _safe_manifest_file(
project_root: Path,
project_root_resolved: Path,
rel: str,
*,
project_root_is_resolved: bool = True,
) -> Path | None:
rel_path = Path(rel)
if rel_path.is_absolute() or ".." in rel_path.parts:
return None
candidate = project_root / rel_path
if not project_root_is_resolved:
walk = project_root
for part in rel_path.parts[:-1]:
walk = walk / part
try:
if walk.is_symlink():
return None
except OSError:
return None
try:
candidate_parent = (
candidate.parent.resolve(strict=False)
if project_root_is_resolved
else candidate.parent.absolute()
)
except (OSError, RuntimeError):
return None
if not _is_within_project(project_root_resolved, candidate_parent):
return None
return candidate
def _tracked_symlink_manifest_status(
path: Path,
project_root_resolved: Path,
*,
project_root_is_resolved: bool = True,
) -> str:
"""Classify a tracked symlink without following it outside the project.
Manifests store content hashes for regular files, so an existing in-project
symlink is still reported as modified. Escaping targets are invalid, and
dangling in-project targets are missing.
"""
try:
target = path.readlink()
except OSError:
return "modified"
target_path = target if target.is_absolute() else path.parent / target
try:
contained_parent = (
target_path.parent.resolve(strict=False)
if project_root_is_resolved
else target_path.parent.absolute()
)
except (OSError, RuntimeError):
return "invalid"
if not _is_within_project(project_root_resolved, contained_parent):
return "invalid"
try:
target_path.lstat()
except FileNotFoundError:
return "missing"
except OSError:
return "modified"
return "modified"
def _resolve_project_root_for_status(
project_root: Path,
findings: list[dict[str, str]],
) -> tuple[Path, bool]:
try:
return project_root.resolve(), True
except (OSError, RuntimeError) as exc:
findings.append(
_finding(
"warning",
"project-root-unresolved",
f"Could not fully resolve project root: {exc}",
suggestion="Check project path permissions and symlinks before relying on manifest path checks.",
)
)
return project_root.absolute(), False
def _is_safe_manifest_key(key: str) -> bool:
if key in {"", ".", ".."}:
return False
if key.endswith("."):
return False
if _MANIFEST_KEY_RE.fullmatch(key) is None:
return False
if key.split(".", 1)[0].upper() in _WINDOWS_RESERVED_MANIFEST_BASENAMES:
return False
if "/" in key or "\\" in key:
return False
key_path = Path(key)
return not key_path.is_absolute() and key_path.name == key
def _manifest_file_status(
manifest: IntegrationManifest,
project_root_resolved: Path,
*,
project_root_is_resolved: bool = True,
) -> tuple[list[str], list[str], list[str], list[str]]:
missing: list[str] = []
modified: list[str] = []
invalid: list[str] = []
valid: list[str] = []
for rel, expected_hash in manifest.files.items():
path = _safe_manifest_file(
manifest.project_root,
project_root_resolved,
rel,
project_root_is_resolved=project_root_is_resolved,
)
if path is None:
invalid.append(rel)
continue
try:
path_stat = path.lstat()
except FileNotFoundError:
valid.append(rel)
missing.append(rel)
continue
except OSError:
valid.append(rel)
modified.append(rel)
continue
is_symlink = stat.S_ISLNK(path_stat.st_mode)
if not is_symlink:
try:
is_symlink = path.is_symlink()
except OSError:
is_symlink = False
if is_symlink:
symlink_status = _tracked_symlink_manifest_status(
path,
project_root_resolved,
project_root_is_resolved=project_root_is_resolved,
)
if symlink_status == "invalid":
invalid.append(rel)
continue
valid.append(rel)
if symlink_status == "missing":
missing.append(rel)
continue
modified.append(rel)
continue
valid.append(rel)
if not stat.S_ISREG(path_stat.st_mode):
modified.append(rel)
continue
try:
if _sha256_file(path) != expected_hash:
modified.append(rel)
except OSError:
modified.append(rel)
return missing, modified, invalid, valid
def _default_not_installed_from_raw_state(raw_state: dict[str, Any]) -> str | None:
if not isinstance(raw_state.get("installed_integrations"), list):
return None
raw_default = default_integration_key(raw_state)
raw_installed = installed_integration_keys(raw_state)
if raw_default and raw_default not in raw_installed:
return raw_default
return None
def _manifest_summary(
manifest_path: Path,
project_root: Path,
*,
readable: bool,
tracked_files: int = 0,
missing_files: list[str] | None = None,
modified_files: list[str] | None = None,
invalid_files: list[str] | None = None,
) -> dict[str, Any]:
return {
"manifest": manifest_path.relative_to(project_root).as_posix(),
"readable": readable,
"tracked_files": tracked_files,
"missing_files": missing_files or [],
"modified_files": modified_files or [],
"invalid_files": invalid_files or [],
}
def _manifest_owner(key: str) -> str:
if key == _SHARED_MANIFEST_KEY:
return "shared Spec Kit infrastructure"
return f"integration '{key}'"
def _manifest_suggestion(key: str, default_key: str | None) -> str:
if key == _SHARED_MANIFEST_KEY:
if default_key and default_key in INTEGRATION_REGISTRY:
return f"Run `specify integration upgrade {default_key}` to regenerate shared managed files."
return (
"Run `specify init --here --force --integration <key>` to regenerate "
"shared managed files."
)
if key not in INTEGRATION_REGISTRY:
return (
"Upgrade Spec Kit, reinstall with a supported CLI version, "
f"or remove the stale integration entry from {INTEGRATION_JSON}."
)
return f"Run `specify integration upgrade {key}` or reinstall the integration."
def build_integration_status_report(project_root: Path) -> dict[str, Any]:
"""Return a machine-readable integration status report for *project_root*."""
findings: list[dict[str, str]] = []
project_root_resolved, project_root_is_resolved = _resolve_project_root_for_status(
project_root,
findings,
)
state, raw_state, error = try_read_integration_json_with_raw(project_root)
if error is not None:
findings.append(
_finding(
"error",
"integration-state-unreadable",
_integration_state_error_message(error),
path=INTEGRATION_JSON,
suggestion=f"Fix or delete {INTEGRATION_JSON}, then retry.",
)
)
return _build_report(None, [], findings, {}, None)
if state is None:
findings.append(
_finding(
"error",
"integration-state-missing",
f"{INTEGRATION_JSON} is missing.",
path=INTEGRATION_JSON,
suggestion="Run `specify integration install <key>` to install an integration.",
)
)
return _build_report(None, [], findings, {}, None)
assert raw_state is not None
raw_default_key = default_integration_key(raw_state)
raw_installed_value = raw_state.get("installed_integrations")
raw_installed_is_list = isinstance(raw_installed_value, list)
raw_installed_keys = (
installed_integration_keys(raw_state)
if raw_installed_is_list
else []
)
default_key = raw_default_key or default_integration_key(state)
installed_keys = installed_integration_keys(state)
raw_default_not_installed = _default_not_installed_from_raw_state(raw_state)
if raw_installed_is_list and raw_default_not_installed and raw_installed_keys:
check_installed_keys = raw_installed_keys
else:
check_installed_keys = installed_keys
recorded_installed_keys = raw_installed_keys
if "installed_integrations" in raw_state and not raw_installed_is_list:
findings.append(
_finding(
"warning",
"installed-integrations-invalid",
(
"installed_integrations must be a list, "
f"got {type(raw_installed_value).__name__}."
),
path=INTEGRATION_JSON,
suggestion=f"Fix {INTEGRATION_JSON}, then retry.",
)
)
if not installed_keys:
findings.append(
_finding(
"warning",
"no-installed-integrations",
"No installed integrations are recorded.",
suggestion="Run `specify integration install <key>` to install one.",
)
)
if raw_installed_keys and raw_default_key is None:
default_key = None
findings.append(
_finding(
"error",
"default-integration-missing",
"No default integration is recorded.",
suggestion="Run `specify integration use <key>` after choosing an installed integration.",
)
)
if raw_default_not_installed:
findings.append(
_finding(
"error",
"default-integration-not-installed",
(
f"Default integration '{raw_default_not_installed}' is not listed "
"in installed_integrations."
),
integration=raw_default_not_installed,
suggestion="Run `specify integration use <key>` for an installed integration, or reinstall the default integration.",
)
)
known_installed = [key for key in check_installed_keys if key in INTEGRATION_REGISTRY]
unknown_installed: list[str] = []
for key in check_installed_keys:
if key not in INTEGRATION_REGISTRY:
unknown_installed.append(key)
findings.append(
_finding(
"error",
"unknown-integration",
f"Integration '{key}' is installed but is not known to this CLI.",
integration=key,
suggestion=(
"Upgrade Spec Kit, reinstall with a supported CLI version, "
f"or remove the stale integration entry from {INTEGRATION_JSON}."
),
)
)
unsafe = [
key for key in known_installed
if not getattr(INTEGRATION_REGISTRY[key], "multi_install_safe", False)
]
if len(check_installed_keys) > 1:
unsafe.extend(unknown_installed)
if len(check_installed_keys) > 1 and unsafe:
findings.append(
_finding(
"error",
"unsafe-multi-install",
(
"Installed integrations are not all declared multi-install safe: "
+ ", ".join(sorted(unsafe))
),
suggestion=(
"Use `specify integration use <key>` to change defaults, "
"or `specify integration switch <key>` only when replacing integrations."
),
)
)
manifest_files_by_path: dict[str, list[str]] = {}
manifest_summaries: dict[str, dict[str, Any]] = {}
attempted_manifest_keys: list[str] = []
manifest_keys = list(check_installed_keys)
if _SHARED_MANIFEST_KEY not in manifest_keys:
manifest_keys.append(_SHARED_MANIFEST_KEY)
for key in manifest_keys:
owner = _manifest_owner(key)
if not _is_safe_manifest_key(key):
findings.append(
_finding(
"error",
"integration-key-invalid",
f"Integration key {key!r} cannot be used as a manifest filename.",
integration=key,
path=INTEGRATION_JSON,
suggestion=f"Fix {INTEGRATION_JSON}, then reinstall the integration.",
)
)
continue
attempted_manifest_keys.append(key)
manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json"
try:
manifest = IntegrationManifest.load(
key,
project_root_resolved,
resolve_project_root=False,
)
except FileNotFoundError:
findings.append(
_finding(
"error",
"manifest-missing",
f"Manifest for {owner} is missing.",
integration=key,
path=manifest_path.relative_to(project_root).as_posix(),
suggestion=_manifest_suggestion(key, default_key),
)
)
manifest_summaries[key] = _manifest_summary(
manifest_path,
project_root,
readable=False,
)
continue
except _MANIFEST_READ_ERRORS as exc:
manifest_summaries[key] = _manifest_summary(
manifest_path,
project_root,
readable=False,
)
findings.append(
_finding(
"error",
"manifest-unreadable",
f"Manifest for {owner} is unreadable: {exc}",
integration=key,
path=manifest_path.relative_to(project_root).as_posix(),
suggestion=_manifest_suggestion(key, default_key),
)
)
continue
missing, modified, invalid, valid_files = _manifest_file_status(
manifest,
project_root_resolved,
project_root_is_resolved=project_root_is_resolved,
)
manifest_summaries[key] = _manifest_summary(
manifest_path,
project_root,
readable=True,
tracked_files=len(manifest.files),
missing_files=missing,
modified_files=modified,
invalid_files=invalid,
)
for rel in valid_files:
manifest_files_by_path.setdefault(rel, []).append(key)
if invalid:
findings.append(
_finding(
"error",
"manifest-paths-invalid",
f"{len(invalid)} unsafe manifest path(s) are recorded for {owner}.",
integration=key,
path=manifest_path.relative_to(project_root).as_posix(),
suggestion=_manifest_suggestion(key, default_key),
)
)
if missing:
findings.append(
_finding(
"error",
"managed-files-missing",
f"{len(missing)} managed file(s) are missing for {owner}.",
integration=key,
suggestion=_manifest_suggestion(key, default_key),
)
)
if modified:
findings.append(
_finding(
"warning",
"managed-files-modified",
f"{len(modified)} managed file(s) were modified for {owner}.",
integration=key,
suggestion="Review the changes before running `specify integration upgrade --force`.",
)
)
for rel, keys in sorted(manifest_files_by_path.items()):
if len(keys) > 1:
findings.append(
_finding(
"warning",
"managed-file-collision",
f"Managed file '{rel}' is tracked by multiple integrations: {', '.join(sorted(keys))}.",
path=rel,
suggestion="Review the manifests before uninstalling or upgrading these integrations.",
)
)
if not raw_installed_is_list or not raw_installed_keys:
multi_install_safe = None
else:
multi_install_safe = not (len(check_installed_keys) > 1 and unsafe)
return _build_report(
default_key,
installed_keys,
findings,
manifest_summaries,
multi_install_safe,
manifest_checked_keys=attempted_manifest_keys,
recorded_installed_keys=recorded_installed_keys,
)
def _build_report(
default_key: str | None,
installed_keys: list[str],
findings: list[dict[str, str]],
manifests: dict[str, dict[str, Any]],
multi_install_safe: bool | None,
*,
manifest_checked_keys: list[str] | None = None,
recorded_installed_keys: list[str] | None = None,
) -> dict[str, Any]:
missing_count = sum(len(item.get("missing_files", [])) for item in manifests.values())
modified_count = sum(len(item.get("modified_files", [])) for item in manifests.values())
invalid_count = sum(len(item.get("invalid_files", [])) for item in manifests.values())
unchecked_count = sum(1 for item in manifests.values() if not item.get("readable", True))
return {
"status": _status(findings),
"default_integration": default_key,
"installed_integrations": installed_keys,
"recorded_installed_integrations": (
installed_keys if recorded_installed_keys is None else recorded_installed_keys
),
"manifest_checked_integrations": (
installed_keys if manifest_checked_keys is None else manifest_checked_keys
),
"multi_install_safe": multi_install_safe,
"shared_templates_target_alignment": default_key,
"missing_managed_files": missing_count,
"modified_managed_files": modified_count,
"invalid_manifest_paths": invalid_count,
"unchecked_manifests": unchecked_count,
"manifests": manifests,
"findings": findings,
}

View File

@@ -1,10 +1,12 @@
"""specify integration list/use/search/info + catalog list/add/remove command handlers.""" """specify integration list/status/use/search/info + catalog list/add/remove command handlers."""
from __future__ import annotations from __future__ import annotations
import json
import os import os
from typing import Optional from typing import Any, Optional
import typer import typer
from rich.markup import escape as _rich_escape
from rich.table import Table from rich.table import Table
from .._console import console from .._console import console
@@ -120,6 +122,86 @@ def integration_list(
console.print("Install one with: [cyan]specify integration install <key>[/cyan]") console.print("Install one with: [cyan]specify integration install <key>[/cyan]")
def _print_integration_status_report(report: dict[str, Any]) -> None:
status = report["status"]
status_label = {
"ok": "[green]OK[/green]",
"warning": "[yellow]WARNING[/yellow]",
"error": "[red]ERROR[/red]",
}.get(str(status), str(status).upper())
installed = report.get("installed_integrations") or []
installed_display = ", ".join(_rich_escape(str(item)) for item in installed)
console.print(f"Integration status: {status_label}")
console.print(
f"Default integration: {_rich_escape(str(report.get('default_integration') or 'none'))}"
)
console.print(f"Installed integrations: {installed_display if installed else 'none'}")
multi_install_safe = report.get("multi_install_safe")
if multi_install_safe is None:
multi_install_safe_display = "unknown"
else:
multi_install_safe_display = "yes" if multi_install_safe else "no"
console.print(f"Multi-install safe: {multi_install_safe_display}")
console.print(
f"Shared templates target alignment: "
f"{_rich_escape(str(report.get('shared_templates_target_alignment') or 'none'))}"
)
console.print(f"Modified managed files: {report.get('modified_managed_files', 0)}")
console.print(f"Missing managed files: {report.get('missing_managed_files', 0)}")
console.print(f"Invalid manifest paths: {report.get('invalid_manifest_paths', 0)}")
console.print(f"Unchecked manifests: {report.get('unchecked_manifests', 0)}")
findings = report.get("findings") or []
if not findings:
return
console.print()
console.print("[bold]Findings:[/bold]")
for item in findings:
severity = item.get("severity", "")
severity_label = {
"error": "[red]error[/red]",
"warning": "[yellow]warning[/yellow]",
}.get(severity, severity)
prefix = f"- {severity_label} {_rich_escape(str(item.get('code', '')))}"
if item.get("integration"):
prefix += f" ({_rich_escape(str(item['integration']))})"
console.print(
f"{prefix}: {_rich_escape(str(item.get('message', '')))}",
soft_wrap=True,
)
if item.get("suggestion"):
console.print(
f" Suggestion: {_rich_escape(str(item['suggestion']))}",
soft_wrap=True,
)
@integration_app.command("status")
def integration_status(
json_output: bool = typer.Option(
False,
"--json",
help="Emit machine-readable integration status.",
),
):
"""Report the current project's integration status without changing files."""
from .. import _require_specify_project
from ..integration_status import build_integration_status_report
project_root = _require_specify_project()
report = build_integration_status_report(project_root)
if json_output:
typer.echo(json.dumps(report, indent=2))
else:
_print_integration_status_report(report)
if report["status"] == "error":
raise typer.Exit(1)
@integration_app.command("use") @integration_app.command("use")
def integration_use( def integration_use(
key: str = typer.Argument(help="Installed integration key to make the default"), key: str = typer.Argument(help="Installed integration key to make the default"),

View File

@@ -108,11 +108,23 @@ class IntegrationManifest:
key: Integration identifier (e.g. ``"copilot"``). key: Integration identifier (e.g. ``"copilot"``).
project_root: Absolute path to the project directory. project_root: Absolute path to the project directory.
version: CLI version string recorded in the manifest. version: CLI version string recorded in the manifest.
resolve_project_root: Resolve ``project_root`` before using it.
""" """
def __init__(self, key: str, project_root: Path, version: str = "") -> None: def __init__(
self,
key: str,
project_root: Path,
version: str = "",
*,
resolve_project_root: bool = True,
) -> None:
self.key = key self.key = key
self.project_root = project_root.resolve() self.project_root = (
project_root.resolve()
if resolve_project_root
else project_root.absolute()
)
self.version = version self.version = version
self._files: dict[str, str] = {} # rel_path → sha256 hex self._files: dict[str, str] = {} # rel_path → sha256 hex
self._recovered_files: set[str] = set() self._recovered_files: set[str] = set()
@@ -387,12 +399,18 @@ class IntegrationManifest:
return path return path
@classmethod @classmethod
def load(cls, key: str, project_root: Path) -> IntegrationManifest: def load(
cls,
key: str,
project_root: Path,
*,
resolve_project_root: bool = True,
) -> IntegrationManifest:
"""Load an existing manifest from disk. """Load an existing manifest from disk.
Raises ``FileNotFoundError`` if the manifest does not exist. Raises ``FileNotFoundError`` if the manifest does not exist.
""" """
inst = cls(key, project_root) inst = cls(key, project_root, resolve_project_root=resolve_project_root)
path = inst.manifest_path path = inst.manifest_path
try: try:
data = json.loads(path.read_text(encoding="utf-8")) data = json.loads(path.read_text(encoding="utf-8"))

View File

@@ -1892,6 +1892,48 @@ class PresetCatalog:
download_url, self._open_url, timeout=timeout download_url, self._open_url, timeout=timeout
) )
def _validate_catalog_payload(self, catalog_data: Any, url: str) -> None:
"""Validate a parsed preset-catalog payload's shape.
Applied to both network-fetched and cache-loaded payloads so a
once-poisoned cache (older spec-kit version, manual edit, upstream
served a bad payload before the network-side guards were added)
cannot re-crash ``_get_merged_packs`` on subsequent calls.
Checking only key presence would let a payload like
``{"presets": []}`` or ``{"presets": null}`` slip through here and
then crash with ``AttributeError: 'list' object has no attribute
'items'`` deep inside ``_get_merged_packs``. The sibling
integration catalog reader already guards both the root object and
the nested mapping (see ``integrations/catalog.py``); the preset
catalog must stay consistent so a malformed payload surfaces as
the user-facing ``Invalid preset catalog format`` error instead of
a raw Python traceback.
Args:
catalog_data: Parsed JSON payload from the catalog source.
url: Source URL — used in the error message so the user can
tell which catalog in a multi-catalog stack is malformed.
Raises:
PresetError: If the payload's shape is invalid.
"""
if not isinstance(catalog_data, dict):
raise PresetError(
f"Invalid preset catalog format from {url}: "
"expected a JSON object"
)
if (
"schema_version" not in catalog_data
or "presets" not in catalog_data
):
raise PresetError(f"Invalid preset catalog format from {url}")
if not isinstance(catalog_data.get("presets"), dict):
raise PresetError(
f"Invalid preset catalog format from {url}: "
"'presets' must be a JSON object"
)
def _load_catalog_config(self, config_path: Path) -> Optional[List[PresetCatalogEntry]]: def _load_catalog_config(self, config_path: Path) -> Optional[List[PresetCatalogEntry]]:
"""Load catalog stack configuration from a YAML file. """Load catalog stack configuration from a YAML file.
@@ -2053,7 +2095,7 @@ class PresetCatalog:
if not cache_file.exists() or not metadata_file.exists(): if not cache_file.exists() or not metadata_file.exists():
return False return False
try: try:
metadata = json.loads(metadata_file.read_text()) metadata = json.loads(metadata_file.read_text(encoding="utf-8"))
cached_at = datetime.fromisoformat(metadata.get("cached_at", "")) cached_at = datetime.fromisoformat(metadata.get("cached_at", ""))
if cached_at.tzinfo is None: if cached_at.tzinfo is None:
cached_at = cached_at.replace(tzinfo=timezone.utc) cached_at = cached_at.replace(tzinfo=timezone.utc)
@@ -2061,7 +2103,23 @@ class PresetCatalog:
datetime.now(timezone.utc) - cached_at datetime.now(timezone.utc) - cached_at
).total_seconds() ).total_seconds()
return age_seconds < self.CACHE_DURATION return age_seconds < self.CACHE_DURATION
except (json.JSONDecodeError, ValueError, KeyError, TypeError): except (
json.JSONDecodeError,
OSError,
UnicodeError,
ValueError,
KeyError,
TypeError,
AttributeError,
):
# Cache validity is best-effort: invalid/missing fields, an
# unreadable metadata file (permissions / disk), a wrongly
# encoded one (written by a tool using the system locale
# codec), or a metadata payload that parses to a non-mapping
# like ``[]`` or ``"oops"`` (so ``metadata.get(...)`` raises
# ``AttributeError``) all degrade to "cache invalid" so the
# caller falls through to a network refetch instead of
# crashing.
return False return False
def _fetch_single_catalog(self, entry: PresetCatalogEntry, force_refresh: bool = False) -> Dict[str, Any]: def _fetch_single_catalog(self, entry: PresetCatalogEntry, force_refresh: bool = False) -> Dict[str, Any]:
@@ -2079,29 +2137,55 @@ class PresetCatalog:
""" """
cache_file, metadata_file = self._get_cache_paths(entry.url) cache_file, metadata_file = self._get_cache_paths(entry.url)
# Use cache if valid. A previously-cached payload must clear the
# same shape checks as a freshly-fetched one — otherwise a once-
# poisoned cache would re-crash on every invocation despite the
# cache being "valid" by age. If validation fails on the cached
# read, fall through to the network fetch path so the cache gets
# refreshed.
if not force_refresh and self._is_url_cache_valid(entry.url): if not force_refresh and self._is_url_cache_valid(entry.url):
try: try:
return json.loads(cache_file.read_text()) cached_data = json.loads(cache_file.read_text(encoding="utf-8"))
except json.JSONDecodeError: self._validate_catalog_payload(cached_data, entry.url)
return cached_data
except (json.JSONDecodeError, OSError, UnicodeError, PresetError):
# Cache is best-effort: a JSON-decode failure, an OS-level
# read failure (permissions / disk / handle limit), or a
# text-encoding failure on a cache file written by an
# older client all fall through to the network fetch path.
# Only the network failure is surfaced to the caller.
pass pass
try: try:
with self._open_url(entry.url, timeout=10) as response: with self._open_url(entry.url, timeout=10) as response:
catalog_data = json.loads(response.read()) catalog_data = json.loads(response.read())
if ( self._validate_catalog_payload(catalog_data, entry.url)
"schema_version" not in catalog_data
or "presets" not in catalog_data
):
raise PresetError("Invalid preset catalog format")
self.cache_dir.mkdir(parents=True, exist_ok=True) # Both files are written explicitly as UTF-8 to match the
cache_file.write_text(json.dumps(catalog_data, indent=2)) # ``read_text(encoding="utf-8")`` on the read side and the
metadata = { # ``integrations/catalog.py`` precedent. Without this,
"cached_at": datetime.now(timezone.utc).isoformat(), # platforms whose default encoding isn't UTF-8 would write
"catalog_url": entry.url, # locale-encoded bytes the read path can't decode, forcing an
} # unnecessary refetch on every invocation. The write itself
metadata_file.write_text(json.dumps(metadata, indent=2)) # is best-effort like the read side: an unwritable cache dir
# (read-only checkout, permissions) must not be re-raised as
# a ``PresetError`` for a payload that was already fetched
# and validated.
try:
self.cache_dir.mkdir(parents=True, exist_ok=True)
cache_file.write_text(
json.dumps(catalog_data, indent=2), encoding="utf-8"
)
metadata = {
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": entry.url,
}
metadata_file.write_text(
json.dumps(metadata, indent=2), encoding="utf-8"
)
except OSError:
pass # Cache is best-effort; proceed with fetched data
return catalog_data return catalog_data
@@ -2127,6 +2211,17 @@ class PresetCatalog:
try: try:
data = self._fetch_single_catalog(entry, force_refresh) data = self._fetch_single_catalog(entry, force_refresh)
for pack_id, pack_data in data.get("presets", {}).items(): for pack_id, pack_data in data.get("presets", {}).items():
# Per-entry guard: ``_fetch_single_catalog`` already
# validates that ``data["presets"]`` is a mapping, but it
# does not (and should not) validate every entry shape
# there — one malformed entry shouldn't poison an
# otherwise valid catalog. Skip non-mapping entries here
# so a payload like ``{"presets": {"foo": [], "bar":
# {...}}}`` still merges the valid entries without
# crashing on ``**pack_data``. Mirrors
# ``integrations/catalog.py:245``.
if not isinstance(pack_data, dict):
continue
pack_data_with_catalog = {**pack_data, "_catalog_name": entry.name, "_install_allowed": entry.install_allowed} pack_data_with_catalog = {**pack_data, "_catalog_name": entry.name, "_install_allowed": entry.install_allowed}
merged[pack_id] = pack_data_with_catalog merged[pack_id] = pack_data_with_catalog
except PresetError: except PresetError:
@@ -2137,6 +2232,12 @@ class PresetCatalog:
def is_cache_valid(self) -> bool: def is_cache_valid(self) -> bool:
"""Check if cached catalog is still valid. """Check if cached catalog is still valid.
Returns ``False`` for any read/decoding failure on the metadata
file (missing fields, malformed JSON, permissions / disk errors,
wrong text encoding) so callers fall through to a network refetch
instead of crashing. Treating cache validity as best-effort
matches the contract used by ``_is_url_cache_valid`` above.
Returns: Returns:
True if cache exists and is within cache duration True if cache exists and is within cache duration
""" """
@@ -2144,7 +2245,9 @@ class PresetCatalog:
return False return False
try: try:
metadata = json.loads(self.cache_metadata_file.read_text()) metadata = json.loads(
self.cache_metadata_file.read_text(encoding="utf-8")
)
cached_at = datetime.fromisoformat(metadata.get("cached_at", "")) cached_at = datetime.fromisoformat(metadata.get("cached_at", ""))
if cached_at.tzinfo is None: if cached_at.tzinfo is None:
cached_at = cached_at.replace(tzinfo=timezone.utc) cached_at = cached_at.replace(tzinfo=timezone.utc)
@@ -2152,7 +2255,20 @@ class PresetCatalog:
datetime.now(timezone.utc) - cached_at datetime.now(timezone.utc) - cached_at
).total_seconds() ).total_seconds()
return age_seconds < self.CACHE_DURATION return age_seconds < self.CACHE_DURATION
except (json.JSONDecodeError, ValueError, KeyError, TypeError): except (
json.JSONDecodeError,
OSError,
UnicodeError,
ValueError,
KeyError,
TypeError,
AttributeError,
):
# ``AttributeError`` covers the case where the metadata file
# parses to a non-mapping (``[]``, ``"oops"``, ``42``) so
# ``metadata.get(...)`` would otherwise crash. All decode /
# shape failures degrade to "cache invalid" so the caller
# falls through to a network refetch.
return False return False
def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]: def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]:
@@ -2169,35 +2285,61 @@ class PresetCatalog:
""" """
catalog_url = self.get_catalog_url() catalog_url = self.get_catalog_url()
# Match the ``_fetch_single_catalog`` cache contract: a poisoned
# or unreadable cache silently falls through to a network refetch
# rather than crashing the caller. ``_validate_catalog_payload``
# is reused here so a cache written by an older client
# (pre-validation) is rejected and refreshed instead of returning
# the stale malformed payload.
if not force_refresh and self.is_cache_valid(): if not force_refresh and self.is_cache_valid():
try: try:
metadata = json.loads(self.cache_metadata_file.read_text()) metadata = json.loads(
self.cache_metadata_file.read_text(encoding="utf-8")
)
if metadata.get("catalog_url") == catalog_url: if metadata.get("catalog_url") == catalog_url:
return json.loads(self.cache_file.read_text()) cached_data = json.loads(
except (json.JSONDecodeError, OSError): self.cache_file.read_text(encoding="utf-8")
# Cache is corrupt or unreadable; fall through to network fetch )
self._validate_catalog_payload(cached_data, catalog_url)
return cached_data
except (json.JSONDecodeError, OSError, UnicodeError, PresetError):
# Cache is corrupt, unreadable, or fails the shape check;
# fall through to network fetch.
pass pass
try: try:
with self._open_url(catalog_url, timeout=10) as response: with self._open_url(catalog_url, timeout=10) as response:
catalog_data = json.loads(response.read()) catalog_data = json.loads(response.read())
if ( # Validate catalog structure. Reuses the same helper as
"schema_version" not in catalog_data # ``_fetch_single_catalog`` so all three branches (root type,
or "presets" not in catalog_data # missing keys, nested-mapping type) stay consistent.
): self._validate_catalog_payload(catalog_data, catalog_url)
raise PresetError("Invalid preset catalog format")
self.cache_dir.mkdir(parents=True, exist_ok=True) # Save to cache. Explicit UTF-8 on both writes mirrors the
self.cache_file.write_text(json.dumps(catalog_data, indent=2)) # ``read_text(encoding="utf-8")`` on the read side and the
# ``integrations/catalog.py`` precedent — otherwise platforms
# whose default encoding isn't UTF-8 would write
# locale-encoded bytes the read path can't decode, forcing an
# unnecessary refetch on every invocation. Like the read
# side, the write is best-effort: an unwritable cache dir
# must not be re-raised as a ``PresetError`` for a payload
# that was already fetched and validated.
try:
self.cache_dir.mkdir(parents=True, exist_ok=True)
self.cache_file.write_text(
json.dumps(catalog_data, indent=2), encoding="utf-8"
)
metadata = { metadata = {
"cached_at": datetime.now(timezone.utc).isoformat(), "cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": catalog_url, "catalog_url": catalog_url,
} }
self.cache_metadata_file.write_text( self.cache_metadata_file.write_text(
json.dumps(metadata, indent=2) json.dumps(metadata, indent=2), encoding="utf-8"
) )
except OSError:
pass # Cache is best-effort; proceed with fetched data
return catalog_data return catalog_data

View File

@@ -313,6 +313,8 @@ def install_shared_infra(
expected = prior_hashes.get(rel) expected = prior_hashes.get(rel)
if not expected or not dst.is_file() or dst.is_symlink(): if not expected or not dst.is_file() or dst.is_symlink():
return False return False
if manifest.is_recovered(rel):
return False
try: try:
return _sha256(dst) == expected return _sha256(dst) == expected
except OSError: except OSError:

View File

@@ -83,11 +83,12 @@ Given that feature description, do this:
**Resolution order for `SPECIFY_FEATURE_DIRECTORY`**: **Resolution order for `SPECIFY_FEATURE_DIRECTORY`**:
1. If the user explicitly provided `SPECIFY_FEATURE_DIRECTORY` (e.g., via environment variable, argument, or configuration), use it as-is 1. If the user explicitly provided `SPECIFY_FEATURE_DIRECTORY` (e.g., via environment variable, argument, or configuration), use it as-is
2. Otherwise, auto-generate it under `specs/`: 2. Otherwise, auto-generate it under `specs/`:
- Check `.specify/init-options.json` for `branch_numbering` - Check `.specify/init-options.json` for `feature_numbering` (preferred) or `branch_numbering` (deprecated, migration only — will be removed in a future release)
- If `"timestamp"`: prefix is `YYYYMMDD-HHMMSS` (current timestamp) - If `"timestamp"`: prefix is `YYYYMMDD-HHMMSS` (current timestamp)
- If `"sequential"` or absent: prefix is `NNN` (next available 3-digit number after scanning existing directories in `specs/`) - If `"sequential"` or absent: prefix is `NNN` (next available 3-digit number after scanning existing directories in `specs/`)
- Construct the directory name: `<prefix>-<short-name>` (e.g., `003-user-auth` or `20260319-143022-user-auth`) - Construct the directory name: `<prefix>-<short-name>` (e.g., `003-user-auth` or `20260319-143022-user-auth`)
- Set `SPECIFY_FEATURE_DIRECTORY` to `specs/<directory-name>` - Set `SPECIFY_FEATURE_DIRECTORY` to `specs/<directory-name>`
- If `branch_numbering` was used (and `feature_numbering` was absent), emit a one-line warning: "⚠️ `branch_numbering` in init-options.json is deprecated. Rename to `feature_numbering`."
**Create the directory and spec file**: **Create the directory and spec file**:
- `mkdir -p SPECIFY_FEATURE_DIRECTORY` - `mkdir -p SPECIFY_FEATURE_DIRECTORY`

View File

@@ -3,7 +3,7 @@ Tests for the bundled git extension (extensions/git/).
Validates: Validates:
- extension.yml manifest - extension.yml manifest
- Bash scripts (create-new-feature.sh, initialize-repo.sh, auto-commit.sh, git-common.sh) - Bash scripts (create-new-feature-branch.sh, initialize-repo.sh, auto-commit.sh, git-common.sh)
- PowerShell scripts (where pwsh is available) - PowerShell scripts (where pwsh is available)
- Config reading from git-config.yml - Config reading from git-config.yml
- Extension install via ExtensionManager - Extension install via ExtensionManager
@@ -193,11 +193,11 @@ class TestGitExtensionInstall:
manager.install_from_directory(EXT_DIR, "0.5.0", register_commands=False) manager.install_from_directory(EXT_DIR, "0.5.0", register_commands=False)
ext_installed = tmp_path / ".specify" / "extensions" / "git" ext_installed = tmp_path / ".specify" / "extensions" / "git"
assert (ext_installed / "scripts" / "bash" / "create-new-feature.sh").is_file() assert (ext_installed / "scripts" / "bash" / "create-new-feature-branch.sh").is_file()
assert (ext_installed / "scripts" / "bash" / "initialize-repo.sh").is_file() assert (ext_installed / "scripts" / "bash" / "initialize-repo.sh").is_file()
assert (ext_installed / "scripts" / "bash" / "auto-commit.sh").is_file() assert (ext_installed / "scripts" / "bash" / "auto-commit.sh").is_file()
assert (ext_installed / "scripts" / "bash" / "git-common.sh").is_file() assert (ext_installed / "scripts" / "bash" / "git-common.sh").is_file()
assert (ext_installed / "scripts" / "powershell" / "create-new-feature.ps1").is_file() assert (ext_installed / "scripts" / "powershell" / "create-new-feature-branch.ps1").is_file()
assert (ext_installed / "scripts" / "powershell" / "initialize-repo.ps1").is_file() assert (ext_installed / "scripts" / "powershell" / "initialize-repo.ps1").is_file()
assert (ext_installed / "scripts" / "powershell" / "auto-commit.ps1").is_file() assert (ext_installed / "scripts" / "powershell" / "auto-commit.ps1").is_file()
assert (ext_installed / "scripts" / "powershell" / "git-common.ps1").is_file() assert (ext_installed / "scripts" / "powershell" / "git-common.ps1").is_file()
@@ -270,16 +270,16 @@ class TestInitializeRepoPowerShell:
assert result.returncode == 0 assert result.returncode == 0
# ── create-new-feature.sh Tests ────────────────────────────────────────────── # ── create-new-feature-branch.sh Tests ──────────────────────────────────────────────
@requires_bash @requires_bash
class TestCreateFeatureBash: class TestCreateFeatureBash:
def test_creates_branch_sequential(self, tmp_path: Path): def test_creates_branch_sequential(self, tmp_path: Path):
"""Extension create-new-feature.sh creates sequential branch.""" """Extension create-new-feature-branch.sh creates sequential branch."""
project = _setup_project(tmp_path) project = _setup_project(tmp_path)
result = _run_bash( result = _run_bash(
"create-new-feature.sh", project, "create-new-feature-branch.sh", project,
"--json", "--short-name", "user-auth", "Add user authentication", "--json", "--short-name", "user-auth", "Add user authentication",
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
@@ -288,10 +288,10 @@ class TestCreateFeatureBash:
assert data["FEATURE_NUM"] == "001" assert data["FEATURE_NUM"] == "001"
def test_creates_branch_timestamp(self, tmp_path: Path): def test_creates_branch_timestamp(self, tmp_path: Path):
"""Extension create-new-feature.sh creates timestamp branch.""" """Extension create-new-feature-branch.sh creates timestamp branch."""
project = _setup_project(tmp_path) project = _setup_project(tmp_path)
result = _run_bash( result = _run_bash(
"create-new-feature.sh", project, "create-new-feature-branch.sh", project,
"--json", "--timestamp", "--short-name", "feat", "Feature", "--json", "--timestamp", "--short-name", "feat", "Feature",
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
@@ -305,7 +305,7 @@ class TestCreateFeatureBash:
(project / "specs" / "002-second").mkdir(parents=True) (project / "specs" / "002-second").mkdir(parents=True)
result = _run_bash( result = _run_bash(
"create-new-feature.sh", project, "create-new-feature-branch.sh", project,
"--json", "--short-name", "third", "Third feature", "--json", "--short-name", "third", "Third feature",
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
@@ -313,10 +313,10 @@ class TestCreateFeatureBash:
assert data["FEATURE_NUM"] == "003" assert data["FEATURE_NUM"] == "003"
def test_no_git_graceful_degradation(self, tmp_path: Path): def test_no_git_graceful_degradation(self, tmp_path: Path):
"""create-new-feature.sh works without git (outputs branch name, skips branch creation).""" """create-new-feature-branch.sh works without git (outputs branch name, skips branch creation)."""
project = _setup_project(tmp_path, git=False) project = _setup_project(tmp_path, git=False)
result = _run_bash( result = _run_bash(
"create-new-feature.sh", project, "create-new-feature-branch.sh", project,
"--json", "--short-name", "no-git", "No git feature", "--json", "--short-name", "no-git", "No git feature",
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
@@ -329,7 +329,7 @@ class TestCreateFeatureBash:
"""--dry-run computes branch name without creating anything.""" """--dry-run computes branch name without creating anything."""
project = _setup_project(tmp_path) project = _setup_project(tmp_path)
result = _run_bash( result = _run_bash(
"create-new-feature.sh", project, "create-new-feature-branch.sh", project,
"--json", "--dry-run", "--short-name", "dry", "Dry run test", "--json", "--dry-run", "--short-name", "dry", "Dry run test",
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
@@ -341,10 +341,10 @@ class TestCreateFeatureBash:
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available") @pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
class TestCreateFeaturePowerShell: class TestCreateFeaturePowerShell:
def test_creates_branch_sequential(self, tmp_path: Path): def test_creates_branch_sequential(self, tmp_path: Path):
"""Extension create-new-feature.ps1 creates sequential branch.""" """Extension create-new-feature-branch.ps1 creates sequential branch."""
project = _setup_project(tmp_path) project = _setup_project(tmp_path)
result = _run_pwsh( result = _run_pwsh(
"create-new-feature.ps1", project, "create-new-feature-branch.ps1", project,
"-Json", "-ShortName", "user-auth", "Add user authentication", "-Json", "-ShortName", "user-auth", "Add user authentication",
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
@@ -352,10 +352,10 @@ class TestCreateFeaturePowerShell:
assert data["BRANCH_NAME"] == "001-user-auth" assert data["BRANCH_NAME"] == "001-user-auth"
def test_creates_branch_timestamp(self, tmp_path: Path): def test_creates_branch_timestamp(self, tmp_path: Path):
"""Extension create-new-feature.ps1 creates timestamp branch.""" """Extension create-new-feature-branch.ps1 creates timestamp branch."""
project = _setup_project(tmp_path) project = _setup_project(tmp_path)
result = _run_pwsh( result = _run_pwsh(
"create-new-feature.ps1", project, "create-new-feature-branch.ps1", project,
"-Json", "-Timestamp", "-ShortName", "feat", "Feature", "-Json", "-Timestamp", "-ShortName", "feat", "Feature",
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
@@ -363,10 +363,10 @@ class TestCreateFeaturePowerShell:
assert re.match(r"^\d{8}-\d{6}-feat$", data["BRANCH_NAME"]) assert re.match(r"^\d{8}-\d{6}-feat$", data["BRANCH_NAME"])
def test_no_git_graceful_degradation(self, tmp_path: Path): def test_no_git_graceful_degradation(self, tmp_path: Path):
"""create-new-feature.ps1 works without git.""" """create-new-feature-branch.ps1 works without git."""
project = _setup_project(tmp_path, git=False) project = _setup_project(tmp_path, git=False)
result = _run_pwsh( result = _run_pwsh(
"create-new-feature.ps1", project, "create-new-feature-branch.ps1", project,
"-Json", "-ShortName", "no-git", "No git feature", "-Json", "-ShortName", "no-git", "No git feature",
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr

View File

@@ -63,7 +63,7 @@ class TestInitIntegrationFlag:
try: try:
os.chdir(project) os.chdir(project)
result = runner.invoke(app, [ result = runner.invoke(app, [
"init", "--here", "--integration", "copilot", "--script", "sh", "--no-git", "init", "--here", "--integration", "copilot", "--script", "sh",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
@@ -111,7 +111,7 @@ class TestInitIntegrationFlag:
runner = CliRunner() runner = CliRunner()
project = tmp_path / "noninteractive" project = tmp_path / "noninteractive"
result = runner.invoke(app, [ result = runner.invoke(app, [
"init", str(project), "--script", "sh", "--no-git", "--ignore-agent-tools", "init", str(project), "--script", "sh", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
@@ -131,7 +131,7 @@ class TestInitIntegrationFlag:
os.chdir(project) os.chdir(project)
runner = CliRunner() runner = CliRunner()
result = runner.invoke(app, [ result = runner.invoke(app, [
"init", "--here", "--integration", "copilot", "--script", "sh", "--no-git", "init", "--here", "--integration", "copilot", "--script", "sh",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
@@ -160,7 +160,6 @@ class TestInitIntegrationFlag:
"copilot", "copilot",
"--script", "--script",
"sh", "sh",
"--no-git",
"--preset", "--preset",
"lean", "lean",
], ],
@@ -192,7 +191,7 @@ class TestInitIntegrationFlag:
os.chdir(project) os.chdir(project)
runner = CliRunner() runner = CliRunner()
result = runner.invoke(app, [ result = runner.invoke(app, [
"init", "--here", "--force", "--integration", "claude", "--script", "sh", "--no-git", "--ignore-agent-tools", "init", "--here", "--force", "--integration", "claude", "--script", "sh", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
@@ -633,7 +632,6 @@ class TestInitIntegrationFlag:
"init", "--here", "--force", "init", "--here", "--force",
"--integration", "copilot", "--integration", "copilot",
"--script", "sh", "--script", "sh",
"--no-git",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
@@ -663,7 +661,6 @@ class TestInitIntegrationFlag:
"init", "--here", "init", "--here",
"--integration", "copilot", "--integration", "copilot",
"--script", "sh", "--script", "sh",
"--no-git",
], input="y\n", catch_exceptions=False) ], input="y\n", catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
@@ -692,7 +689,7 @@ class TestForceExistingDirectory:
runner = CliRunner() runner = CliRunner()
result = runner.invoke(app, [ result = runner.invoke(app, [
"init", str(target), "--integration", "copilot", "--force", "init", str(target), "--integration", "copilot", "--force",
"--no-git", "--script", "sh", "--script", "sh",
], catch_exceptions=False) ], catch_exceptions=False)
assert result.exit_code == 0, f"init --force failed: {result.output}" assert result.exit_code == 0, f"init --force failed: {result.output}"
@@ -715,22 +712,22 @@ class TestForceExistingDirectory:
runner = CliRunner() runner = CliRunner()
result = runner.invoke(app, [ result = runner.invoke(app, [
"init", str(target), "--integration", "copilot", "init", str(target), "--integration", "copilot",
"--no-git", "--script", "sh", "--script", "sh",
], catch_exceptions=False) ], catch_exceptions=False)
assert result.exit_code == 1 assert result.exit_code == 1
assert "already exists" in _normalize_cli_output(result.output) assert "already exists" in _normalize_cli_output(result.output)
class TestGitExtensionAutoInstall: class TestGitExtensionOptIn:
"""Tests for auto-installation of the git extension during specify init.""" """Tests verifying that the git extension is opt-in (not auto-installed) during specify init."""
def test_git_extension_auto_installed(self, tmp_path): def test_git_extension_not_auto_installed(self, tmp_path):
"""Without --no-git, the git extension is installed during init.""" """Git extension is NOT installed automatically during init."""
from typer.testing import CliRunner from typer.testing import CliRunner
from specify_cli import app from specify_cli import app
project = tmp_path / "git-auto" project = tmp_path / "git-opt-in"
project.mkdir() project.mkdir()
old_cwd = os.getcwd() old_cwd = os.getcwd()
try: try:
@@ -745,30 +742,16 @@ class TestGitExtensionAutoInstall:
assert result.exit_code == 0, f"init failed: {result.output}" assert result.exit_code == 0, f"init failed: {result.output}"
# Check that the tracker didn't report a git error # Git extension directory should NOT be present after init
assert "install failed" not in result.output, f"git extension install failed: {result.output}"
# Git extension files should be installed
ext_dir = project / ".specify" / "extensions" / "git" ext_dir = project / ".specify" / "extensions" / "git"
assert ext_dir.exists(), "git extension directory not installed" assert not ext_dir.exists(), "git extension should not be auto-installed"
assert (ext_dir / "extension.yml").exists()
assert (ext_dir / "scripts" / "bash" / "create-new-feature.sh").exists()
assert (ext_dir / "scripts" / "bash" / "initialize-repo.sh").exists()
# Hooks should be registered def test_no_git_flag_is_rejected(self, tmp_path):
extensions_yml = project / ".specify" / "extensions.yml" """--no-git flag has been removed; passing it should fail."""
assert extensions_yml.exists(), "extensions.yml not created"
hooks_data = yaml.safe_load(extensions_yml.read_text(encoding="utf-8"))
assert "hooks" in hooks_data
assert "before_specify" in hooks_data["hooks"]
assert "before_constitution" in hooks_data["hooks"]
def test_no_git_skips_extension(self, tmp_path):
"""With --no-git, the git extension is NOT installed."""
from typer.testing import CliRunner from typer.testing import CliRunner
from specify_cli import app from specify_cli import app
project = tmp_path / "no-git" project = tmp_path / "no-git-rejected"
project.mkdir() project.mkdir()
old_cwd = os.getcwd() old_cwd = os.getcwd()
try: try:
@@ -777,75 +760,19 @@ class TestGitExtensionAutoInstall:
result = runner.invoke(app, [ result = runner.invoke(app, [
"init", "--here", "--integration", "claude", "--script", "sh", "init", "--here", "--integration", "claude", "--script", "sh",
"--no-git", "--ignore-agent-tools", "--no-git", "--ignore-agent-tools",
], catch_exceptions=False) ])
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}" assert result.exit_code != 0, "--no-git should be rejected as an unknown option"
assert "No such option" in result.output or "no such option" in result.output.lower()
# Git extension should NOT be installed def test_git_extension_commands_not_registered_by_default(self, tmp_path):
ext_dir = project / ".specify" / "extensions" / "git" """Git extension commands are NOT registered with the agent during default init."""
assert not ext_dir.exists(), "git extension should not be installed with --no-git"
def test_no_git_emits_deprecation_warning(self, tmp_path):
"""Using --no-git emits a visible deprecation warning."""
from typer.testing import CliRunner from typer.testing import CliRunner
from specify_cli import app from specify_cli import app
project = tmp_path / "no-git-warn" project = tmp_path / "git-cmds-absent"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--integration", "claude", "--script", "sh",
"--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 0, result.output
assert "--no-git" in normalized_output
assert "deprecated" in normalized_output
assert "0.10.0" in normalized_output
assert "specify extension" in normalized_output
assert "will be removed" in normalized_output
assert "git extension will no longer be enabled by default" in normalized_output
def test_default_git_auto_enable_emits_notice(self, tmp_path):
"""Default git auto-enable emits notice about the v0.10.0 opt-in change."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "git-default-notice"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--integration", "claude", "--script", "sh",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 0, result.output
# Check for key message components (notice may have box-drawing chars)
assert "git extension is currently enabled by default" in normalized_output
assert "v0.10.0" in normalized_output
assert "explicit opt-in" in normalized_output
assert "specify extension add git" in normalized_output
def test_git_extension_commands_registered(self, tmp_path):
"""Git extension commands are registered with the agent during init."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "git-cmds"
project.mkdir() project.mkdir()
old_cwd = os.getcwd() old_cwd = os.getcwd()
try: try:
@@ -860,11 +787,11 @@ class TestGitExtensionAutoInstall:
assert result.exit_code == 0, f"init failed: {result.output}" assert result.exit_code == 0, f"init failed: {result.output}"
# Git extension commands should be registered with the agent # Git extension skill commands should NOT be present
claude_skills = project / ".claude" / "skills" claude_skills = project / ".claude" / "skills"
assert claude_skills.exists(), "Claude skills directory was not created" assert claude_skills.exists(), "Claude skills directory was not created"
git_skills = [f for f in claude_skills.iterdir() if f.name.startswith("speckit-git-")] git_skills = [f for f in claude_skills.iterdir() if f.name.startswith("speckit-git-")]
assert len(git_skills) > 0, "no git extension commands registered" assert len(git_skills) == 0, "git extension commands should not be registered by default"
class TestSharedInfraCommandRefs: class TestSharedInfraCommandRefs:
@@ -983,7 +910,6 @@ class TestSharedInfraCommandRefs:
"init", str(project), "init", str(project),
"--integration", "claude", "--integration", "claude",
"--script", "sh", "--script", "sh",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
@@ -1014,7 +940,6 @@ class TestSharedInfraCommandRefs:
"init", str(project), "init", str(project),
"--integration", "copilot", "--integration", "copilot",
"--script", "sh", "--script", "sh",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
@@ -1046,7 +971,6 @@ class TestSharedInfraCommandRefs:
"--integration", "copilot", "--integration", "copilot",
"--integration-options", "--skills", "--integration-options", "--skills",
"--script", "sh", "--script", "sh",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:

View File

@@ -39,7 +39,7 @@ class TestAgyInitFlow:
runner = CliRunner() runner = CliRunner()
target = tmp_path / "test-proj" target = tmp_path / "test-proj"
result = runner.invoke(app, ["init", str(target), "--integration", "agy", "--no-git", "--script", "sh", "--ignore-agent-tools"]) result = runner.invoke(app, ["init", str(target), "--integration", "agy", "--script", "sh", "--ignore-agent-tools"])
assert result.exit_code == 0, f"init --integration agy failed: {result.output}" assert result.exit_code == 0, f"init --integration agy failed: {result.output}"
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists() assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
@@ -52,7 +52,7 @@ class TestAgyInitFlow:
# Click >= 8.2 separates stdout and stderr natively # Click >= 8.2 separates stdout and stderr natively
runner = CliRunner() runner = CliRunner()
target = tmp_path / "test-proj2" target = tmp_path / "test-proj2"
result = runner.invoke(app, ["init", str(target), "--integration", "agy", "--no-git", "--script", "sh", "--ignore-agent-tools"]) result = runner.invoke(app, ["init", str(target), "--integration", "agy", "--script", "sh", "--ignore-agent-tools"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "Warning: The .agents/ layout requires Antigravity v1.20.5 or newer" in result.stderr assert "Warning: The .agents/ layout requires Antigravity v1.20.5 or newer" in result.stderr

View File

@@ -192,7 +192,7 @@ class MarkdownIntegrationTests:
os.chdir(project) os.chdir(project)
runner = CliRunner() runner = CliRunner()
result = runner.invoke(app, [ result = runner.invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git", "init", "--here", "--integration", self.KEY, "--script", "sh",
"--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
@@ -213,7 +213,7 @@ class MarkdownIntegrationTests:
os.chdir(project) os.chdir(project)
runner = CliRunner() runner = CliRunner()
result = runner.invoke(app, [ result = runner.invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git", "init", "--here", "--integration", self.KEY, "--script", "sh",
"--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
@@ -238,7 +238,7 @@ class MarkdownIntegrationTests:
os.chdir(project) os.chdir(project)
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh", "init", "--here", "--integration", self.KEY, "--script", "sh",
"--no-git", "--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
@@ -321,13 +321,13 @@ class MarkdownIntegrationTests:
os.chdir(project) os.chdir(project)
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh", "init", "--here", "--integration", self.KEY, "--script", "sh",
"--no-git", "--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}" assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(p.relative_to(project).as_posix() actual = sorted(p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file()) for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
expected = self._expected_files("sh") expected = self._expected_files("sh")
assert actual == expected, ( assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n" f"Missing: {sorted(set(expected) - set(actual))}\n"
@@ -346,13 +346,13 @@ class MarkdownIntegrationTests:
os.chdir(project) os.chdir(project)
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "ps", "init", "--here", "--integration", self.KEY, "--script", "ps",
"--no-git", "--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}" assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(p.relative_to(project).as_posix() actual = sorted(p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file()) for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
expected = self._expected_files("ps") expected = self._expected_files("ps")
assert actual == expected, ( assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n" f"Missing: {sorted(set(expected) - set(actual))}\n"

View File

@@ -325,7 +325,7 @@ class SkillsIntegrationTests:
os.chdir(project) os.chdir(project)
runner = CliRunner() runner = CliRunner()
result = runner.invoke(app, [ result = runner.invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git", "init", "--here", "--integration", self.KEY, "--script", "sh",
"--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
@@ -346,7 +346,7 @@ class SkillsIntegrationTests:
os.chdir(project) os.chdir(project)
runner = CliRunner() runner = CliRunner()
result = runner.invoke(app, [ result = runner.invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git", "init", "--here", "--integration", self.KEY, "--script", "sh",
"--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
@@ -369,7 +369,7 @@ class SkillsIntegrationTests:
os.chdir(project) os.chdir(project)
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh", "init", "--here", "--integration", self.KEY, "--script", "sh",
"--no-git", "--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
@@ -471,15 +471,15 @@ class SkillsIntegrationTests:
try: try:
os.chdir(project) os.chdir(project)
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "init", "--here", "--integration", self.KEY, "--script", "sh",
"--script", "sh", "--no-git", "--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}" assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted( actual = sorted(
p.relative_to(project).as_posix() p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
) )
expected = self._expected_files("sh") expected = self._expected_files("sh")
assert actual == expected, ( assert actual == expected, (
@@ -498,15 +498,15 @@ class SkillsIntegrationTests:
try: try:
os.chdir(project) os.chdir(project)
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "init", "--here", "--integration", self.KEY, "--script", "ps",
"--script", "ps", "--no-git", "--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}" assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted( actual = sorted(
p.relative_to(project).as_posix() p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
) )
expected = self._expected_files("ps") expected = self._expected_files("ps")
assert actual == expected, ( assert actual == expected, (

View File

@@ -409,7 +409,6 @@ class TomlIntegrationTests:
self.KEY, self.KEY,
"--script", "--script",
"sh", "sh",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], ],
catch_exceptions=False, catch_exceptions=False,
@@ -440,7 +439,6 @@ class TomlIntegrationTests:
self.KEY, self.KEY,
"--script", "--script",
"sh", "sh",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], ],
catch_exceptions=False, catch_exceptions=False,
@@ -469,7 +467,7 @@ class TomlIntegrationTests:
os.chdir(project) os.chdir(project)
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh", "init", "--here", "--integration", self.KEY, "--script", "sh",
"--no-git", "--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
@@ -580,7 +578,6 @@ class TomlIntegrationTests:
self.KEY, self.KEY,
"--script", "--script",
"sh", "sh",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], ],
catch_exceptions=False, catch_exceptions=False,
@@ -589,7 +586,7 @@ class TomlIntegrationTests:
os.chdir(old_cwd) os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}" assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted( actual = sorted(
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
) )
expected = self._expected_files("sh") expected = self._expected_files("sh")
assert actual == expected, ( assert actual == expected, (
@@ -616,7 +613,6 @@ class TomlIntegrationTests:
self.KEY, self.KEY,
"--script", "--script",
"ps", "ps",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], ],
catch_exceptions=False, catch_exceptions=False,
@@ -625,7 +621,7 @@ class TomlIntegrationTests:
os.chdir(old_cwd) os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}" assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted( actual = sorted(
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
) )
expected = self._expected_files("ps") expected = self._expected_files("ps")
assert actual == expected, ( assert actual == expected, (

View File

@@ -288,7 +288,6 @@ class YamlIntegrationTests:
self.KEY, self.KEY,
"--script", "--script",
"sh", "sh",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], ],
catch_exceptions=False, catch_exceptions=False,
@@ -319,7 +318,6 @@ class YamlIntegrationTests:
self.KEY, self.KEY,
"--script", "--script",
"sh", "sh",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], ],
catch_exceptions=False, catch_exceptions=False,
@@ -348,7 +346,7 @@ class YamlIntegrationTests:
os.chdir(project) os.chdir(project)
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh", "init", "--here", "--integration", self.KEY, "--script", "sh",
"--no-git", "--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
@@ -459,7 +457,6 @@ class YamlIntegrationTests:
self.KEY, self.KEY,
"--script", "--script",
"sh", "sh",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], ],
catch_exceptions=False, catch_exceptions=False,
@@ -468,7 +465,7 @@ class YamlIntegrationTests:
os.chdir(old_cwd) os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}" assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted( actual = sorted(
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
) )
expected = self._expected_files("sh") expected = self._expected_files("sh")
assert actual == expected, ( assert actual == expected, (
@@ -495,7 +492,6 @@ class YamlIntegrationTests:
self.KEY, self.KEY,
"--script", "--script",
"ps", "ps",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], ],
catch_exceptions=False, catch_exceptions=False,
@@ -504,7 +500,7 @@ class YamlIntegrationTests:
os.chdir(old_cwd) os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}" assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted( actual = sorted(
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
) )
expected = self._expected_files("ps") expected = self._expected_files("ps")
assert actual == expected, ( assert actual == expected, (

View File

@@ -458,7 +458,6 @@ class TestIntegrationListCatalog:
"init", "--here", "init", "--here",
"--integration", "copilot", "--integration", "copilot",
"--script", "sh", "--script", "sh",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
@@ -556,7 +555,6 @@ class TestIntegrationUpgrade:
"init", "--here", "init", "--here",
"--integration", integration, "--integration", integration,
"--script", "sh", "--script", "sh",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:

View File

@@ -137,7 +137,6 @@ class TestClaudeIntegration:
"claude", "claude",
"--script", "--script",
"sh", "sh",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], ],
catch_exceptions=False, catch_exceptions=False,
@@ -175,7 +174,6 @@ class TestClaudeIntegration:
"claude", "claude",
"--script", "--script",
"sh", "sh",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], ],
catch_exceptions=False, catch_exceptions=False,
@@ -208,7 +206,6 @@ class TestClaudeIntegration:
"--here", "--here",
"--script", "--script",
"sh", "sh",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], ],
catch_exceptions=False, catch_exceptions=False,
@@ -243,7 +240,7 @@ class TestClaudeIntegration:
result = runner.invoke( result = runner.invoke(
app, app,
["init", str(target), "--integration", "claude", "--script", "sh", "--no-git", "--ignore-agent-tools"], ["init", str(target), "--integration", "claude", "--script", "sh", "--ignore-agent-tools"],
) )
assert result.exit_code == 0 assert result.exit_code == 0

View File

@@ -139,7 +139,6 @@ class TestClineIntegration(MarkdownIntegrationTests):
self.KEY, self.KEY,
"--script", "--script",
"sh", "sh",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], ],
catch_exceptions=False, catch_exceptions=False,

View File

@@ -24,7 +24,7 @@ class TestCodexInitFlow:
runner = CliRunner() runner = CliRunner()
target = tmp_path / "test-proj" target = tmp_path / "test-proj"
result = runner.invoke(app, ["init", str(target), "--integration", "codex", "--no-git", "--ignore-agent-tools", "--script", "sh"]) result = runner.invoke(app, ["init", str(target), "--integration", "codex", "--ignore-agent-tools", "--script", "sh"])
assert result.exit_code == 0, f"init --integration codex failed: {result.output}" assert result.exit_code == 0, f"init --integration codex failed: {result.output}"
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists() assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()

View File

@@ -186,12 +186,12 @@ class TestCopilotIntegration:
try: try:
os.chdir(project) os.chdir(project)
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot", "--script", "sh", "--no-git", "init", "--here", "--integration", "copilot", "--script", "sh",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
assert result.exit_code == 0 assert result.exit_code == 0
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()) actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
expected = sorted([ expected = sorted([
".github/agents/speckit.agent-context.update.agent.md", ".github/agents/speckit.agent-context.update.agent.md",
".github/agents/speckit.analyze.agent.md", ".github/agents/speckit.analyze.agent.md",
@@ -256,12 +256,12 @@ class TestCopilotIntegration:
try: try:
os.chdir(project) os.chdir(project)
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot", "--script", "ps", "--no-git", "init", "--here", "--integration", "copilot", "--script", "ps",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
assert result.exit_code == 0 assert result.exit_code == 0
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()) actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
expected = sorted([ expected = sorted([
".github/agents/speckit.agent-context.update.agent.md", ".github/agents/speckit.agent-context.update.agent.md",
".github/agents/speckit.analyze.agent.md", ".github/agents/speckit.analyze.agent.md",
@@ -622,7 +622,7 @@ class TestCopilotSkillsMode:
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot", "init", "--here", "--integration", "copilot",
"--integration-options", "--skills", "--integration-options", "--skills",
"--script", "sh", "--no-git", "--script", "sh",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
@@ -648,12 +648,12 @@ class TestCopilotSkillsMode:
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot", "init", "--here", "--integration", "copilot",
"--integration-options", "--skills", "--integration-options", "--skills",
"--script", "sh", "--no-git", "--script", "sh",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}" assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()) actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
expected = sorted([ expected = sorted([
# Skill files (core + extension-installed agent-context command) # Skill files (core + extension-installed agent-context command)
*[f".github/skills/speckit-{cmd}/SKILL.md" for cmd in self._SKILL_COMMANDS], *[f".github/skills/speckit-{cmd}/SKILL.md" for cmd in self._SKILL_COMMANDS],
@@ -775,7 +775,6 @@ class TestCopilotSkillsMode:
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot", "init", "--here", "--integration", "copilot",
"--integration-options", "--skills", "--integration-options", "--skills",
"--script", "sh", "--no-git",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)

View File

@@ -102,7 +102,7 @@ class TestCursorAgentInitFlow:
runner = CliRunner() runner = CliRunner()
target = tmp_path / "test-proj" target = tmp_path / "test-proj"
result = runner.invoke(app, ["init", str(target), "--integration", "cursor-agent", "--no-git", "--ignore-agent-tools", "--script", "sh"]) result = runner.invoke(app, ["init", str(target), "--integration", "cursor-agent", "--ignore-agent-tools", "--script", "sh"])
assert result.exit_code == 0, f"init --integration cursor-agent failed: {result.output}" assert result.exit_code == 0, f"init --integration cursor-agent failed: {result.output}"
assert (target / ".cursor" / "skills" / "speckit-plan" / "SKILL.md").exists() assert (target / ".cursor" / "skills" / "speckit-plan" / "SKILL.md").exists()

View File

@@ -68,7 +68,7 @@ class TestDevinInitFlow:
target = tmp_path / "test-proj" target = tmp_path / "test-proj"
result = runner.invoke( result = runner.invoke(
app, app,
["init", str(target), "--integration", "devin", "--no-git", "--ignore-agent-tools", "--script", "sh"], ["init", str(target), "--integration", "devin", "--ignore-agent-tools", "--script", "sh"],
) )
assert result.exit_code == 0, f"init --integration devin failed: {result.output}" assert result.exit_code == 0, f"init --integration devin failed: {result.output}"

View File

@@ -251,7 +251,6 @@ class TestGenericIntegration:
runner = CliRunner() runner = CliRunner()
result = runner.invoke(app, [ result = runner.invoke(app, [
"init", str(tmp_path / "test-generic"), "--integration", "generic", "init", str(tmp_path / "test-generic"), "--integration", "generic",
"--script", "sh", "--no-git",
]) ])
# Generic requires --commands-dir via --integration-options # Generic requires --commands-dir via --integration-options
assert result.exit_code != 0 assert result.exit_code != 0
@@ -270,7 +269,7 @@ class TestGenericIntegration:
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", "generic", "init", "--here", "--integration", "generic",
"--integration-options=--commands-dir .myagent/commands", "--integration-options=--commands-dir .myagent/commands",
"--script", "sh", "--no-git", "--script", "sh",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
@@ -292,14 +291,14 @@ class TestGenericIntegration:
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", "generic", "init", "--here", "--integration", "generic",
"--integration-options=--commands-dir .myagent/commands", "--integration-options=--commands-dir .myagent/commands",
"--script", "sh", "--no-git", "--script", "sh",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}" assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted( actual = sorted(
p.relative_to(project).as_posix() p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
) )
expected = sorted([ expected = sorted([
"AGENTS.md", "AGENTS.md",
@@ -356,14 +355,14 @@ class TestGenericIntegration:
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", "generic", "init", "--here", "--integration", "generic",
"--integration-options=--commands-dir .myagent/commands", "--integration-options=--commands-dir .myagent/commands",
"--script", "ps", "--no-git", "--script", "ps",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}" assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted( actual = sorted(
p.relative_to(project).as_posix() p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
) )
expected = sorted([ expected = sorted([
"AGENTS.md", "AGENTS.md",

View File

@@ -232,7 +232,7 @@ class TestHermesIntegration(SkillsIntegrationTests):
os.chdir(project) os.chdir(project)
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "init", "--here", "--integration", self.KEY,
"--script", "sh", "--no-git", "--ignore-agent-tools", "--script", "sh", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
@@ -270,7 +270,7 @@ class TestHermesIntegration(SkillsIntegrationTests):
os.chdir(project) os.chdir(project)
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "init", "--here", "--integration", self.KEY,
"--script", "ps", "--no-git", "--ignore-agent-tools", "--script", "ps", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
@@ -342,7 +342,6 @@ class TestHermesInitFlow:
result = runner.invoke(app, [ result = runner.invoke(app, [
"init", str(target), "init", str(target),
"--integration", "hermes", "--integration", "hermes",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
"--script", "sh", "--script", "sh",
]) ])

View File

@@ -137,7 +137,7 @@ class TestKimiNextSteps:
os.chdir(project) os.chdir(project)
runner = CliRunner() runner = CliRunner()
result = runner.invoke(app, [ result = runner.invoke(app, [
"init", "--here", "--integration", "kimi", "--no-git", "init", "--here", "--integration", "kimi",
"--ignore-agent-tools", "--script", "sh", "--ignore-agent-tools", "--script", "sh",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:

View File

@@ -140,7 +140,7 @@ class TestKiroIntegration:
runner = CliRunner() runner = CliRunner()
result = runner.invoke(app, [ result = runner.invoke(app, [
"init", "--here", "--integration", "kiro-cli", "init", "--here", "--integration", "kiro-cli",
"--ignore-agent-tools", "--script", "sh", "--no-git", "--ignore-agent-tools", "--script", "sh",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)

View File

@@ -25,8 +25,7 @@ def _run_init(project, *flags: str) -> Result:
os.chdir(project) os.chdir(project)
return CliRunner().invoke( return CliRunner().invoke(
app, app,
["init", "--here", *flags, "--script", "sh", ["init", "--here", *flags, "--script", "sh", "--ignore-agent-tools"],
"--no-git", "--ignore-agent-tools"],
catch_exceptions=False, catch_exceptions=False,
) )
finally: finally:

View File

@@ -2,7 +2,10 @@
import json import json
import os import os
import shutil
from pathlib import Path
import pytest
from typer.testing import CliRunner from typer.testing import CliRunner
from specify_cli import app from specify_cli import app
@@ -23,7 +26,6 @@ def _init_project(tmp_path, integration="copilot"):
"init", "--here", "init", "--here",
"--integration", integration, "--integration", integration,
"--script", "sh", "--script", "sh",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
@@ -48,6 +50,32 @@ def _write_invalid_manifest(project, key):
return manifest return manifest
def _copy_project_template(tmp_path, template):
project = tmp_path / "proj"
shutil.copytree(template, project)
return project
@pytest.fixture(scope="module")
def status_copilot_template(tmp_path_factory):
return _init_project(tmp_path_factory.mktemp("status-copilot"), "copilot")
@pytest.fixture(scope="module")
def status_claude_template(tmp_path_factory):
return _init_project(tmp_path_factory.mktemp("status-claude"), "claude")
@pytest.fixture
def copilot_project(tmp_path, status_copilot_template):
return _copy_project_template(tmp_path, status_copilot_template)
@pytest.fixture
def claude_project(tmp_path, status_claude_template):
return _copy_project_template(tmp_path, status_claude_template)
def _integration_list_row_cells(output: str, key: str) -> list[str]: def _integration_list_row_cells(output: str, key: str) -> list[str]:
plain = strip_ansi(output) plain = strip_ansi(output)
row = next(line for line in plain.splitlines() if line.startswith(f"{key}")) row = next(line for line in plain.splitlines() if line.startswith(f"{key}"))
@@ -127,6 +155,823 @@ class TestIntegrationList:
assert "only supports schema 1" in normalized assert "only supports schema 1" in normalized
# ── status ───────────────────────────────────────────────────────────
class TestIntegrationStatus:
def test_status_requires_speckit_project(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
result = runner.invoke(app, ["integration", "status"])
assert result.exit_code != 0
assert "Not a spec-kit project" in result.output
def test_status_reports_healthy_project(self, copilot_project):
result = _run_in_project(copilot_project, ["integration", "status"])
assert result.exit_code == 0
assert "Integration status: OK" in result.output
assert "Default integration: copilot" in result.output
assert "Installed integrations: copilot" in result.output
assert "Shared templates target alignment: copilot" in result.output
assert "Modified managed files: 0" in result.output
assert "Missing managed files: 0" in result.output
def test_status_json_reports_healthy_project(self, copilot_project):
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["status"] == "ok"
assert payload["default_integration"] == "copilot"
assert payload["installed_integrations"] == ["copilot"]
assert payload["recorded_installed_integrations"] == ["copilot"]
assert payload["manifest_checked_integrations"] == ["copilot", "speckit"]
assert payload["multi_install_safe"] is True
assert payload["shared_templates_target_alignment"] == "copilot"
assert "shared_templates_aligned_to" not in payload
assert payload["findings"] == []
def test_status_reports_invalid_integration_json(self, copilot_project):
(copilot_project / ".specify" / "integration.json").write_text("{", encoding="utf-8")
result = _run_in_project(copilot_project, ["integration", "status"])
assert result.exit_code != 0
assert "integration-state-unreadable" in result.output
assert "invalid JSON" in result.output
assert "Detail:" in result.output
assert "Multi-install safe: unknown" in result.output
assert "Traceback" not in result.output
def test_status_json_reports_unknown_multi_install_safety_when_state_unreadable(
self,
copilot_project,
):
(copilot_project / ".specify" / "integration.json").write_text("{", encoding="utf-8")
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert payload["status"] == "error"
assert payload["multi_install_safe"] is None
assert payload["manifest_checked_integrations"] == []
assert payload["findings"][0]["code"] == "integration-state-unreadable"
assert "Detail:" in payload["findings"][0]["message"]
def test_status_reports_supported_schema_for_newer_integration_state(self, copilot_project):
state_path = copilot_project / ".specify" / "integration.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state["integration_state_schema"] = 99
state_path.write_text(json.dumps(state), encoding="utf-8")
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert payload["findings"][0]["code"] == "integration-state-unreadable"
assert "schema 99" in payload["findings"][0]["message"]
assert "supported schema: 1" in payload["findings"][0]["message"]
def test_status_reports_missing_integration_json(self, copilot_project):
(copilot_project / ".specify" / "integration.json").unlink()
result = _run_in_project(copilot_project, ["integration", "status"])
assert result.exit_code != 0
assert "integration-state-missing" in result.output
assert ".specify/integration.json is missing" in result.output
assert "Multi-install safe: unknown" in result.output
def test_status_json_reports_unknown_multi_install_safety_when_state_missing(
self,
copilot_project,
):
(copilot_project / ".specify" / "integration.json").unlink()
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert payload["status"] == "error"
assert payload["multi_install_safe"] is None
assert payload["manifest_checked_integrations"] == []
assert payload["findings"][0]["code"] == "integration-state-missing"
def test_status_json_reports_no_installed_integrations_as_warning(self, copilot_project):
state_path = copilot_project / ".specify" / "integration.json"
state_path.write_text(
json.dumps({
"version": "test",
"integration_state_schema": 1,
"installed_integrations": [],
}),
encoding="utf-8",
)
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["status"] == "warning"
assert payload["installed_integrations"] == []
assert payload["multi_install_safe"] is None
assert payload["manifest_checked_integrations"] == ["speckit"]
assert payload["findings"][0]["code"] == "no-installed-integrations"
assert "speckit" in payload["manifests"]
assert payload["manifests"]["speckit"]["readable"] is True
def test_status_checks_shared_manifest_when_no_integrations_installed(self, copilot_project):
state_path = copilot_project / ".specify" / "integration.json"
state_path.write_text(
json.dumps({
"version": "test",
"integration_state_schema": 1,
"installed_integrations": [],
}),
encoding="utf-8",
)
(copilot_project / ".specify" / "integrations" / "speckit.manifest.json").unlink()
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert payload["status"] == "error"
assert payload["installed_integrations"] == []
assert payload["manifest_checked_integrations"] == ["speckit"]
assert payload["unchecked_manifests"] == 1
assert any(
item["code"] == "no-installed-integrations"
for item in payload["findings"]
)
assert any(
item["code"] == "manifest-missing"
and item["integration"] == "speckit"
for item in payload["findings"]
)
def test_status_json_reports_missing_default_integration_as_error(self, claude_project):
state_path = claude_project / ".specify" / "integration.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state.pop("default_integration", None)
state.pop("integration", None)
state["installed_integrations"] = ["claude"]
state_path.write_text(json.dumps(state), encoding="utf-8")
result = _run_in_project(claude_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert payload["status"] == "error"
assert payload["default_integration"] is None
assert any(
item["code"] == "default-integration-missing"
for item in payload["findings"]
)
def test_status_ignores_non_list_raw_installed_integrations(self, copilot_project):
state_path = copilot_project / ".specify" / "integration.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state.pop("default_integration", None)
state.pop("integration", None)
state["installed_integrations"] = "copilot"
state_path.write_text(json.dumps(state), encoding="utf-8")
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["status"] == "warning"
assert payload["installed_integrations"] == []
assert payload["recorded_installed_integrations"] == []
assert payload["manifest_checked_integrations"] == ["speckit"]
assert payload["multi_install_safe"] is None
assert [item["code"] for item in payload["findings"]] == [
"installed-integrations-invalid",
"no-installed-integrations",
]
def test_status_reports_non_list_raw_installed_integrations_with_default(self, copilot_project):
state_path = copilot_project / ".specify" / "integration.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state["default_integration"] = "copilot"
state["integration"] = "copilot"
state["installed_integrations"] = "copilot"
state_path.write_text(json.dumps(state), encoding="utf-8")
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["status"] == "warning"
assert payload["installed_integrations"] == ["copilot"]
assert payload["recorded_installed_integrations"] == []
assert payload["manifest_checked_integrations"] == ["copilot", "speckit"]
assert payload["multi_install_safe"] is None
assert [item["code"] for item in payload["findings"]] == [
"installed-integrations-invalid",
]
def test_status_reports_default_integration_not_installed(self, claude_project):
state_path = claude_project / ".specify" / "integration.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state["default_integration"] = "codex"
state["integration"] = "codex"
state["installed_integrations"] = ["claude"]
state_path.write_text(json.dumps(state), encoding="utf-8")
result = _run_in_project(claude_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert payload["default_integration"] == "codex"
assert payload["installed_integrations"] == ["codex", "claude"]
assert payload["recorded_installed_integrations"] == ["claude"]
assert payload["manifest_checked_integrations"] == ["claude", "speckit"]
assert any(
item["code"] == "default-integration-not-installed"
and "Default integration 'codex' is not listed" in item["message"]
for item in payload["findings"]
)
assert "codex" not in payload["manifests"]
assert not any(
item["code"] == "manifest-missing" and item.get("integration") == "codex"
for item in payload["findings"]
)
def test_status_checks_effective_default_manifest_when_raw_installed_is_empty(self, claude_project):
state_path = claude_project / ".specify" / "integration.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state["installed_integrations"] = []
state_path.write_text(json.dumps(state), encoding="utf-8")
result = _run_in_project(claude_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert payload["installed_integrations"] == ["claude"]
assert payload["recorded_installed_integrations"] == []
assert payload["manifest_checked_integrations"] == ["claude", "speckit"]
assert payload["multi_install_safe"] is None
assert payload["manifests"]["claude"]["readable"] is True
assert any(
item["code"] == "default-integration-not-installed"
for item in payload["findings"]
)
def test_status_reports_missing_manifest(self, copilot_project):
(copilot_project / ".specify" / "integrations" / "copilot.manifest.json").unlink()
result = _run_in_project(copilot_project, ["integration", "status"])
assert result.exit_code != 0
assert "manifest-missing" in result.output
assert "Manifest for integration 'copilot' is missing" in result.output
def test_status_reports_unreadable_manifest_in_json_summary(self, copilot_project):
_write_invalid_manifest(copilot_project, "copilot")
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert payload["unchecked_manifests"] == 1
assert payload["manifests"]["copilot"]["readable"] is False
assert payload["manifests"]["copilot"]["missing_files"] == []
assert payload["manifests"]["copilot"]["modified_files"] == []
def test_status_reports_modified_managed_files_without_failing(self, copilot_project):
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"]
first_rel = next(iter(tracked_files))
(copilot_project / first_rel).write_text("MODIFIED CONTENT\n", encoding="utf-8")
result = _run_in_project(copilot_project, ["integration", "status"])
assert result.exit_code == 0
assert "Integration status: WARNING" in result.output
assert "managed-files-modified" in result.output
assert "Modified managed files: 1" in result.output
def test_status_reports_missing_managed_files(self, copilot_project):
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"]
first_rel = next(iter(tracked_files))
(copilot_project / first_rel).unlink()
result = _run_in_project(copilot_project, ["integration", "status"])
assert result.exit_code != 0
assert "managed-files-missing" in result.output
assert "Missing managed files: 1" in result.output
def test_status_reports_missing_shared_managed_files(self, copilot_project):
shared_file = copilot_project / ".specify" / "scripts" / "bash" / "common.sh"
assert shared_file.exists()
shared_file.unlink()
result = _run_in_project(copilot_project, ["integration", "status"])
assert result.exit_code != 0
assert "managed-files-missing" in result.output
assert "shared Spec Kit infrastructure" in result.output
assert "Missing managed files: 1" in result.output
def test_status_does_not_use_exists_precheck_for_managed_files(self, tmp_path, monkeypatch):
from specify_cli.integration_status import _manifest_file_status
from specify_cli.integrations.manifest import IntegrationManifest
project = tmp_path / "proj"
project.mkdir()
tracked = project / "tracked.md"
tracked.write_text("content\n", encoding="utf-8")
manifest = IntegrationManifest("test", project, version="test")
manifest.record_existing("tracked.md")
def fail_exists(self):
raise AssertionError(f"Path.exists() should not be used for {self}")
monkeypatch.setattr(Path, "exists", fail_exists)
missing, modified, invalid, valid = _manifest_file_status(
manifest,
project.resolve(),
)
assert missing == []
assert modified == []
assert invalid == []
assert valid == ["tracked.md"]
def test_status_does_not_use_exists_precheck_for_manifest_load(self, copilot_project, monkeypatch):
def fail_exists(self):
raise AssertionError(f"Path.exists() should not be used for {self}")
monkeypatch.setattr(Path, "exists", fail_exists)
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["status"] == "ok"
assert payload["manifests"]["copilot"]["readable"] is True
def test_status_reports_unresolved_project_root_without_crashing(self, copilot_project, monkeypatch):
original_resolve = Path.resolve
failed = {"done": False}
def fail_first_project_root_resolve(self, *args, **kwargs):
if self == copilot_project and not failed["done"]:
failed["done"] = True
raise RuntimeError("symlink loop")
return original_resolve(self, *args, **kwargs)
monkeypatch.setattr(Path, "resolve", fail_first_project_root_resolve)
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["status"] == "warning"
assert any(item["code"] == "project-root-unresolved" for item in payload["findings"])
def test_status_loads_manifests_when_project_root_resolution_keeps_failing(
self,
copilot_project,
monkeypatch,
):
original_resolve = Path.resolve
def fail_project_root_resolve(self, *args, **kwargs):
if self == copilot_project:
raise RuntimeError("symlink loop")
return original_resolve(self, *args, **kwargs)
monkeypatch.setattr(Path, "resolve", fail_project_root_resolve)
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["status"] == "warning"
assert payload["manifests"]["copilot"]["readable"] is True
assert payload["manifests"]["speckit"]["readable"] is True
assert any(item["code"] == "project-root-unresolved" for item in payload["findings"])
def test_status_uses_lexical_manifest_paths_when_project_root_resolution_falls_back(self, tmp_path):
from specify_cli.integration_status import _manifest_file_status
from specify_cli.integrations.manifest import IntegrationManifest
real_project = tmp_path / "real-project"
real_project.mkdir()
tracked = real_project / "tracked.md"
tracked.write_text("content\n", encoding="utf-8")
symlinked_project = tmp_path / "symlinked-project"
try:
symlinked_project.symlink_to(real_project, target_is_directory=True)
except OSError as exc:
pytest.skip(f"symlinks unavailable: {exc}")
manifest = IntegrationManifest("test", real_project, version="test")
manifest.record_existing("tracked.md")
manifest.project_root = symlinked_project.absolute()
missing, modified, invalid, valid = _manifest_file_status(
manifest,
symlinked_project.absolute(),
project_root_is_resolved=False,
)
assert missing == []
assert modified == []
assert invalid == []
assert valid == ["tracked.md"]
def test_status_treats_resolve_runtime_error_as_invalid_path(self, tmp_path, monkeypatch):
from specify_cli.integration_status import _manifest_file_status
from specify_cli.integrations.manifest import IntegrationManifest
project = tmp_path / "proj"
project.mkdir()
tracked = project / "tracked.md"
tracked.write_text("content\n", encoding="utf-8")
manifest = IntegrationManifest("test", project, version="test")
manifest.record_existing("tracked.md")
project_root_resolved = project.resolve()
original_resolve = Path.resolve
def fail_project_parent_resolve(self, *args, **kwargs):
if self == project:
raise RuntimeError("symlink loop")
return original_resolve(self, *args, **kwargs)
monkeypatch.setattr(Path, "resolve", fail_project_parent_resolve)
missing, modified, invalid, valid = _manifest_file_status(
manifest,
project_root_resolved,
)
assert missing == []
assert modified == []
assert invalid == ["tracked.md"]
assert valid == []
def test_status_does_not_mask_runtime_errors_from_manifest_load(self, copilot_project, monkeypatch):
from specify_cli import integration_status as status_module
def fail_load(key, project_root, **kwargs):
raise RuntimeError(f"unexpected manifest loader bug for {key}")
monkeypatch.setattr(status_module.IntegrationManifest, "load", fail_load)
with pytest.raises(RuntimeError, match="unexpected manifest loader bug"):
status_module.build_integration_status_report(copilot_project)
def test_status_treats_dangling_symlink_as_missing(self, copilot_project):
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"]
first_rel = next(iter(tracked_files))
target = copilot_project / first_rel
target.unlink()
try:
target.symlink_to(copilot_project / "missing-target")
except OSError as exc:
pytest.skip(f"symlinks unavailable: {exc}")
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert first_rel in payload["manifests"]["copilot"]["missing_files"]
assert first_rel not in payload["manifests"]["copilot"]["modified_files"]
def test_status_treats_windows_style_dangling_symlink_as_missing(self, tmp_path, monkeypatch):
from specify_cli.integration_status import _manifest_file_status
from specify_cli.integrations.manifest import IntegrationManifest
project = tmp_path / "proj"
project.mkdir()
tracked = project / "tracked.md"
tracked.write_text("content\n", encoding="utf-8")
regular_stat = tracked.lstat()
manifest = IntegrationManifest("test", project, version="test")
manifest.record_existing("tracked.md")
tracked.unlink()
try:
tracked.symlink_to(project / "missing-target")
except OSError as exc:
pytest.skip(f"symlinks unavailable: {exc}")
original_lstat = Path.lstat
original_is_symlink = Path.is_symlink
def windows_style_lstat(self):
if self == tracked:
return regular_stat
return original_lstat(self)
def windows_style_is_symlink(self):
if self == tracked:
return True
return original_is_symlink(self)
monkeypatch.setattr(Path, "lstat", windows_style_lstat)
monkeypatch.setattr(Path, "is_symlink", windows_style_is_symlink)
missing, modified, invalid, valid = _manifest_file_status(
manifest,
project.resolve(),
)
assert missing == ["tracked.md"]
assert modified == []
assert invalid == []
assert valid == ["tracked.md"]
def test_strip_extended_length_prefix_normalizes_windows_paths(self):
from specify_cli.integration_status import _strip_extended_length_prefix
# Build the prefixed strings explicitly so the test is meaningful on
# every platform (POSIX won't parse backslash separators, but the
# helper operates on the string form). Compare Path objects rather than
# their str() form: on Windows pathlib renders a UNC root with a
# trailing separator (``\\server\share\``), so an exact string match is
# brittle, whereas Path equality captures the intended semantics on
# both POSIX and Windows.
bs = "\\"
assert _strip_extended_length_prefix(
Path(f"{bs}{bs}?{bs}C:{bs}proj")
) == Path(f"C:{bs}proj")
assert _strip_extended_length_prefix(
Path(f"{bs}{bs}?{bs}UNC{bs}server{bs}share")
) == Path(f"{bs}{bs}server{bs}share")
# Paths without the prefix are returned unchanged.
assert _strip_extended_length_prefix(Path("relative/path")) == Path("relative/path")
def test_is_within_project_tolerates_extended_length_prefix(self):
from specify_cli.integration_status import _is_within_project
# A readlink result on POSIX never carries the prefix, so an in-project
# child is contained and an outside path is not. The Windows
# prefix-stripping branch is exercised by the dangling-symlink tests on
# Windows CI; here we lock in the cross-platform containment contract.
root = Path("/tmp/project").resolve()
assert _is_within_project(root, root / "child")
assert not _is_within_project(root, Path("/tmp/other").resolve())
def test_status_reports_unsafe_manifest_paths_without_hashing_them(self, tmp_path, copilot_project):
outside = tmp_path / "outside"
outside.mkdir()
(outside / "secret.txt").write_text("outside project\n", encoding="utf-8")
link = copilot_project / "outside-link"
try:
link.symlink_to(outside, target_is_directory=True)
except OSError as exc:
pytest.skip(f"symlinks unavailable: {exc}")
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest_data["files"]["outside-link/secret.txt"] = "wrong"
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert payload["invalid_manifest_paths"] == 1
assert "outside-link/secret.txt" in payload["manifests"]["copilot"]["invalid_files"]
assert "outside-link/secret.txt" not in payload["manifests"]["copilot"]["modified_files"]
def test_status_reports_tracked_symlink_target_escape_as_invalid(self, tmp_path, copilot_project, monkeypatch):
outside = tmp_path / "outside"
outside.mkdir()
outside_file = outside / "secret.txt"
outside_file.write_text("outside project\n", encoding="utf-8")
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"]
first_rel = next(iter(tracked_files))
tracked_path = copilot_project / first_rel
tracked_path.unlink()
try:
tracked_path.symlink_to(outside_file)
except OSError as exc:
pytest.skip(f"symlinks unavailable: {exc}")
original_stat = Path.stat
def fail_tracked_symlink_stat(self, *args, **kwargs):
follows_symlinks = kwargs.get("follow_symlinks", True)
if self == tracked_path and follows_symlinks:
raise AssertionError("Path.stat() should not follow tracked symlinks")
return original_stat(self, *args, **kwargs)
monkeypatch.setattr(Path, "stat", fail_tracked_symlink_stat)
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert payload["invalid_manifest_paths"] == 1
assert first_rel in payload["manifests"]["copilot"]["invalid_files"]
assert first_rel not in payload["manifests"]["copilot"]["modified_files"]
def test_status_reports_unsafe_multi_install_combination(self, copilot_project):
from specify_cli.integrations.manifest import IntegrationManifest
state_path = copilot_project / ".specify" / "integration.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state["installed_integrations"] = ["copilot", "claude"]
state["default_integration"] = "copilot"
state["integration"] = "copilot"
state_path.write_text(json.dumps(state), encoding="utf-8")
IntegrationManifest("claude", copilot_project, version="test").save()
result = _run_in_project(copilot_project, ["integration", "status"])
assert result.exit_code != 0
assert "unsafe-multi-install" in result.output
assert "Multi-install safe: no" in result.output
assert "specify integration switch <key>" in result.output
def test_status_treats_unknown_multi_install_as_unsafe(self, claude_project):
from specify_cli.integrations.manifest import IntegrationManifest
state_path = claude_project / ".specify" / "integration.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state["installed_integrations"] = ["claude", "mystery"]
state["default_integration"] = "claude"
state["integration"] = "claude"
state_path.write_text(json.dumps(state), encoding="utf-8")
IntegrationManifest("mystery", claude_project, version="test").save()
result = _run_in_project(claude_project, ["integration", "status"])
assert result.exit_code != 0
assert "unknown-integration" in result.output
assert "unsafe-multi-install" in result.output
assert "remove the stale integration entry" in result.output
assert "Multi-install safe: no" in result.output
def test_status_gives_actionable_suggestion_for_unknown_manifest(self, claude_project):
state_path = claude_project / ".specify" / "integration.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state["installed_integrations"] = ["mystery"]
state["default_integration"] = "mystery"
state["integration"] = "mystery"
state_path.write_text(json.dumps(state), encoding="utf-8")
result = _run_in_project(claude_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
manifest_finding = next(
item for item in payload["findings"]
if item["code"] == "manifest-missing" and item["integration"] == "mystery"
)
assert "remove the stale integration entry" in manifest_finding["suggestion"]
assert "integration upgrade mystery" not in manifest_finding["suggestion"]
def test_status_rejects_unsafe_integration_keys_before_manifest_lookup(self, tmp_path, claude_project):
state_path = claude_project / ".specify" / "integration.json"
unsafe_key = "../../../escape"
state_path.write_text(
json.dumps({
"integration": unsafe_key,
"default_integration": unsafe_key,
"installed_integrations": [unsafe_key],
}),
encoding="utf-8",
)
outside_manifest = tmp_path / "escape.manifest.json"
outside_manifest.write_text(
json.dumps({"integration": unsafe_key, "files": {}}),
encoding="utf-8",
)
result = _run_in_project(claude_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert unsafe_key not in payload["manifests"]
assert payload["manifest_checked_integrations"] == ["speckit"]
assert any(
item["code"] == "integration-key-invalid"
and item["integration"] == unsafe_key
for item in payload["findings"]
)
def test_status_rejects_filename_invalid_integration_keys(self, claude_project):
state_path = claude_project / ".specify" / "integration.json"
unsafe_key = "bad:key"
state_path.write_text(
json.dumps({
"integration": unsafe_key,
"default_integration": unsafe_key,
"installed_integrations": [unsafe_key],
}),
encoding="utf-8",
)
result = _run_in_project(claude_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert any(
item["code"] == "integration-key-invalid"
and item["integration"] == unsafe_key
for item in payload["findings"]
)
def test_status_rejects_windows_reserved_integration_keys(self, claude_project):
state_path = claude_project / ".specify" / "integration.json"
unsafe_key = "CON"
state_path.write_text(
json.dumps({
"integration": unsafe_key,
"default_integration": unsafe_key,
"installed_integrations": [unsafe_key],
}),
encoding="utf-8",
)
result = _run_in_project(claude_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert any(
item["code"] == "integration-key-invalid"
and item["integration"] == unsafe_key
for item in payload["findings"]
)
def test_status_reports_managed_file_collisions(self, claude_project):
from specify_cli.integrations.manifest import IntegrationManifest
state_path = claude_project / ".specify" / "integration.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state["installed_integrations"] = ["claude", "codex"]
state["default_integration"] = "claude"
state["integration"] = "claude"
state_path.write_text(json.dumps(state), encoding="utf-8")
claude_manifest = claude_project / ".specify" / "integrations" / "claude.manifest.json"
tracked_files = json.loads(claude_manifest.read_text(encoding="utf-8"))["files"]
shared_rel = next(iter(tracked_files))
codex_manifest = IntegrationManifest("codex", claude_project, version="test")
codex_manifest.record_existing(shared_rel)
codex_manifest.save()
result = _run_in_project(claude_project, ["integration", "status"])
assert result.exit_code == 0
assert "managed-file-collision" in result.output
assert "Integration status: WARNING" in result.output
def test_status_json_is_not_rich_rendered(self, tmp_path, monkeypatch):
project = tmp_path / "proj"
project.mkdir()
(project / ".specify").mkdir()
(project / ".specify" / "integration.json").write_text(
json.dumps({
"integration": "[red]x[/red]",
"installed_integrations": ["[red]x[/red]"],
}),
encoding="utf-8",
)
monkeypatch.chdir(project)
result = runner.invoke(app, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert payload["default_integration"] == "[red]x[/red]"
assert payload["installed_integrations"] == ["[red]x[/red]"]
def test_status_text_escapes_rich_markup_from_project_state(self, tmp_path, monkeypatch):
project = tmp_path / "proj"
project.mkdir()
(project / ".specify").mkdir()
(project / ".specify" / "integration.json").write_text(
json.dumps({
"integration": "[red]x[/red]",
"installed_integrations": ["[red]x[/red]"],
}),
encoding="utf-8",
)
monkeypatch.chdir(project)
result = runner.invoke(app, ["integration", "status"])
assert result.exit_code != 0
assert "Default integration: [red]x[/red]" in result.output
assert "Installed integrations: [red]x[/red]" in result.output
# ── install ────────────────────────────────────────────────────────── # ── install ──────────────────────────────────────────────────────────
@@ -961,7 +1806,7 @@ class TestIntegrationSwitch:
def test_switch_refreshes_managed_shared_script_refs(self, tmp_path): def test_switch_refreshes_managed_shared_script_refs(self, tmp_path):
"""Switching refreshes managed shared scripts to the target command style.""" """Switching refreshes managed shared scripts to the target command style."""
project = _init_project(tmp_path, "claude") project = _init_project(tmp_path, "claude")
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh" shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
assert shared_script.exists() assert shared_script.exists()
shared_content = shared_script.read_text(encoding="utf-8") shared_content = shared_script.read_text(encoding="utf-8")
assert "/speckit-plan" in shared_content assert "/speckit-plan" in shared_content
@@ -987,7 +1832,7 @@ class TestIntegrationSwitch:
import hashlib import hashlib
project = _init_project(tmp_path, "claude") project = _init_project(tmp_path, "claude")
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh" shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
assert "/speckit-plan" in shared_script.read_text(encoding="utf-8") assert "/speckit-plan" in shared_script.read_text(encoding="utf-8")
# Simulate a stale vendored script: write truncated content as bytes # Simulate a stale vendored script: write truncated content as bytes
@@ -999,7 +1844,7 @@ class TestIntegrationSwitch:
manifest_path = project / ".specify" / "integrations" / "speckit.manifest.json" manifest_path = project / ".specify" / "integrations" / "speckit.manifest.json"
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8")) manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest_data["files"][".specify/scripts/bash/common.sh"] = ( manifest_data["files"][".specify/scripts/bash/setup-tasks.sh"] = (
hashlib.sha256(stale_bytes).hexdigest() hashlib.sha256(stale_bytes).hexdigest()
) )
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8") manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
@@ -1048,7 +1893,7 @@ class TestIntegrationSwitch:
def test_switch_refresh_shared_infra_overwrites_customizations(self, tmp_path): def test_switch_refresh_shared_infra_overwrites_customizations(self, tmp_path):
"""--refresh-shared-infra explicitly overwrites user customizations on switch.""" """--refresh-shared-infra explicitly overwrites user customizations on switch."""
project = _init_project(tmp_path, "claude") project = _init_project(tmp_path, "claude")
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh" shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
assert "/speckit-plan" in shared_script.read_text(encoding="utf-8") assert "/speckit-plan" in shared_script.read_text(encoding="utf-8")
rendered_bytes = shared_script.read_bytes() rendered_bytes = shared_script.read_bytes()
@@ -1073,6 +1918,45 @@ class TestIntegrationSwitch:
assert "/speckit.plan" in updated assert "/speckit.plan" in updated
assert "/speckit-plan" not in updated assert "/speckit-plan" not in updated
def test_switch_preserves_recovered_files(self, tmp_path):
"""Regression for #2918: files marked recovered in the manifest are not overwritten.
When a file already exists on disk before init and is recorded with
``recovered=True``, ``integration use``/``switch`` must not treat it as
managed even when the on-disk hash matches the manifest hash.
"""
import hashlib
project = _init_project(tmp_path, "claude")
shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
assert shared_script.is_file()
# Simulate a team-customized file that was recorded as recovered:
# write custom content, then update the manifest to record its hash
# with the recovered flag set.
custom_bytes = b"#!/usr/bin/env bash\n# team custom workflow\nexit 0\n"
shared_script.write_bytes(custom_bytes)
manifest_path = project / ".specify" / "integrations" / "speckit.manifest.json"
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
rel = ".specify/scripts/bash/setup-tasks.sh"
manifest_data["files"][rel] = hashlib.sha256(custom_bytes).hexdigest()
manifest_data.setdefault("recovered_files", []).append(rel)
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
# Recovered file must NOT be overwritten — team content preserved.
assert shared_script.read_bytes() == custom_bytes
def test_switch_skips_symlinked_parent_directory(self, tmp_path): def test_switch_skips_symlinked_parent_directory(self, tmp_path):
"""Regression: if .specify/scripts/bash is a symlink, switch must not write through it. """Regression: if .specify/scripts/bash is a symlink, switch must not write through it.

View File

@@ -254,7 +254,6 @@ class TestMultiInstallSafeContracts:
initial, initial,
"--script", "--script",
"sh", "sh",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], ],
catch_exceptions=False, catch_exceptions=False,

View File

@@ -793,6 +793,35 @@ class TestRedirectStripping:
assert new_req.headers.get("Authorization") is None assert new_req.headers.get("Authorization") is None
assert new_req.unredirected_hdrs.get("Authorization") is None assert new_req.unredirected_hdrs.get("Authorization") is None
def test_https_to_http_same_host_redirect_strips_auth(self):
from specify_cli.authentication.http import _StripAuthOnRedirect
from urllib.request import Request
import io
handler = _StripAuthOnRedirect(("github.com",))
req = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
new_req = handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
"http://github.com/org/repo")
assert new_req is not None
assert new_req.headers.get("Authorization") is None
assert new_req.unredirected_hdrs.get("Authorization") is None
def test_redirect_validator_can_reject_before_following_redirect(self):
import urllib.error
from specify_cli.authentication.http import _StripAuthOnRedirect
from urllib.request import Request
import io
def reject_http(old_url, new_url):
if new_url.startswith("http://"):
raise urllib.error.URLError("scheme downgrade")
handler = _StripAuthOnRedirect(("github.com",), reject_http)
req = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
with pytest.raises(urllib.error.URLError, match="scheme downgrade"):
handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
"http://github.com/org/repo")
def test_multi_hop_redirect_within_hosts_preserves_auth(self): def test_multi_hop_redirect_within_hosts_preserves_auth(self):
"""Auth survives a multi-hop redirect chain within allowed hosts.""" """Auth survives a multi-hop redirect chain within allowed hosts."""
from specify_cli.authentication.http import _StripAuthOnRedirect from specify_cli.authentication.http import _StripAuthOnRedirect

View File

@@ -1,74 +1,24 @@
""" """
Unit tests for branch numbering options (sequential vs timestamp). Unit tests verifying --branch-numbering removal (v0.10.0).
Tests cover: Branch numbering is now managed entirely by the git extension's config.
- Persisting branch_numbering in init-options.json The --branch-numbering flag was removed from `specify init`.
- Default value when branch_numbering is None
- Validation of branch_numbering values
""" """
import json
from pathlib import Path from pathlib import Path
from specify_cli import save_init_options
class TestBranchNumberingFlagRemoved:
"""--branch-numbering flag was removed in v0.10.0."""
class TestSaveBranchNumbering: def test_branch_numbering_flag_is_rejected(self, tmp_path: Path):
"""Tests for save_init_options with branch_numbering."""
def test_save_branch_numbering_timestamp(self, tmp_path: Path):
opts = {"branch_numbering": "timestamp", "ai": "claude"}
save_init_options(tmp_path, opts)
saved = json.loads((tmp_path / ".specify/init-options.json").read_text())
assert saved["branch_numbering"] == "timestamp"
def test_save_branch_numbering_sequential(self, tmp_path: Path):
opts = {"branch_numbering": "sequential", "ai": "claude"}
save_init_options(tmp_path, opts)
saved = json.loads((tmp_path / ".specify/init-options.json").read_text())
assert saved["branch_numbering"] == "sequential"
def test_branch_numbering_defaults_to_sequential(self, tmp_path: Path):
from typer.testing import CliRunner
from specify_cli import app
project_dir = tmp_path / "proj"
runner = CliRunner()
result = runner.invoke(app, ["init", str(project_dir), "--integration", "claude", "--ignore-agent-tools", "--no-git", "--script", "sh"])
assert result.exit_code == 0
saved = json.loads((project_dir / ".specify/init-options.json").read_text())
assert saved["branch_numbering"] == "sequential"
class TestBranchNumberingValidation:
"""Tests for branch_numbering CLI validation via CliRunner."""
def test_invalid_branch_numbering_rejected(self, tmp_path: Path):
from typer.testing import CliRunner from typer.testing import CliRunner
from specify_cli import app from specify_cli import app
runner = CliRunner() runner = CliRunner()
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--integration", "claude", "--branch-numbering", "foobar", "--ignore-agent-tools"]) result = runner.invoke(app, [
assert result.exit_code == 1 "init", str(tmp_path / "proj"), "--integration", "claude",
assert "Invalid --branch-numbering" in result.output "--branch-numbering", "sequential", "--ignore-agent-tools",
])
def test_valid_branch_numbering_sequential(self, tmp_path: Path): assert result.exit_code != 0, "--branch-numbering should be rejected"
from typer.testing import CliRunner assert "No such option" in result.output or "no such option" in result.output.lower()
from specify_cli import app
runner = CliRunner()
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--integration", "claude", "--branch-numbering", "sequential", "--ignore-agent-tools", "--no-git", "--script", "sh"])
assert result.exit_code == 0
assert "Invalid --branch-numbering" not in (result.output or "")
def test_valid_branch_numbering_timestamp(self, tmp_path: Path):
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--integration", "claude", "--branch-numbering", "timestamp", "--ignore-agent-tools", "--no-git", "--script", "sh"])
assert result.exit_code == 0
assert "Invalid --branch-numbering" not in (result.output or "")

View File

@@ -34,6 +34,15 @@ def _install_ps_scripts(repo: Path) -> None:
shutil.copy(CHECK_PREREQS_PS, d / "check-prerequisites.ps1") shutil.copy(CHECK_PREREQS_PS, d / "check-prerequisites.ps1")
def _write_feature_json(
repo: Path, feature_directory: str = "specs/001-my-feature"
) -> None:
(repo / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": feature_directory}),
encoding="utf-8",
)
def _clean_env() -> dict[str, str]: def _clean_env() -> dict[str, str]:
env = os.environ.copy() env = os.environ.copy()
for key in list(env): for key in list(env):
@@ -69,7 +78,10 @@ def prereq_repo(tmp_path: Path) -> Path:
@requires_bash @requires_bash
def test_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None: def test_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
"""--paths-only must return paths without branch validation (main branch).""" """--paths-only must return paths when feature.json pins the feature dir."""
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
_write_feature_json(prereq_repo)
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
result = subprocess.run( result = subprocess.run(
["bash", str(script), "--json", "--paths-only"], ["bash", str(script), "--json", "--paths-only"],
@@ -88,20 +100,20 @@ def test_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
@requires_bash @requires_bash
def test_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None: def test_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
"""--paths-only must also work on a properly named spec branch.""" """--paths-only must also work when feature.json and SPECIFY_FEATURE agree."""
subprocess.run( feat = prereq_repo / "specs" / "001-my-feature"
["git", "checkout", "-q", "-b", "001-my-feature"], feat.mkdir(parents=True, exist_ok=True)
cwd=prereq_repo, _write_feature_json(prereq_repo)
check=True,
)
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
env = _clean_env()
env["SPECIFY_FEATURE"] = "001-my-feature"
result = subprocess.run( result = subprocess.run(
["bash", str(script), "--json", "--paths-only"], ["bash", str(script), "--json", "--paths-only"],
cwd=prereq_repo, cwd=prereq_repo,
capture_output=True, capture_output=True,
text=True, text=True,
check=False, check=False,
env=_clean_env(), env=env,
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
data = json.loads(result.stdout) data = json.loads(result.stdout)
@@ -111,7 +123,10 @@ def test_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
@requires_bash @requires_bash
def test_paths_only_text_mode_on_non_spec_branch(prereq_repo: Path) -> None: def test_paths_only_text_mode_on_non_spec_branch(prereq_repo: Path) -> None:
"""--paths-only without --json must return text paths on a non-spec branch.""" """--paths-only without --json must return text paths from feature.json."""
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
_write_feature_json(prereq_repo)
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
result = subprocess.run( result = subprocess.run(
["bash", str(script), "--paths-only"], ["bash", str(script), "--paths-only"],
@@ -128,7 +143,7 @@ def test_paths_only_text_mode_on_non_spec_branch(prereq_repo: Path) -> None:
@requires_bash @requires_bash
def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None: def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
"""Without --paths-only, branch validation must still fail on main.""" """Without --paths-only, feature directory validation must still fail on main."""
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
result = subprocess.run( result = subprocess.run(
["bash", str(script), "--json"], ["bash", str(script), "--json"],
@@ -139,7 +154,7 @@ def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
env=_clean_env(), env=_clean_env(),
) )
assert result.returncode != 0 assert result.returncode != 0
assert "Not on a feature branch" in result.stderr assert "Feature directory not found" in result.stderr
# ── PowerShell tests ────────────────────────────────────────────────────── # ── PowerShell tests ──────────────────────────────────────────────────────
@@ -147,7 +162,10 @@ def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") @pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_ps_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None: def test_ps_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
"""-PathsOnly must return paths without branch validation (main branch).""" """-PathsOnly must return paths when feature.json pins the feature dir."""
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
_write_feature_json(prereq_repo)
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1" script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _POWERSHELL exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run( result = subprocess.run(
@@ -167,21 +185,26 @@ def test_ps_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") @pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_ps_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None: def test_ps_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
"""-PathsOnly must also work on a properly named spec branch.""" """-PathsOnly must also work when feature.json and SPECIFY_FEATURE agree."""
subprocess.run( subprocess.run(
["git", "checkout", "-q", "-b", "001-my-feature"], ["git", "checkout", "-q", "-b", "001-my-feature"],
cwd=prereq_repo, cwd=prereq_repo,
check=True, check=True,
) )
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
_write_feature_json(prereq_repo)
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1" script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _POWERSHELL exe = "pwsh" if HAS_PWSH else _POWERSHELL
env = _clean_env()
env["SPECIFY_FEATURE"] = "001-my-feature"
result = subprocess.run( result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json", "-PathsOnly"], [exe, "-NoProfile", "-File", str(script), "-Json", "-PathsOnly"],
cwd=prereq_repo, cwd=prereq_repo,
capture_output=True, capture_output=True,
text=True, text=True,
check=False, check=False,
env=_clean_env(), env=env,
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
data = json.loads(result.stdout) data = json.loads(result.stdout)
@@ -190,7 +213,7 @@ def test_ps_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") @pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_ps_normal_mode_still_validates_branch(prereq_repo: Path) -> None: def test_ps_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
"""Without -PathsOnly, branch validation must still fail on main.""" """Without -PathsOnly, feature directory validation must still fail on main."""
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1" script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _POWERSHELL exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run( result = subprocess.run(
@@ -202,4 +225,5 @@ def test_ps_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
env=_clean_env(), env=_clean_env(),
) )
assert result.returncode != 0 assert result.returncode != 0
assert "Not on a feature branch" in result.stderr combined = result.stdout + result.stderr
assert "Feature directory not found" in combined

View File

@@ -303,6 +303,135 @@ class TestExtensionSkillRegistration:
assert "description" in parsed assert "description" in parsed
assert parsed["disable-model-invocation"] is False assert parsed["disable-model-invocation"] is False
def test_argument_hint_preserved_for_extension_command(
self, skills_project, temp_dir
):
"""argument-hint from an extension command must survive into SKILL.md.
Regression for #2903: the field was dropped for extension-provided
commands while being kept for core template commands. The source
description is intentionally long so it folds across multiple lines
when serialized, guarding against an in-place string injection that
would split the folded scalar and produce invalid YAML.
"""
project_dir, skills_dir = skills_project
long_description = (
"Build and maintain a lean, static context/ knowledge folder so "
"coding agents load only what is relevant and save tokens"
)
arg_hint = "<init | update | list | check> [area] [slug] [-- notes]"
ext_dir = temp_dir / "hint-ext"
ext_dir.mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "hint-ext",
"name": "Hint Extension",
"version": "1.0.0",
"description": "Extension exercising argument-hint preservation",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.hint-ext.build-context",
"file": "commands/build-context.md",
"description": long_description,
}
]
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.dump(manifest_data, f)
commands_dir = ext_dir / "commands"
commands_dir.mkdir()
(commands_dir / "build-context.md").write_text(
"---\n"
f'description: "{long_description}"\n'
f'argument-hint: "{arg_hint}"\n'
"---\n"
"\n"
"# Build Context\n"
"\n"
"Do the thing.\n"
"$ARGUMENTS\n",
encoding="utf-8",
)
manager = ExtensionManager(project_dir)
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
skill_file = skills_dir / "speckit-hint-ext-build-context" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text(encoding="utf-8")
# Frontmatter must parse cleanly even though the description folds.
parts = content.split("---", 2)
assert len(parts) >= 3
parsed = yaml.safe_load(parts[1])
assert parsed["argument-hint"] == arg_hint
assert parsed["description"] == long_description
def test_argument_hint_not_added_for_non_claude_agent(self, project_dir, temp_dir):
"""argument-hint must stay Claude-only — other skills agents are untouched.
The hint is carried only for integrations that support it (currently
Claude, the sole integration defining inject_argument_hint). A non-Claude
skills agent such as kimi must keep the shared build_skill_frontmatter
shape (name/description/compatibility/metadata) with no argument-hint.
"""
_create_init_options(project_dir, ai="kimi", ai_skills=True)
skills_dir = _create_skills_dir(project_dir, ai="kimi")
arg_hint = "<init | update | list | check> [area]"
ext_dir = temp_dir / "hint-ext-kimi"
ext_dir.mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "hint-ext-kimi",
"name": "Hint Extension Kimi",
"version": "1.0.0",
"description": "Extension exercising argument-hint gating",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.hint-ext-kimi.build-context",
"file": "commands/build-context.md",
"description": "Build context",
}
]
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.dump(manifest_data, f)
commands_dir = ext_dir / "commands"
commands_dir.mkdir()
(commands_dir / "build-context.md").write_text(
"---\n"
'description: "Build context"\n'
f'argument-hint: "{arg_hint}"\n'
"---\n"
"\n"
"# Build Context\n"
"\n"
"Do the thing.\n"
"$ARGUMENTS\n",
encoding="utf-8",
)
manager = ExtensionManager(project_dir)
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
skill_file = skills_dir / "speckit-hint-ext-kimi-build-context" / "SKILL.md"
assert skill_file.exists()
parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1])
assert "argument-hint" not in parsed
def test_no_skills_when_ai_skills_disabled(self, no_skills_project, extension_dir): def test_no_skills_when_ai_skills_disabled(self, no_skills_project, extension_dir):
"""No skills should be created when ai_skills is false.""" """No skills should be created when ai_skills is false."""
manager = ExtensionManager(no_skills_project) manager = ExtensionManager(no_skills_project)

View File

@@ -24,6 +24,7 @@ from specify_cli.extensions import (
CatalogEntry, CatalogEntry,
CORE_COMMAND_NAMES, CORE_COMMAND_NAMES,
DEFAULT_HOOK_PRIORITY, DEFAULT_HOOK_PRIORITY,
VALID_EFFECTS,
ExtensionManifest, ExtensionManifest,
ExtensionRegistry, ExtensionRegistry,
ExtensionManager, ExtensionManager,
@@ -300,6 +301,69 @@ class TestExtensionManifest:
with pytest.raises(ValidationError, match="Invalid version"): with pytest.raises(ValidationError, match="Invalid version"):
ExtensionManifest(manifest_path) ExtensionManifest(manifest_path)
def test_valid_category(self, temp_dir, valid_manifest_data):
"""Test manifest with various category values (free-form string)."""
import yaml
for category in ("docs", "code", "process", "integration", "visibility", "custom-category"):
valid_manifest_data["extension"]["category"] = category
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
manifest = ExtensionManifest(manifest_path)
assert manifest.category == category
def test_valid_effect(self, temp_dir, valid_manifest_data):
"""Test manifest with valid effect values."""
import yaml
for effect in sorted(VALID_EFFECTS):
valid_manifest_data["extension"]["effect"] = effect
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
manifest = ExtensionManifest(manifest_path)
assert manifest.effect == effect
def test_invalid_category(self, temp_dir, valid_manifest_data):
"""Test manifest with empty category raises ValidationError."""
import yaml
valid_manifest_data["extension"]["category"] = ""
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="Invalid extension.category"):
ExtensionManifest(manifest_path)
def test_invalid_effect(self, temp_dir, valid_manifest_data):
"""Test manifest with invalid effect raises ValidationError."""
import yaml
valid_manifest_data["extension"]["effect"] = "write-only"
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="Invalid extension.effect"):
ExtensionManifest(manifest_path)
def test_category_and_effect_optional(self, temp_dir, valid_manifest_data):
"""Test that omitting category and effect still passes validation."""
import yaml
# Ensure no category/effect in data
valid_manifest_data["extension"].pop("category", None)
valid_manifest_data["extension"].pop("effect", None)
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
manifest = ExtensionManifest(manifest_path)
assert manifest.category is None
assert manifest.effect is None
def test_invalid_command_name(self, temp_dir, valid_manifest_data): def test_invalid_command_name(self, temp_dir, valid_manifest_data):
"""Test manifest with command name that cannot be auto-corrected raises ValidationError.""" """Test manifest with command name that cannot be auto-corrected raises ValidationError."""
import yaml import yaml
@@ -3087,6 +3151,424 @@ class TestExtensionCatalog:
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken" assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
@pytest.mark.parametrize(
"payload",
[
# Root is not a JSON object.
[],
"oops",
42,
None,
# Root is fine but ``extensions`` is the wrong type.
{"schema_version": "1.0", "extensions": []},
{"schema_version": "1.0", "extensions": "oops"},
{"schema_version": "1.0", "extensions": None},
{"schema_version": "1.0", "extensions": 42},
],
)
def test_fetch_single_catalog_rejects_malformed_payload(self, temp_dir, payload):
"""Malformed catalog payloads raise ExtensionError, not AttributeError.
Without this guard, a payload like ``{"extensions": []}`` would pass the
key-presence check and then crash with ``AttributeError: 'list' object
has no attribute 'items'`` deep inside ``_get_merged_extensions``. The
sibling integration catalog reader already validates both the root
object and the nested mapping (see ``integrations/catalog.py``); the
extension catalog must stay consistent.
"""
from unittest.mock import patch, MagicMock
catalog = self._make_catalog(temp_dir)
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(payload).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
entry = CatalogEntry(
url="https://example.com/catalog.json",
name="default",
priority=1,
install_allowed=True,
)
with patch.object(catalog, "_open_url", return_value=mock_response):
with pytest.raises(ExtensionError, match="Invalid catalog format"):
catalog._fetch_single_catalog(entry, force_refresh=True)
@pytest.mark.parametrize(
"cached_payload",
[
[],
"oops",
42,
None,
{"schema_version": "1.0", "extensions": []},
{"schema_version": "1.0", "extensions": "oops"},
{"schema_version": "1.0", "extensions": None},
],
)
def test_fetch_single_catalog_rejects_malformed_cached_payload(
self, temp_dir, cached_payload
):
"""A poisoned cache silently falls back to the network instead of
crashing — cached payloads pass through the same shape validation
as freshly-fetched ones.
Without this, a cache poisoned by an older spec-kit version (or a
manual edit, or an upstream that briefly served a bad payload
before the network guards landed) would re-crash every invocation
of ``_get_merged_extensions`` despite the cache being "valid" by
age. The recovery contract is: if the cached payload fails
validation, drop it and refetch — never propagate
``AttributeError`` to the caller.
"""
from unittest.mock import patch, MagicMock
catalog = self._make_catalog(temp_dir)
# Poison the default-URL cache. ``DEFAULT_CATALOG_URL`` is the
# branch that goes through ``is_cache_valid()`` (the non-default
# branch uses per-URL hashed cache files but the same code path
# below).
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text(json.dumps(cached_payload))
catalog.cache_metadata_file.write_text(
json.dumps(
{
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": ExtensionCatalog.DEFAULT_CATALOG_URL,
}
)
)
# Network refetch returns a valid payload so the recovery path
# can complete.
valid = {
"schema_version": "1.0",
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(valid).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
entry = CatalogEntry(
url=ExtensionCatalog.DEFAULT_CATALOG_URL,
name="default",
priority=1,
install_allowed=True,
)
with patch.object(catalog, "_open_url", return_value=mock_response):
result = catalog._fetch_single_catalog(entry, force_refresh=False)
# The poisoned cache was discarded and the network payload returned.
assert result == valid
@pytest.mark.parametrize(
"payload",
[
# Root is not a JSON object.
[],
"oops",
42,
None,
# Root is fine but ``extensions`` is the wrong type.
{"schema_version": "1.0", "extensions": []},
{"schema_version": "1.0", "extensions": "oops"},
{"schema_version": "1.0", "extensions": None},
],
)
def test_fetch_catalog_rejects_malformed_payload(self, temp_dir, payload):
"""Legacy ``fetch_catalog`` reuses the same shape-validation helper.
Before this change ``fetch_catalog`` only checked key presence — so
a payload like ``42`` would crash with
``TypeError: argument of type 'int' is not iterable`` during the
``"schema_version" in catalog_data`` check, and an entry mapping
of the wrong type would crash downstream. Reusing
``_validate_catalog_payload`` keeps the network-side behaviour of
the legacy single-catalog method consistent with the multi-catalog
``_fetch_single_catalog`` path.
"""
from unittest.mock import patch, MagicMock
catalog = self._make_catalog(temp_dir)
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(payload).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
with patch.object(catalog, "_open_url", return_value=mock_response):
with pytest.raises(ExtensionError, match="Invalid catalog format"):
catalog.fetch_catalog(force_refresh=True)
def test_fetch_catalog_recovers_from_unreadable_cache(self, temp_dir):
"""An unreadable / wrong-encoded cache file silently refetches.
The cache contract is best-effort: a JSON-decode failure, an OS
read failure (permissions / disk / handle limit), or an invalid
text encoding on a cache file written by an older client must
all fall through to the network fetch rather than crash the
caller. Covers Copilot's review point that the previous
``except (json.JSONDecodeError,)`` was too narrow.
"""
from unittest.mock import patch, MagicMock
catalog = self._make_catalog(temp_dir)
# Write invalid UTF-8 bytes to the cache file so ``read_text``
# raises ``UnicodeDecodeError`` (a subclass of ``UnicodeError``).
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_bytes(b"\xff\xfe\x00not-utf-8")
catalog.cache_metadata_file.write_text(
json.dumps(
{
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": ExtensionCatalog.DEFAULT_CATALOG_URL,
}
),
encoding="utf-8",
)
valid = {
"schema_version": "1.0",
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(valid).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
with patch.object(catalog, "_open_url", return_value=mock_response):
result = catalog.fetch_catalog(force_refresh=False)
# Recovered via network rather than crashing on the unreadable cache.
assert result == valid
def test_fetch_catalog_recovers_from_unreadable_metadata(self, temp_dir):
"""A wrongly-encoded metadata file degrades to a cache miss.
``is_cache_valid`` is consulted *before* the cache payload is
read; if the metadata file itself can't be decoded (e.g. it was
written on a Windows host whose default codec isn't UTF-8) the
validity check must return ``False`` rather than propagate
``UnicodeDecodeError``. Without that guard, a corrupted metadata
file would crash every invocation instead of falling through to
a network refetch.
"""
from unittest.mock import patch, MagicMock
catalog = self._make_catalog(temp_dir)
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text("{}", encoding="utf-8")
# Bytes that are not valid UTF-8 — ``read_text(encoding="utf-8")``
# will raise ``UnicodeDecodeError`` (subclass of ``UnicodeError``).
catalog.cache_metadata_file.write_bytes(b"\xff\xfe\x00bad")
# is_cache_valid must absorb the decode failure, not crash.
assert catalog.is_cache_valid() is False
valid = {
"schema_version": "1.0",
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(valid).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
with patch.object(catalog, "_open_url", return_value=mock_response):
result = catalog.fetch_catalog(force_refresh=False)
assert result == valid
@pytest.mark.parametrize(
"non_mapping_metadata",
[
"[]", # JSON array
'"oops"', # JSON string
"42", # JSON number
"true", # JSON bool
"null", # JSON null
],
)
def test_is_cache_valid_handles_non_mapping_metadata(
self, temp_dir, non_mapping_metadata
):
"""Metadata that parses to a non-mapping degrades to cache-invalid.
The cache-validity check calls ``metadata.get("cached_at", "")``
immediately after ``json.loads``. If the metadata file is valid
JSON but parses to a non-mapping (``[]``, ``"oops"``, ``42``,
``true``, ``null``), ``.get`` raises ``AttributeError`` — which
previously slipped past the except tuple and crashed the
caller. The contract documented on ``is_cache_valid`` says any
decode/shape failure should return ``False`` so ``fetch_catalog``
falls through to a network refetch. This test pins that
contract across every JSON non-mapping root type so a regression
in the except clause can't silently re-introduce the crash.
"""
catalog = self._make_catalog(temp_dir)
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text("{}", encoding="utf-8")
catalog.cache_metadata_file.write_text(
non_mapping_metadata, encoding="utf-8"
)
# Must not raise — the contract is "any decode/shape failure → False".
assert catalog.is_cache_valid() is False
def test_fetch_catalog_writes_cache_as_utf8(self, temp_dir, monkeypatch):
"""Cache + metadata writes pass ``encoding="utf-8"``, observably.
The earlier version of this test claimed to assert UTF-8 at the
byte level but actually only round-tripped a non-ASCII string
through ``json.dumps`` and ``read_text(encoding="utf-8")``.
Because ``json.dumps`` defaults to ``ensure_ascii=True``, "café"
was serialized as the all-ASCII escape ``caf\\u00e9`` before it
ever reached ``write_text`` — the bytes on disk were identical
regardless of the encoding kwarg, so a locale-encoded write
would have round-tripped just fine. The drift Copilot's review
flagged wasn't actually being caught.
Fix: directly observe the ``encoding`` argument passed to every
``write_text`` call made against the cache directory. This is
the production code's encoding choice, which is exactly what
the regression guard cares about; non-ASCII payload tricks are
unnecessary because the assertion is about the kwarg, not the
bytes.
"""
from unittest.mock import patch, MagicMock
from pathlib import Path as _PathCls
catalog = self._make_catalog(temp_dir)
payload = {
"schema_version": "1.0",
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(payload).encode("utf-8")
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
# Record every ``write_text`` call's encoding kwarg so the
# assertion observes the production writer's argument directly.
recorded: list[dict] = []
real_write_text = _PathCls.write_text
def recording_write_text(self, data, *args, **kwargs):
recorded.append(
{"path": str(self), "encoding": kwargs.get("encoding")}
)
return real_write_text(self, data, *args, **kwargs)
monkeypatch.setattr(_PathCls, "write_text", recording_write_text)
with patch.object(catalog, "_open_url", return_value=mock_response):
catalog.fetch_catalog(force_refresh=True)
# Filter to writes inside the catalog's cache directory so
# unrelated writes from other machinery don't pollute the
# assertion.
cache_writes = [
r for r in recorded if str(catalog.cache_dir) in r["path"]
]
assert cache_writes, "fetch_catalog made no writes to the cache dir"
for record in cache_writes:
assert record["encoding"] == "utf-8", (
f"write_text on {record['path']} used encoding "
f"{record['encoding']!r}; expected 'utf-8'"
)
def test_fetch_catalog_survives_unwritable_cache(self, temp_dir, monkeypatch):
"""An unwritable cache dir doesn't fail a successful fetch.
Cache writes are best-effort, mirroring the read side and the
``integrations/catalog.py`` precedent: if ``mkdir``/``write_text``
raises ``OSError`` (read-only checkout, permissions), the
already-fetched-and-validated payload must still be returned
rather than surfacing the cache failure to the caller.
"""
from unittest.mock import patch, MagicMock
from pathlib import Path as _PathCls
catalog = self._make_catalog(temp_dir)
valid = {
"schema_version": "1.0",
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(valid).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
# Simulate an unwritable cache dir: every write_text under the
# cache directory raises PermissionError (an OSError subclass).
real_write_text = _PathCls.write_text
def failing_write_text(self, data, *args, **kwargs):
if str(catalog.cache_dir) in str(self):
raise PermissionError("cache dir is read-only")
return real_write_text(self, data, *args, **kwargs)
monkeypatch.setattr(_PathCls, "write_text", failing_write_text)
with patch.object(catalog, "_open_url", return_value=mock_response):
# Legacy single-catalog path.
assert catalog.fetch_catalog(force_refresh=True) == valid
# Multi-catalog path.
entry = CatalogEntry(
url="https://example.com/catalog.json",
name="default",
priority=1,
install_allowed=True,
)
assert catalog._fetch_single_catalog(entry, force_refresh=True) == valid
def test_get_merged_extensions_skips_non_mapping_entries(self, temp_dir):
"""Per-entry guard: one malformed entry shouldn't poison the merge.
``_fetch_single_catalog`` validates that ``extensions`` is a mapping,
but it doesn't (and shouldn't) validate every entry inside it — a
single bad entry in an otherwise-valid catalog should be skipped, not
crash the whole resolve path. Mirrors the per-entry skip in
``integrations/catalog.py``: a malformed entry returns no error,
valid entries continue to merge normally.
"""
from unittest.mock import patch, MagicMock
catalog = self._make_catalog(temp_dir)
# Mix of valid entry, list-shaped entry, and string-shaped entry.
payload = {
"schema_version": "1.0",
"extensions": {
"good": {"name": "Good", "version": "1.0.0"},
"bad-list": [],
"bad-str": "oops",
},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(payload).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
entry = CatalogEntry(
url="https://example.com/catalog.json",
name="default",
priority=1,
install_allowed=True,
)
with patch.object(catalog, "_open_url", return_value=mock_response), \
patch.object(catalog, "get_active_catalogs", return_value=[entry]):
merged = catalog._get_merged_extensions(force_refresh=True)
# Only the well-formed entry survives; the two malformed entries are
# silently dropped rather than raising or crashing.
assert [ext["id"] for ext in merged] == ["good"]
def test_download_extension_sends_auth_header(self, temp_dir, monkeypatch): def test_download_extension_sends_auth_header(self, temp_dir, monkeypatch):
"""download_extension passes Authorization header when a provider is configured.""" """download_extension passes Authorization header when a provider is configured."""
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock

View File

@@ -187,4 +187,4 @@ class TestResolveGitHubReleaseAssetApiUrl:
capturing_open, capturing_open,
) )
assert len(captured_urls) == 1 assert len(captured_urls) == 1
assert "releases/tags/v1%23beta" in captured_urls[0] assert "releases/tags/v1%23beta" in captured_urls[0]

View File

@@ -11,6 +11,7 @@ Tests cover:
""" """
import pytest import pytest
import io
import json import json
import tempfile import tempfile
import shutil import shutil
@@ -18,6 +19,7 @@ import warnings
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from datetime import datetime, timezone from datetime import datetime, timezone
from types import SimpleNamespace
import yaml import yaml
@@ -1514,6 +1516,421 @@ class TestPresetCatalog:
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken" assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
@pytest.mark.parametrize(
"payload",
[
# Root is not a JSON object.
[],
"oops",
42,
None,
# Root is fine but ``presets`` is the wrong type.
{"schema_version": "1.0", "presets": []},
{"schema_version": "1.0", "presets": "oops"},
{"schema_version": "1.0", "presets": None},
{"schema_version": "1.0", "presets": 42},
],
)
def test_fetch_single_catalog_rejects_malformed_payload(self, project_dir, payload):
"""Malformed catalog payloads raise PresetError, not AttributeError.
Without this guard, a payload like ``{"presets": []}`` would pass the
key-presence check and then crash with ``AttributeError: 'list' object
has no attribute 'items'`` deep inside ``_get_merged_packs``. The
sibling integration catalog reader already validates both the root
object and the nested mapping (see ``integrations/catalog.py``); the
preset catalog must stay consistent.
"""
from unittest.mock import patch, MagicMock
catalog = PresetCatalog(project_dir)
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(payload).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
entry = PresetCatalogEntry(
url="https://example.com/catalog.json",
name="default",
priority=1,
install_allowed=True,
)
with patch.object(catalog, "_open_url", return_value=mock_response):
with pytest.raises(PresetError, match="Invalid preset catalog format"):
catalog._fetch_single_catalog(entry, force_refresh=True)
@pytest.mark.parametrize(
"cached_payload",
[
[],
"oops",
42,
None,
{"schema_version": "1.0", "presets": []},
{"schema_version": "1.0", "presets": "oops"},
{"schema_version": "1.0", "presets": None},
],
)
def test_fetch_single_catalog_rejects_malformed_cached_payload(
self, project_dir, cached_payload
):
"""A poisoned cache silently falls back to the network instead of
crashing — cached payloads pass through the same shape validation
as freshly-fetched ones.
Without this, a cache poisoned by an older spec-kit version (or a
manual edit, or an upstream that briefly served a bad payload
before the network guards landed) would re-crash every invocation
of ``_get_merged_packs`` despite the cache being "valid" by age.
The recovery contract is: if the cached payload fails validation,
drop it and refetch — never propagate ``AttributeError`` to the
caller.
"""
from unittest.mock import patch, MagicMock
catalog = PresetCatalog(project_dir)
# Poison the default-URL cache. ``DEFAULT_CATALOG_URL`` and
# non-default URLs both flow through the same cache-load branch.
cache_file, metadata_file = catalog._get_cache_paths(
catalog.DEFAULT_CATALOG_URL
)
cache_file.parent.mkdir(parents=True, exist_ok=True)
cache_file.write_text(json.dumps(cached_payload))
metadata_file.write_text(
json.dumps(
{
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": catalog.DEFAULT_CATALOG_URL,
}
)
)
# Network refetch returns a valid payload so the recovery path
# can complete.
valid = {
"schema_version": "1.0",
"presets": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(valid).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
entry = PresetCatalogEntry(
url=catalog.DEFAULT_CATALOG_URL,
name="default",
priority=1,
install_allowed=True,
)
with patch.object(catalog, "_open_url", return_value=mock_response):
result = catalog._fetch_single_catalog(entry, force_refresh=False)
# The poisoned cache was discarded and the network payload returned.
assert result == valid
@pytest.mark.parametrize(
"payload",
[
# Root is not a JSON object.
[],
"oops",
42,
None,
# Root is fine but ``presets`` is the wrong type.
{"schema_version": "1.0", "presets": []},
{"schema_version": "1.0", "presets": "oops"},
{"schema_version": "1.0", "presets": None},
],
)
def test_fetch_catalog_rejects_malformed_payload(self, project_dir, payload):
"""Legacy ``fetch_catalog`` reuses the same shape-validation helper.
Before this change ``fetch_catalog`` only checked key presence —
so a payload like ``42`` would crash with
``TypeError: argument of type 'int' is not iterable`` during the
``"schema_version" in catalog_data`` check, and an entry mapping
of the wrong type would crash downstream. Reusing
``_validate_catalog_payload`` keeps the network-side behaviour of
the legacy single-catalog method consistent with the multi-catalog
``_fetch_single_catalog`` path.
"""
from unittest.mock import patch, MagicMock
catalog = PresetCatalog(project_dir)
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(payload).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
with patch.object(catalog, "_open_url", return_value=mock_response):
with pytest.raises(PresetError, match="Invalid preset catalog format"):
catalog.fetch_catalog(force_refresh=True)
def test_fetch_catalog_recovers_from_unreadable_cache(self, project_dir):
"""An unreadable / wrong-encoded cache file silently refetches.
The cache contract is best-effort: a JSON-decode failure, an OS
read failure (permissions / disk / handle limit), or an invalid
text encoding on a cache file written by an older client must
all fall through to the network fetch rather than crash the
caller. Covers Copilot's review point that the previous
``except (json.JSONDecodeError, OSError)`` was missing
``UnicodeError``.
"""
from unittest.mock import patch, MagicMock
catalog = PresetCatalog(project_dir)
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
# Invalid UTF-8 bytes so ``read_text`` raises ``UnicodeDecodeError``
# (a subclass of ``UnicodeError``).
catalog.cache_file.write_bytes(b"\xff\xfe\x00not-utf-8")
catalog.cache_metadata_file.write_text(
json.dumps(
{
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": catalog.get_catalog_url(),
}
),
encoding="utf-8",
)
valid = {
"schema_version": "1.0",
"presets": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(valid).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
with patch.object(catalog, "_open_url", return_value=mock_response):
result = catalog.fetch_catalog(force_refresh=False)
# Recovered via network rather than crashing on the unreadable cache.
assert result == valid
def test_fetch_catalog_recovers_from_unreadable_metadata(self, project_dir):
"""A wrongly-encoded metadata file degrades to a cache miss.
``is_cache_valid`` is consulted *before* the cache payload is
read; if the metadata file itself can't be decoded (e.g. it was
written on a host whose default codec isn't UTF-8) the validity
check must return ``False`` rather than propagate
``UnicodeDecodeError``. Without that guard, a corrupted metadata
file would crash every invocation instead of falling through to
a network refetch.
"""
from unittest.mock import patch, MagicMock
catalog = PresetCatalog(project_dir)
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text("{}", encoding="utf-8")
# Bytes that are not valid UTF-8 — ``read_text(encoding="utf-8")``
# will raise ``UnicodeDecodeError`` (subclass of ``UnicodeError``).
catalog.cache_metadata_file.write_bytes(b"\xff\xfe\x00bad")
# is_cache_valid must absorb the decode failure, not crash.
assert catalog.is_cache_valid() is False
valid = {
"schema_version": "1.0",
"presets": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(valid).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
with patch.object(catalog, "_open_url", return_value=mock_response):
result = catalog.fetch_catalog(force_refresh=False)
assert result == valid
@pytest.mark.parametrize(
"non_mapping_metadata",
[
"[]", # JSON array
'"oops"', # JSON string
"42", # JSON number
"true", # JSON bool
"null", # JSON null
],
)
def test_is_cache_valid_handles_non_mapping_metadata(
self, project_dir, non_mapping_metadata
):
"""Metadata that parses to a non-mapping degrades to cache-invalid.
The cache-validity check calls ``metadata.get("cached_at", "")``
immediately after ``json.loads``. If the metadata file is valid
JSON but parses to a non-mapping (``[]``, ``"oops"``, ``42``,
``true``, ``null``), ``.get`` raises ``AttributeError`` — which
previously slipped past the except tuple and crashed the
caller. The contract documented on ``is_cache_valid`` says any
decode/shape failure should return ``False`` so ``fetch_catalog``
falls through to a network refetch. This test pins that
contract across every JSON non-mapping root type.
"""
catalog = PresetCatalog(project_dir)
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
catalog.cache_file.write_text("{}", encoding="utf-8")
catalog.cache_metadata_file.write_text(
non_mapping_metadata, encoding="utf-8"
)
# Must not raise — the contract is "any decode/shape failure → False".
assert catalog.is_cache_valid() is False
def test_fetch_catalog_writes_cache_as_utf8(self, project_dir, monkeypatch):
"""Cache + metadata writes pass ``encoding="utf-8"``, observably.
The earlier version of this test claimed to assert UTF-8 at the
byte level but actually only round-tripped a non-ASCII string
through ``json.dumps`` and ``read_text(encoding="utf-8")``.
Because ``json.dumps`` defaults to ``ensure_ascii=True``, "café"
was serialized as the all-ASCII escape ``caf\\u00e9`` before it
ever reached ``write_text`` — the bytes on disk were identical
regardless of the encoding kwarg. The drift Copilot's review
flagged wasn't actually being caught.
Fix: directly observe the ``encoding`` argument passed to every
``write_text`` call made against the cache directory. This is
the production code's encoding choice, which is exactly what
the regression guard cares about.
"""
from unittest.mock import patch, MagicMock
from pathlib import Path as _PathCls
catalog = PresetCatalog(project_dir)
payload = {
"schema_version": "1.0",
"presets": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(payload).encode("utf-8")
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
# Record every ``write_text`` call's encoding kwarg so the
# assertion observes the production writer's argument directly.
recorded: list[dict] = []
real_write_text = _PathCls.write_text
def recording_write_text(self, data, *args, **kwargs):
recorded.append(
{"path": str(self), "encoding": kwargs.get("encoding")}
)
return real_write_text(self, data, *args, **kwargs)
monkeypatch.setattr(_PathCls, "write_text", recording_write_text)
with patch.object(catalog, "_open_url", return_value=mock_response):
catalog.fetch_catalog(force_refresh=True)
cache_writes = [
r for r in recorded if str(catalog.cache_dir) in r["path"]
]
assert cache_writes, "fetch_catalog made no writes to the cache dir"
for record in cache_writes:
assert record["encoding"] == "utf-8", (
f"write_text on {record['path']} used encoding "
f"{record['encoding']!r}; expected 'utf-8'"
)
def test_fetch_catalog_survives_unwritable_cache(self, project_dir, monkeypatch):
"""An unwritable cache dir doesn't fail a successful fetch.
Cache writes are best-effort, mirroring the read side and the
``integrations/catalog.py`` precedent: if ``mkdir``/``write_text``
raises ``OSError`` (read-only checkout, permissions), the
already-fetched-and-validated payload must still be returned —
not swallowed into the broad except and re-raised as a
``PresetError``.
"""
from unittest.mock import patch, MagicMock
from pathlib import Path as _PathCls
catalog = PresetCatalog(project_dir)
valid = {
"schema_version": "1.0",
"presets": {"foo": {"name": "Foo", "version": "1.0.0"}},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(valid).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
# Simulate an unwritable cache dir: every write_text under the
# cache directory raises PermissionError (an OSError subclass).
real_write_text = _PathCls.write_text
def failing_write_text(self, data, *args, **kwargs):
if str(catalog.cache_dir) in str(self):
raise PermissionError("cache dir is read-only")
return real_write_text(self, data, *args, **kwargs)
monkeypatch.setattr(_PathCls, "write_text", failing_write_text)
with patch.object(catalog, "_open_url", return_value=mock_response):
# Legacy single-catalog path.
assert catalog.fetch_catalog(force_refresh=True) == valid
# Multi-catalog path.
entry = PresetCatalogEntry(
url=catalog.DEFAULT_CATALOG_URL,
name="default",
priority=1,
install_allowed=True,
)
assert (
catalog._fetch_single_catalog(entry, force_refresh=True) == valid
)
def test_get_merged_packs_skips_non_mapping_entries(self, project_dir):
"""Per-entry guard: one malformed entry shouldn't poison the merge.
``_fetch_single_catalog`` validates that ``presets`` is a mapping,
but it doesn't (and shouldn't) validate every entry inside it — a
single bad entry in an otherwise-valid catalog should be skipped,
not crash the whole resolve path. Mirrors the per-entry skip in
``integrations/catalog.py``: a malformed entry returns no error,
valid entries continue to merge normally.
"""
from unittest.mock import patch, MagicMock
catalog = PresetCatalog(project_dir)
payload = {
"schema_version": "1.0",
"presets": {
"good": {"name": "Good", "version": "1.0.0"},
"bad-list": [],
"bad-str": "oops",
},
}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(payload).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
entry = PresetCatalogEntry(
url="https://example.com/catalog.json",
name="default",
priority=1,
install_allowed=True,
)
with patch.object(catalog, "_open_url", return_value=mock_response), \
patch.object(catalog, "get_active_catalogs", return_value=[entry]):
merged = catalog._get_merged_packs(force_refresh=True)
# Only the well-formed entry survives; the two malformed entries are
# silently dropped rather than raising or crashing.
assert list(merged.keys()) == ["good"]
def test_download_pack_sends_auth_header(self, project_dir, monkeypatch): def test_download_pack_sends_auth_header(self, project_dir, monkeypatch):
"""download_pack passes Authorization header when configured.""" """download_pack passes Authorization header when configured."""
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
@@ -3842,6 +4259,141 @@ class TestBundledPresetLocator:
assert "Lean Workflow" in result.output assert "Lean Workflow" in result.output
assert "installed" in result.output.lower() assert "installed" in result.output.lower()
def test_preset_add_from_url_rejects_insecure_redirect(self, project_dir, monkeypatch):
"""URL installs reject redirects from HTTPS to non-loopback HTTP."""
import typer
from specify_cli import preset_add
class FakeResponse(io.BytesIO):
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def geturl(self):
return "http://example.com/preset.zip"
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0")
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
assert redirect_validator is not None
redirect_validator(url, "http://example.com/preset.zip")
return FakeResponse(b"zip")
monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url)
installed = False
def fake_install_from_zip(self, zip_path, speckit_version, priority=10):
nonlocal installed
installed = True
monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip)
with pytest.raises(typer.Exit) as exc_info:
preset_add(preset_id=None, from_url="https://example.com/preset.zip", dev=None, priority=10)
assert exc_info.value.exit_code == 1
assert installed is False
def test_preset_add_from_url_rejects_hostless_https_url(self, project_dir):
"""URL installs reject HTTPS URLs without a hostname before downloading."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("specify_cli.authentication.http.open_url") as open_url:
result = runner.invoke(app, ["preset", "add", "--from", "https:///preset.zip"])
assert result.exit_code == 1
output = strip_ansi(result.output)
assert "URL must use HTTPS with a hostname" in output
assert "got https://" not in output
open_url.assert_not_called()
def test_preset_add_from_url_redirect_error_describes_disallowed_url(self, project_dir, monkeypatch, capsys):
"""Redirect rejection message covers hostless HTTPS, not only non-HTTPS URLs."""
import typer
from specify_cli import preset_add
class FakeResponse(io.BytesIO):
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def geturl(self):
return "https:///preset.zip"
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0")
monkeypatch.setattr(
"specify_cli.authentication.http.open_url",
lambda url, timeout=None, extra_headers=None, redirect_validator=None: FakeResponse(b"zip"),
)
monkeypatch.setattr(PresetManager, "install_from_zip", lambda *args, **kwargs: None)
with pytest.raises(typer.Exit) as exc_info:
preset_add(preset_id=None, from_url="https://example.com/preset.zip", dev=None, priority=10)
assert exc_info.value.exit_code == 1
output = strip_ansi(capsys.readouterr().out)
assert "redirected to a disallowed URL" in output
assert "must use HTTPS with a hostname" in output
def test_preset_add_from_url_streams_download_to_zip(self, project_dir, monkeypatch):
"""URL installs stream response bytes to disk before installing the ZIP."""
from specify_cli import preset_add
class FakeResponse(io.BytesIO):
def __init__(self, data):
super().__init__(data)
self.read_sizes = []
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def geturl(self):
return "https://example.com/preset.zip"
def read(self, size=-1):
assert size not in (-1, None)
self.read_sizes.append(size)
return super().read(size)
response = FakeResponse(b"zip-bytes")
installed = {}
def fake_install_from_zip(self, zip_path, speckit_version, priority=10):
installed["zip_bytes"] = Path(zip_path).read_bytes()
installed["speckit_version"] = speckit_version
installed["priority"] = priority
return SimpleNamespace(name="Test Preset", version="1.0.0")
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0")
monkeypatch.setattr(
"specify_cli.authentication.http.open_url",
lambda url, timeout=None, extra_headers=None, redirect_validator=None: response,
)
monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip)
preset_add(preset_id=None, from_url="https://example.com/preset.zip", dev=None, priority=7)
assert response.read_sizes
assert installed == {
"zip_bytes": b"zip-bytes",
"speckit_version": "0.6.0",
"priority": 7,
}
def test_bundled_preset_in_catalog(self): def test_bundled_preset_in_catalog(self):
"""Verify the lean preset is listed in catalog.json with bundled marker.""" """Verify the lean preset is listed in catalog.json with bundled marker."""
catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json" catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json"
@@ -3931,7 +4483,7 @@ class TestPresetAddFromUrlResolution:
def __exit__(self, *a): def __exit__(self, *a):
return False return False
def fake_open_url(url, timeout=None, extra_headers=None): def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
captured_urls.append((url, extra_headers)) captured_urls.append((url, extra_headers))
if "releases/tags/" in url: if "releases/tags/" in url:
return FakeResponse(json.dumps({ return FakeResponse(json.dumps({
@@ -3989,7 +4541,7 @@ class TestPresetAddFromUrlResolution:
def __exit__(self, *a): def __exit__(self, *a):
return False return False
def fake_open_url(url, timeout=None, extra_headers=None): def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
captured_urls.append((url, extra_headers)) captured_urls.append((url, extra_headers))
return FakeResponse(zip_bytes) return FakeResponse(zip_bytes)

View File

@@ -41,6 +41,13 @@ def _minimal_templates(repo: Path) -> None:
shutil.copy(PLAN_TEMPLATE, tdir / "plan-template.md") shutil.copy(PLAN_TEMPLATE, tdir / "plan-template.md")
def _write_feature_json(repo: Path, feature_directory: str) -> None:
(repo / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": feature_directory}),
encoding="utf-8",
)
def _clean_env() -> dict[str, str]: def _clean_env() -> dict[str, str]:
"""Return a copy of the current environment with any SPECIFY_* vars removed. """Return a copy of the current environment with any SPECIFY_* vars removed.
@@ -89,10 +96,7 @@ def test_setup_plan_passes_custom_branch_when_feature_json_valid(plan_repo: Path
feat = plan_repo / "specs" / "001-tiny-notes-app" feat = plan_repo / "specs" / "001-tiny-notes-app"
feat.mkdir(parents=True) feat.mkdir(parents=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8") (feat / "spec.md").write_text("# spec\n", encoding="utf-8")
(plan_repo / ".specify" / "feature.json").write_text( _write_feature_json(plan_repo, "specs/001-tiny-notes-app")
json.dumps({"feature_directory": "specs/001-tiny-notes-app"}),
encoding="utf-8",
)
script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh" script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh"
result = subprocess.run( result = subprocess.run(
["bash", str(script)], ["bash", str(script)],
@@ -107,12 +111,8 @@ def test_setup_plan_passes_custom_branch_when_feature_json_valid(plan_repo: Path
@requires_bash @requires_bash
def test_setup_plan_fails_custom_branch_without_feature_json(plan_repo: Path) -> None: def test_setup_plan_errors_without_feature_context(plan_repo: Path) -> None:
subprocess.run( """Without feature.json or SPECIFY_FEATURE_DIRECTORY, setup-plan must error."""
["git", "checkout", "-q", "-b", "feature/my-feature-branch"],
cwd=plan_repo,
check=True,
)
script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh" script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh"
result = subprocess.run( result = subprocess.run(
["bash", str(script)], ["bash", str(script)],
@@ -123,13 +123,14 @@ def test_setup_plan_fails_custom_branch_without_feature_json(plan_repo: Path) ->
env=_clean_env(), env=_clean_env(),
) )
assert result.returncode != 0 assert result.returncode != 0
assert "Not on a feature branch" in result.stderr assert "Feature directory not found" in result.stderr
@requires_bash @requires_bash
def test_setup_plan_numbered_branch_unchanged_without_feature_json( def test_setup_plan_numbered_branch_works_with_feature_json(
plan_repo: Path, plan_repo: Path,
) -> None: ) -> None:
"""A numbered branch still works when feature.json explicitly pins the spec dir."""
subprocess.run( subprocess.run(
["git", "checkout", "-q", "-b", "001-tiny-notes-app"], ["git", "checkout", "-q", "-b", "001-tiny-notes-app"],
cwd=plan_repo, cwd=plan_repo,
@@ -138,6 +139,7 @@ def test_setup_plan_numbered_branch_unchanged_without_feature_json(
feat = plan_repo / "specs" / "001-tiny-notes-app" feat = plan_repo / "specs" / "001-tiny-notes-app"
feat.mkdir(parents=True) feat.mkdir(parents=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8") (feat / "spec.md").write_text("# spec\n", encoding="utf-8")
_write_feature_json(plan_repo, "specs/001-tiny-notes-app")
script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh" script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh"
result = subprocess.run( result = subprocess.run(
["bash", str(script)], ["bash", str(script)],
@@ -161,10 +163,7 @@ def test_setup_plan_ps_passes_custom_branch_when_feature_json_valid(plan_repo: P
feat = plan_repo / "specs" / "001-tiny-notes-app" feat = plan_repo / "specs" / "001-tiny-notes-app"
feat.mkdir(parents=True) feat.mkdir(parents=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8") (feat / "spec.md").write_text("# spec\n", encoding="utf-8")
(plan_repo / ".specify" / "feature.json").write_text( _write_feature_json(plan_repo, "specs/001-tiny-notes-app")
json.dumps({"feature_directory": "specs/001-tiny-notes-app"}),
encoding="utf-8",
)
script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1" script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1"
exe = "pwsh" if HAS_PWSH else _POWERSHELL exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run( result = subprocess.run(
@@ -180,14 +179,9 @@ def test_setup_plan_ps_passes_custom_branch_when_feature_json_valid(plan_repo: P
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") @pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_setup_plan_ps_fails_custom_branch_without_feature_json( def test_setup_plan_ps_errors_without_feature_context(
plan_repo: Path, plan_repo: Path,
) -> None: ) -> None:
subprocess.run(
["git", "checkout", "-q", "-b", "feature/my-feature-branch"],
cwd=plan_repo,
check=True,
)
script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1" script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1"
exe = "pwsh" if HAS_PWSH else _POWERSHELL exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run( result = subprocess.run(
@@ -198,5 +192,6 @@ def test_setup_plan_ps_fails_custom_branch_without_feature_json(
check=False, check=False,
env=_clean_env(), env=_clean_env(),
) )
combined = result.stderr + result.stdout
assert result.returncode != 0 assert result.returncode != 0
assert "Not on a feature branch" in result.stderr assert "Feature directory not found" in combined

View File

@@ -41,6 +41,15 @@ def _minimal_templates(repo: Path) -> None:
shutil.copy(PLAN_TEMPLATE, tdir / "plan-template.md") shutil.copy(PLAN_TEMPLATE, tdir / "plan-template.md")
def _write_feature_json(
repo: Path, feature_directory: str = "specs/001-my-feature"
) -> None:
(repo / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": feature_directory}),
encoding="utf-8",
)
def _clean_env() -> dict[str, str]: def _clean_env() -> dict[str, str]:
env = os.environ.copy() env = os.environ.copy()
for key in list(env): for key in list(env):
@@ -74,6 +83,7 @@ def plan_repo(tmp_path: Path) -> Path:
_minimal_templates(repo) _minimal_templates(repo)
_install_bash_scripts(repo) _install_bash_scripts(repo)
_install_ps_scripts(repo) _install_ps_scripts(repo)
_write_feature_json(repo)
return repo return repo

View File

@@ -1,4 +1,4 @@
"""Tests for setup-tasks.{sh,ps1} template resolution and branch validation.""" """Tests for setup-tasks.{sh,ps1} template resolution and feature resolution."""
import json import json
import os import os
@@ -50,6 +50,15 @@ def _install_core_tasks_template(repo: Path) -> None:
shutil.copy(TASKS_TEMPLATE, tdir / "tasks-template.md") shutil.copy(TASKS_TEMPLATE, tdir / "tasks-template.md")
def _write_feature_json(
repo: Path, feature_directory: str = "specs/001-my-feature"
) -> None:
(repo / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": feature_directory}),
encoding="utf-8",
)
def _minimal_feature(repo: Path) -> Path: def _minimal_feature(repo: Path) -> Path:
""" """
Create a numbered branch-style feature directory with spec.md and plan.md Create a numbered branch-style feature directory with spec.md and plan.md
@@ -60,6 +69,7 @@ def _minimal_feature(repo: Path) -> Path:
feat.mkdir(parents=True, exist_ok=True) feat.mkdir(parents=True, exist_ok=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8") (feat / "spec.md").write_text("# spec\n", encoding="utf-8")
(feat / "plan.md").write_text("# plan\n", encoding="utf-8") (feat / "plan.md").write_text("# plan\n", encoding="utf-8")
_write_feature_json(repo)
return feat return feat
@@ -85,7 +95,7 @@ def _write_integration_state(repo: Path, integration: str = "claude", separator:
def _clean_env() -> dict[str, str]: def _clean_env() -> dict[str, str]:
""" """
Return os.environ with all SPECIFY_* variables stripped so the scripts Return os.environ with all SPECIFY_* variables stripped so the scripts
rely purely on git branch + feature.json state set up by each fixture. rely purely on feature.json and on-disk feature directories set up by each fixture.
""" """
env = os.environ.copy() env = os.environ.copy()
for key in list(env): for key in list(env):
@@ -153,7 +163,8 @@ def tasks_repo(tmp_path: Path) -> Path:
repo.mkdir() repo.mkdir()
_git_init(repo) _git_init(repo)
# Switch to a numbered branch so branch validation passes without feature.json # 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( subprocess.run(
["git", "checkout", "-q", "-b", "001-my-feature"], ["git", "checkout", "-q", "-b", "001-my-feature"],
cwd=repo, cwd=repo,
@@ -492,6 +503,7 @@ def test_setup_tasks_bash_uses_invoke_separator_in_plan_hint(tasks_repo: Path) -
feat = tasks_repo / "specs" / "001-my-feature" feat = tasks_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True) feat.mkdir(parents=True, exist_ok=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8") (feat / "spec.md").write_text("# spec\n", encoding="utf-8")
_write_feature_json(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh" script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
@@ -550,11 +562,7 @@ def test_setup_tasks_bash_passes_custom_branch_when_feature_json_valid(
feat.mkdir(parents=True, exist_ok=True) feat.mkdir(parents=True, exist_ok=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8") (feat / "spec.md").write_text("# spec\n", encoding="utf-8")
(feat / "plan.md").write_text("# plan\n", encoding="utf-8") (feat / "plan.md").write_text("# plan\n", encoding="utf-8")
_write_feature_json(tasks_repo)
(tasks_repo / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": "specs/001-my-feature"}),
encoding="utf-8",
)
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh" script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
@@ -571,21 +579,17 @@ def test_setup_tasks_bash_passes_custom_branch_when_feature_json_valid(
@requires_bash @requires_bash
def test_setup_tasks_bash_fails_custom_branch_without_feature_json( def test_setup_tasks_bash_errors_without_feature_context(
tasks_repo: Path, tasks_repo: Path,
) -> None: ) -> None:
""" """Without feature.json or SPECIFY_FEATURE_DIRECTORY, setup-tasks.sh must error."""
On a non-standard branch with no feature.json, setup-tasks.sh must fail main_feat = tasks_repo / "specs" / "main"
and report that we are not on a feature branch. main_feat.mkdir(parents=True, exist_ok=True)
""" (main_feat / "spec.md").write_text("# spec\n", encoding="utf-8")
subprocess.run( (main_feat / "plan.md").write_text("# plan\n", encoding="utf-8")
["git", "checkout", "-q", "-b", "feature/custom-branch"],
cwd=tasks_repo,
check=True,
)
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh" script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run( result = subprocess.run(
["bash", str(script), "--json"], ["bash", str(script), "--json"],
cwd=tasks_repo, cwd=tasks_repo,
@@ -596,7 +600,7 @@ def test_setup_tasks_bash_fails_custom_branch_without_feature_json(
) )
assert result.returncode != 0 assert result.returncode != 0
assert "Not on a feature branch" in result.stderr assert "Feature directory not found" in result.stderr
# =========================================================================== # ===========================================================================
# POWERSHELL TESTS # POWERSHELL TESTS
@@ -731,6 +735,7 @@ def test_setup_tasks_ps_uses_invoke_separator_in_plan_hint(tasks_repo: Path) ->
feat = tasks_repo / "specs" / "001-my-feature" feat = tasks_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True) feat.mkdir(parents=True, exist_ok=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8") (feat / "spec.md").write_text("# spec\n", encoding="utf-8")
_write_feature_json(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1" script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _POWERSHELL exe = "pwsh" if HAS_PWSH else _POWERSHELL
@@ -793,11 +798,7 @@ def test_setup_tasks_ps_passes_custom_branch_when_feature_json_valid(
feat.mkdir(parents=True, exist_ok=True) feat.mkdir(parents=True, exist_ok=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8") (feat / "spec.md").write_text("# spec\n", encoding="utf-8")
(feat / "plan.md").write_text("# plan\n", encoding="utf-8") (feat / "plan.md").write_text("# plan\n", encoding="utf-8")
_write_feature_json(tasks_repo)
(tasks_repo / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": "specs/001-my-feature"}),
encoding="utf-8",
)
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1" script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _POWERSHELL exe = "pwsh" if HAS_PWSH else _POWERSHELL
@@ -815,22 +816,18 @@ def test_setup_tasks_ps_passes_custom_branch_when_feature_json_valid(
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") @pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_setup_tasks_ps_fails_custom_branch_without_feature_json( def test_setup_tasks_ps_errors_without_feature_context(
tasks_repo: Path, tasks_repo: Path,
) -> None: ) -> None:
""" """Without feature.json or SPECIFY_FEATURE_DIRECTORY, setup-tasks.ps1 must error."""
On a non-standard branch with no feature.json, setup-tasks.ps1 must fail main_feat = tasks_repo / "specs" / "main"
and report that we are not on a feature branch. main_feat.mkdir(parents=True, exist_ok=True)
""" (main_feat / "spec.md").write_text("# spec\n", encoding="utf-8")
subprocess.run( (main_feat / "plan.md").write_text("# plan\n", encoding="utf-8")
["git", "checkout", "-q", "-b", "feature/custom-branch"],
cwd=tasks_repo,
check=True,
)
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1" script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _POWERSHELL exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run( result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"], [exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=tasks_repo, cwd=tasks_repo,
@@ -839,6 +836,7 @@ def test_setup_tasks_ps_fails_custom_branch_without_feature_json(
check=False, check=False,
env=_clean_env(), env=_clean_env(),
) )
output = result.stderr + result.stdout
assert result.returncode != 0 assert result.returncode != 0
assert "Not on a feature branch" in result.stderr assert "Feature directory not found" in output

View File

@@ -19,14 +19,12 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent
CREATE_FEATURE = PROJECT_ROOT / "scripts" / "bash" / "create-new-feature.sh" CREATE_FEATURE = PROJECT_ROOT / "scripts" / "bash" / "create-new-feature.sh"
CREATE_FEATURE_PS = PROJECT_ROOT / "scripts" / "powershell" / "create-new-feature.ps1" CREATE_FEATURE_PS = PROJECT_ROOT / "scripts" / "powershell" / "create-new-feature.ps1"
EXT_CREATE_FEATURE = ( EXT_CREATE_FEATURE = (
PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh" PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature-branch.sh"
) )
EXT_CREATE_FEATURE_PS = ( EXT_CREATE_FEATURE_PS = (
PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1" PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature-branch.ps1"
) )
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh" COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
EXT_CREATE_FEATURE = PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh"
EXT_CREATE_FEATURE_PS = PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1"
HAS_PWSH = shutil.which("pwsh") is not None HAS_PWSH = shutil.which("pwsh") is not None
@@ -77,7 +75,7 @@ def ext_git_repo(tmp_path: Path) -> Path:
# Copy extension script # Copy extension script
ext_dir = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "bash" ext_dir = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "bash"
ext_dir.mkdir(parents=True) ext_dir.mkdir(parents=True)
shutil.copy(EXT_CREATE_FEATURE, ext_dir / "create-new-feature.sh") shutil.copy(EXT_CREATE_FEATURE, ext_dir / "create-new-feature-branch.sh")
# Also copy git-common.sh if it exists # Also copy git-common.sh if it exists
git_common = PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "git-common.sh" git_common = PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
if git_common.exists(): if git_common.exists():
@@ -106,7 +104,7 @@ def ext_ps_git_repo(tmp_path: Path) -> Path:
# Copy extension script # Copy extension script
ext_ps = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "powershell" ext_ps = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "powershell"
ext_ps.mkdir(parents=True) ext_ps.mkdir(parents=True)
shutil.copy(EXT_CREATE_FEATURE_PS, ext_ps / "create-new-feature.ps1") shutil.copy(EXT_CREATE_FEATURE_PS, ext_ps / "create-new-feature-branch.ps1")
git_common_ps = PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1" git_common_ps = PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1"
if git_common_ps.exists(): if git_common_ps.exists():
shutil.copy(git_common_ps, ext_ps / "git-common.ps1") shutil.copy(git_common_ps, ext_ps / "git-common.ps1")
@@ -279,64 +277,13 @@ class TestSequentialBranchPowerShell:
@requires_bash @requires_bash
class TestCheckFeatureBranch: class TestCoreCommonRemovesGitHelpers:
def test_accepts_timestamp_branch(self): def test_check_feature_branch_removed(self):
"""Test 6: check_feature_branch accepts timestamp branch.""" result = source_and_call('declare -F check_feature_branch >/dev/null')
result = source_and_call('check_feature_branch "20260319-143022-feat" "true"')
assert result.returncode == 0
def test_accepts_sequential_branch(self):
"""Test 7: check_feature_branch accepts sequential branch."""
result = source_and_call('check_feature_branch "004-feat" "true"')
assert result.returncode == 0
def test_rejects_main(self):
"""Test 8: check_feature_branch rejects main."""
result = source_and_call('check_feature_branch "main" "true"')
assert result.returncode != 0 assert result.returncode != 0
def test_accepts_four_digit_sequential_branch(self): def test_has_git_removed(self):
"""check_feature_branch accepts 4+ digit sequential branch.""" result = source_and_call('declare -F has_git >/dev/null')
result = source_and_call('check_feature_branch "1234-feat" "true"')
assert result.returncode == 0
def test_rejects_partial_timestamp(self):
"""Test 9: check_feature_branch rejects 7-digit date."""
result = source_and_call('check_feature_branch "2026031-143022-feat" "true"')
assert result.returncode != 0
def test_rejects_timestamp_without_slug(self):
"""check_feature_branch rejects timestamp-like branch missing trailing slug."""
result = source_and_call('check_feature_branch "20260319-143022" "true"')
assert result.returncode != 0
def test_rejects_7digit_timestamp_without_slug(self):
"""check_feature_branch rejects 7-digit date + 6-digit time without slug."""
result = source_and_call('check_feature_branch "2026031-143022" "true"')
assert result.returncode != 0
def test_accepts_single_prefix_sequential(self):
"""Optional gitflow-style prefix: one segment + sequential feature name."""
result = source_and_call('check_feature_branch "feat/004-my-feature" "true"')
assert result.returncode == 0
def test_accepts_single_prefix_timestamp(self):
"""Optional prefix + timestamp-style feature name."""
result = source_and_call('check_feature_branch "release/20260319-143022-feat" "true"')
assert result.returncode == 0
def test_rejects_invalid_suffix_with_single_prefix(self):
result = source_and_call('check_feature_branch "feat/main" "true"')
assert result.returncode != 0
assert "feat/main" in result.stderr
def test_rejects_two_level_prefix_before_feature(self):
"""More than one slash: no stripping; whole name must match (fails)."""
result = source_and_call('check_feature_branch "feat/fix/004-feat" "true"')
assert result.returncode != 0
def test_rejects_malformed_timestamp_with_prefix(self):
result = source_and_call('check_feature_branch "feat/2026031-143022-feat" "true"')
assert result.returncode != 0 assert result.returncode != 0
@@ -344,50 +291,11 @@ class TestCheckFeatureBranch:
@requires_bash @requires_bash
class TestFindFeatureDirByPrefix: class TestFindFeatureDirByPrefixRemoved:
def test_timestamp_branch(self, tmp_path: Path): def test_find_feature_dir_by_prefix_removed(self):
"""Test 10: find_feature_dir_by_prefix with timestamp branch.""" """Directory scanning helper is removed from core common.sh."""
(tmp_path / "specs" / "20260319-143022-user-auth").mkdir(parents=True) result = source_and_call('declare -F find_feature_dir_by_prefix >/dev/null')
result = source_and_call( assert result.returncode != 0
f'find_feature_dir_by_prefix "{tmp_path}" "20260319-143022-user-auth"'
)
assert result.returncode == 0
assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-user-auth"
def test_cross_branch_prefix(self, tmp_path: Path):
"""Test 11: find_feature_dir_by_prefix cross-branch (different suffix, same timestamp)."""
(tmp_path / "specs" / "20260319-143022-original-feat").mkdir(parents=True)
result = source_and_call(
f'find_feature_dir_by_prefix "{tmp_path}" "20260319-143022-different-name"'
)
assert result.returncode == 0
assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-original-feat"
def test_four_digit_sequential_prefix(self, tmp_path: Path):
"""find_feature_dir_by_prefix resolves 4+ digit sequential prefix."""
(tmp_path / "specs" / "1000-original-feat").mkdir(parents=True)
result = source_and_call(
f'find_feature_dir_by_prefix "{tmp_path}" "1000-different-name"'
)
assert result.returncode == 0
assert result.stdout.strip() == f"{tmp_path}/specs/1000-original-feat"
def test_sequential_with_single_path_prefix(self, tmp_path: Path):
"""Strip one optional prefix segment before prefix directory lookup."""
(tmp_path / "specs" / "004-only-dir").mkdir(parents=True)
result = source_and_call(
f'find_feature_dir_by_prefix "{tmp_path}" "feat/004-other-suffix"'
)
assert result.returncode == 0
assert result.stdout.strip() == f"{tmp_path}/specs/004-only-dir"
def test_timestamp_with_single_path_prefix_cross_branch(self, tmp_path: Path):
(tmp_path / "specs" / "20260319-143022-canonical").mkdir(parents=True)
result = source_and_call(
f'find_feature_dir_by_prefix "{tmp_path}" "hotfix/20260319-143022-alias"'
)
assert result.returncode == 0
assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-canonical"
# ── get_feature_paths + single-prefix integration ─────────────────────────── # ── get_feature_paths + single-prefix integration ───────────────────────────
@@ -395,26 +303,29 @@ class TestFindFeatureDirByPrefix:
class TestGetFeaturePathsSinglePrefix: class TestGetFeaturePathsSinglePrefix:
@requires_bash @requires_bash
def test_bash_specify_feature_prefixed_resolves_by_prefix(self, tmp_path: Path): def test_bash_specify_feature_prefixed_requires_explicit_feature_context(
"""get_feature_paths: SPECIFY_FEATURE with one optional prefix uses effective name for lookup.""" self, tmp_path: Path
):
"""SPECIFY_FEATURE alone no longer triggers path lookup in bash."""
(tmp_path / ".specify").mkdir() (tmp_path / ".specify").mkdir()
(tmp_path / "specs" / "001-target-spec").mkdir(parents=True) (tmp_path / "specs" / "001-target-spec").mkdir(parents=True)
cmd = ( cmd = (
f'cd "{tmp_path}" && export SPECIFY_FEATURE="feat/001-other" && ' f'cd "{tmp_path}" && export SPECIFY_FEATURE="feat/001-other" && '
f'source "{COMMON_SH}" && eval "$(get_feature_paths)" && printf "%s" "$FEATURE_DIR"' f'source "{COMMON_SH}" && get_feature_paths'
) )
result = subprocess.run( result = subprocess.run(
["bash", "-c", cmd], ["bash", "-c", cmd],
capture_output=True, capture_output=True,
text=True, text=True,
) )
assert result.returncode == 0, result.stderr assert result.returncode != 0
assert result.stdout.strip() == str(tmp_path / "specs" / "001-target-spec") assert "Feature directory not found" in result.stderr
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed") @pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
def test_ps_specify_feature_prefixed_resolves_by_prefix(self, git_repo: Path): def test_ps_specify_feature_prefixed_requires_explicit_feature_context(
"""PowerShell Get-FeaturePathsEnv: same prefix stripping as bash.""" self, git_repo: Path
):
"""PowerShell also requires feature.json or SPECIFY_FEATURE_DIRECTORY."""
common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
spec_dir = git_repo / "specs" / "001-ps-prefix-spec" spec_dir = git_repo / "specs" / "001-ps-prefix-spec"
spec_dir.mkdir(parents=True) spec_dir.mkdir(parents=True)
@@ -426,14 +337,8 @@ class TestGetFeaturePathsSinglePrefix:
text=True, text=True,
env={**os.environ, "SPECIFY_FEATURE": "feat/001-other"}, env={**os.environ, "SPECIFY_FEATURE": "feat/001-other"},
) )
assert result.returncode == 0, result.stderr assert result.returncode != 0
for line in result.stdout.splitlines(): assert "Feature directory not found" in (result.stderr + result.stdout)
if line.startswith("FEATURE_DIR="):
val = line.split("=", 1)[1].strip()
assert val == str(spec_dir)
break
else:
pytest.fail("FEATURE_DIR not found in PowerShell output")
# ── get_current_branch Tests ───────────────────────────────────────────────── # ── get_current_branch Tests ─────────────────────────────────────────────────
@@ -453,12 +358,11 @@ class TestGetCurrentBranch:
@requires_bash @requires_bash
class TestNoGitTimestamp: class TestNoGitTimestamp:
def test_no_git_timestamp(self, no_git_dir: Path): def test_no_git_timestamp(self, no_git_dir: Path):
"""Test 13: No-git repo + timestamp creates spec dir with warning.""" """Test 13: Timestamp mode works without git and creates a spec dir."""
result = run_script(no_git_dir, "--timestamp", "--short-name", "no-git-feat", "No git feature") result = run_script(no_git_dir, "--timestamp", "--short-name", "no-git-feat", "No git feature")
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
spec_dirs = list((no_git_dir / "specs").iterdir()) if (no_git_dir / "specs").exists() else [] spec_dirs = list((no_git_dir / "specs").iterdir()) if (no_git_dir / "specs").exists() else []
assert len(spec_dirs) > 0, "spec dir not created" assert len(spec_dirs) > 0, "spec dir not created"
assert "git" in result.stderr.lower() or "warning" in result.stderr.lower()
# ── E2E Flow Tests ─────────────────────────────────────────────────────────── # ── E2E Flow Tests ───────────────────────────────────────────────────────────
@@ -467,32 +371,65 @@ class TestNoGitTimestamp:
@requires_bash @requires_bash
class TestE2EFlow: class TestE2EFlow:
def test_e2e_timestamp(self, git_repo: Path): def test_e2e_timestamp(self, git_repo: Path):
"""Test 14: E2E timestamp flow — branch, dir, validation.""" """Test 14: E2E timestamp flow creates only a feature directory."""
run_script(git_repo, "--timestamp", "--short-name", "e2e-ts", "E2E timestamp test") before = subprocess.run(
branch = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"], ["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=git_repo, cwd=git_repo,
capture_output=True, capture_output=True,
text=True, text=True,
check=True,
).stdout.strip() ).stdout.strip()
assert re.match(r"^\d{8}-\d{6}-e2e-ts$", branch), f"branch: {branch}" result = run_script(git_repo, "--timestamp", "--short-name", "e2e-ts", "E2E timestamp test")
assert (git_repo / "specs" / branch).is_dir() assert result.returncode == 0, result.stderr
val = source_and_call(f'check_feature_branch "{branch}" "true"')
assert val.returncode == 0 branch_name = None
for line in result.stdout.splitlines():
if line.startswith("BRANCH_NAME:"):
branch_name = line.split(":", 1)[1].strip()
break
assert branch_name is not None
assert re.match(r"^\d{8}-\d{6}-e2e-ts$", branch_name), f"branch: {branch_name}"
assert (git_repo / "specs" / branch_name).is_dir()
after = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=git_repo,
capture_output=True,
text=True,
check=True,
).stdout.strip()
assert after == before
def test_e2e_sequential(self, git_repo: Path): def test_e2e_sequential(self, git_repo: Path):
"""Test 15: E2E sequential flow (regression guard).""" """Test 15: E2E sequential flow creates only a feature directory."""
run_script(git_repo, "--short-name", "seq-feat", "Sequential feature") before = subprocess.run(
branch = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"], ["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=git_repo, cwd=git_repo,
capture_output=True, capture_output=True,
text=True, text=True,
check=True,
).stdout.strip() ).stdout.strip()
assert re.match(r"^\d{3,}-seq-feat$", branch), f"branch: {branch}" result = run_script(git_repo, "--short-name", "seq-feat", "Sequential feature")
assert (git_repo / "specs" / branch).is_dir() assert result.returncode == 0, result.stderr
val = source_and_call(f'check_feature_branch "{branch}" "true"')
assert val.returncode == 0 branch_name = None
for line in result.stdout.splitlines():
if line.startswith("BRANCH_NAME:"):
branch_name = line.split(":", 1)[1].strip()
break
assert branch_name == "001-seq-feat"
assert (git_repo / "specs" / branch_name).is_dir()
after = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=git_repo,
capture_output=True,
text=True,
check=True,
).stdout.strip()
assert after == before
# ── Allow Existing Branch Tests ────────────────────────────────────────────── # ── Allow Existing Branch Tests ──────────────────────────────────────────────
@@ -500,67 +437,22 @@ class TestE2EFlow:
@requires_bash @requires_bash
class TestAllowExistingBranch: class TestAllowExistingBranch:
def test_allow_existing_switches_to_branch(self, git_repo: Path): def test_allow_existing_reuses_existing_feature_dir(self, git_repo: Path):
"""T006: Pre-create branch, verify script switches to it.""" """T006: Existing feature directory can be reused when the flag is set."""
subprocess.run( feature_dir = git_repo / "specs" / "004-pre-exist"
["git", "checkout", "-b", "004-pre-exist"], feature_dir.mkdir(parents=True)
cwd=git_repo, check=True, capture_output=True,
)
subprocess.run(
["git", "checkout", "-"],
cwd=git_repo, check=True, capture_output=True,
)
result = run_script( result = run_script(
git_repo, "--allow-existing-branch", "--short-name", "pre-exist", git_repo, "--allow-existing-branch", "--short-name", "pre-exist",
"--number", "4", "Pre-existing feature", "--number", "4", "Pre-existing feature",
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
current = subprocess.run( assert feature_dir.is_dir()
["git", "rev-parse", "--abbrev-ref", "HEAD"], assert (feature_dir / "spec.md").exists()
cwd=git_repo, capture_output=True, text=True,
).stdout.strip()
assert current == "004-pre-exist", f"expected 004-pre-exist, got {current}"
def test_allow_existing_already_on_branch(self, git_repo: Path):
"""T007: Verify success when already on the target branch."""
subprocess.run(
["git", "checkout", "-b", "005-already-on"],
cwd=git_repo, check=True, capture_output=True,
)
result = run_script(
git_repo, "--allow-existing-branch", "--short-name", "already-on",
"--number", "5", "Already on branch",
)
assert result.returncode == 0, result.stderr
def test_allow_existing_creates_spec_dir(self, git_repo: Path):
"""T008: Verify spec directory created on existing branch."""
subprocess.run(
["git", "checkout", "-b", "006-spec-dir"],
cwd=git_repo, check=True, capture_output=True,
)
subprocess.run(
["git", "checkout", "-"],
cwd=git_repo, check=True, capture_output=True,
)
result = run_script(
git_repo, "--allow-existing-branch", "--short-name", "spec-dir",
"--number", "6", "Spec dir feature",
)
assert result.returncode == 0, result.stderr
assert (git_repo / "specs" / "006-spec-dir").is_dir()
assert (git_repo / "specs" / "006-spec-dir" / "spec.md").exists()
def test_without_flag_still_errors(self, git_repo: Path): def test_without_flag_still_errors(self, git_repo: Path):
"""T009: Verify backwards compatibility (error without flag).""" """T009: Existing feature directories still fail without the flag."""
subprocess.run( (git_repo / "specs" / "007-no-flag").mkdir(parents=True)
["git", "checkout", "-b", "007-no-flag"],
cwd=git_repo, check=True, capture_output=True,
)
subprocess.run(
["git", "checkout", "-"],
cwd=git_repo, check=True, capture_output=True,
)
result = run_script( result = run_script(
git_repo, "--short-name", "no-flag", "--number", "7", "No flag feature", git_repo, "--short-name", "no-flag", "--number", "7", "No flag feature",
) )
@@ -569,18 +461,11 @@ class TestAllowExistingBranch:
def test_allow_existing_no_overwrite_spec(self, git_repo: Path): def test_allow_existing_no_overwrite_spec(self, git_repo: Path):
"""T010: Pre-create spec.md with content, verify it is preserved.""" """T010: Pre-create spec.md with content, verify it is preserved."""
subprocess.run(
["git", "checkout", "-b", "008-no-overwrite"],
cwd=git_repo, check=True, capture_output=True,
)
spec_dir = git_repo / "specs" / "008-no-overwrite" spec_dir = git_repo / "specs" / "008-no-overwrite"
spec_dir.mkdir(parents=True) spec_dir.mkdir(parents=True)
spec_file = spec_dir / "spec.md" spec_file = spec_dir / "spec.md"
spec_file.write_text("# My custom spec content\n") spec_file.write_text("# My custom spec content\n")
subprocess.run(
["git", "checkout", "-"],
cwd=git_repo, check=True, capture_output=True,
)
result = run_script( result = run_script(
git_repo, "--allow-existing-branch", "--short-name", "no-overwrite", git_repo, "--allow-existing-branch", "--short-name", "no-overwrite",
"--number", "8", "No overwrite feature", "--number", "8", "No overwrite feature",
@@ -588,31 +473,20 @@ class TestAllowExistingBranch:
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
assert spec_file.read_text() == "# My custom spec content\n" assert spec_file.read_text() == "# My custom spec content\n"
def test_allow_existing_creates_branch_if_not_exists(self, git_repo: Path): def test_allow_existing_creates_feature_dir_when_missing(self, git_repo: Path):
"""T011: Verify normal creation when branch doesn't exist.""" """T011: Verify normal directory creation when the feature dir does not exist."""
result = run_script( result = run_script(
git_repo, "--allow-existing-branch", "--short-name", "new-branch", git_repo, "--allow-existing-branch", "--short-name", "new-branch",
"New branch feature", "New branch feature",
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
current = subprocess.run( assert (git_repo / "specs" / "001-new-branch").is_dir()
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=git_repo, capture_output=True, text=True,
).stdout.strip()
assert "new-branch" in current
def test_allow_existing_with_json(self, git_repo: Path): def test_allow_existing_with_json(self, git_repo: Path):
"""T012: Verify JSON output is correct.""" """T012: Verify JSON output is correct."""
import json import json
subprocess.run( (git_repo / "specs" / "009-json-test").mkdir(parents=True)
["git", "checkout", "-b", "009-json-test"],
cwd=git_repo, check=True, capture_output=True,
)
subprocess.run(
["git", "checkout", "-"],
cwd=git_repo, check=True, capture_output=True,
)
result = run_script( result = run_script(
git_repo, "--allow-existing-branch", "--json", "--short-name", "json-test", git_repo, "--allow-existing-branch", "--json", "--short-name", "json-test",
"--number", "9", "JSON test", "--number", "9", "JSON test",
@@ -622,64 +496,26 @@ class TestAllowExistingBranch:
assert data["BRANCH_NAME"] == "009-json-test" assert data["BRANCH_NAME"] == "009-json-test"
def test_allow_existing_no_git(self, no_git_dir: Path): def test_allow_existing_no_git(self, no_git_dir: Path):
"""T013: Verify flag is silently ignored in non-git repos.""" """T013: Verify flag also works in non-git repos."""
result = run_script( result = run_script(
no_git_dir, "--allow-existing-branch", "--short-name", "no-git", no_git_dir, "--allow-existing-branch", "--short-name", "no-git",
"No git feature", "No git feature",
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
def test_allow_existing_surfaces_checkout_error(self, git_repo: Path):
"""Checkout failures on an existing branch should include Git's stderr."""
shared_file = git_repo / "shared.txt"
shared_file.write_text("base\n")
subprocess.run(
["git", "add", "shared.txt"],
cwd=git_repo, check=True, capture_output=True,
)
subprocess.run(
["git", "commit", "-m", "add shared file", "-q"],
cwd=git_repo, check=True, capture_output=True,
)
subprocess.run(
["git", "checkout", "-b", "010-checkout-failure"],
cwd=git_repo, check=True, capture_output=True,
)
shared_file.write_text("branch version\n")
subprocess.run(
["git", "commit", "-am", "branch change", "-q"],
cwd=git_repo, check=True, capture_output=True,
)
subprocess.run(
["git", "checkout", "-"],
cwd=git_repo, check=True, capture_output=True,
)
shared_file.write_text("uncommitted main change\n")
result = run_script(
git_repo, "--allow-existing-branch", "--short-name", "checkout-failure",
"--number", "10", "Checkout failure",
)
assert result.returncode != 0, "checkout should fail with conflicting local changes"
assert "Failed to switch to existing branch '010-checkout-failure'" in result.stderr
assert "would be overwritten by checkout" in result.stderr
assert "shared.txt" in result.stderr
class TestAllowExistingBranchPowerShell: class TestAllowExistingBranchPowerShell:
def test_powershell_supports_allow_existing_branch_flag(self): def test_powershell_supports_allow_existing_branch_flag(self):
"""Static guard: PS script exposes and uses -AllowExistingBranch.""" """Static guard: PS script exposes and uses -AllowExistingBranch."""
contents = CREATE_FEATURE_PS.read_text(encoding="utf-8") contents = CREATE_FEATURE_PS.read_text(encoding="utf-8")
assert "-AllowExistingBranch" in contents assert "-AllowExistingBranch" in contents
# Ensure the flag is referenced in script logic, not just declared
assert "AllowExistingBranch" in contents.replace("-AllowExistingBranch", "") assert "AllowExistingBranch" in contents.replace("-AllowExistingBranch", "")
def test_powershell_surfaces_checkout_errors(self): def test_powershell_reuses_existing_feature_dir(self):
"""Static guard: PS script preserves checkout stderr on existing-branch failures.""" """Static guard: PS script handles existing feature directories without git."""
contents = CREATE_FEATURE_PS.read_text(encoding="utf-8") contents = CREATE_FEATURE_PS.read_text(encoding="utf-8")
assert "$switchBranchError = git checkout -q $branchName 2>&1 | Out-String" in contents assert "Feature directory '$featureDir' already exists" in contents
assert "exists but could not be checked out.`n$($switchBranchError.Trim())" in contents assert "-not $AllowExistingBranch" in contents
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed") @pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
@pytest.mark.skipif( @pytest.mark.skipif(
@@ -754,20 +590,27 @@ class TestDryRun:
branch = line.split(":", 1)[1].strip() branch = line.split(":", 1)[1].strip()
assert branch == "003-new-feat", f"expected 003-new-feat, got: {branch}" assert branch == "003-new-feat", f"expected 003-new-feat, got: {branch}"
def test_dry_run_no_branch_created(self, git_repo: Path): def test_dry_run_does_not_change_git_branch(self, git_repo: Path):
"""T010: Dry-run does not create a git branch.""" """T010: Dry-run leaves the current git branch untouched."""
before = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=git_repo,
capture_output=True,
text=True,
check=True,
).stdout.strip()
result = run_script( result = run_script(
git_repo, "--dry-run", "--short-name", "no-branch", "No branch feature" git_repo, "--dry-run", "--short-name", "no-branch", "No branch feature"
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
branches = subprocess.run( after = subprocess.run(
["git", "branch", "--list", "*no-branch*"], ["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=git_repo, cwd=git_repo,
capture_output=True, capture_output=True,
text=True, text=True,
) check=True,
assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}" ).stdout.strip()
assert branches.stdout.strip() == "", "branch should not exist after dry-run" assert after == before
def test_dry_run_no_spec_dir_created(self, git_repo: Path): def test_dry_run_no_spec_dir_created(self, git_repo: Path):
"""T011: Dry-run does not create any directories (including root specs/).""" """T011: Dry-run does not create any directories (including root specs/)."""
@@ -832,50 +675,22 @@ class TestDryRun:
real_branch = line.split(":", 1)[1].strip() real_branch = line.split(":", 1)[1].strip()
assert dry_branch == real_branch, f"dry={dry_branch} != real={real_branch}" assert dry_branch == real_branch, f"dry={dry_branch} != real={real_branch}"
def test_dry_run_accounts_for_remote_branches(self, git_repo: Path): def test_dry_run_ignores_git_branches(self, git_repo: Path):
"""Dry-run queries remote refs via ls-remote (no fetch) for accurate numbering.""" """Dry-run uses only spec directories for numbering."""
(git_repo / "specs" / "001-existing").mkdir(parents=True) (git_repo / "specs" / "001-existing").mkdir(parents=True)
# Set up a bare remote and push (use subdirs of git_repo for isolation)
remote_dir = git_repo / "test-remote.git"
subprocess.run( subprocess.run(
["git", "init", "--bare", str(remote_dir)], ["git", "checkout", "-b", "005-git-only"],
check=True, capture_output=True, cwd=git_repo,
check=True,
capture_output=True,
) )
subprocess.run( subprocess.run(
["git", "remote", "add", "origin", str(remote_dir)], ["git", "checkout", "-"],
check=True, cwd=git_repo, capture_output=True, cwd=git_repo,
) check=True,
subprocess.run( capture_output=True,
["git", "push", "-u", "origin", "HEAD"],
check=True, cwd=git_repo, capture_output=True,
) )
# Clone into a second copy, create a higher-numbered branch, push it
second_clone = git_repo / "test-second-clone"
subprocess.run(
["git", "clone", str(remote_dir), str(second_clone)],
check=True, capture_output=True,
)
subprocess.run(
["git", "config", "user.email", "test@example.com"],
cwd=second_clone, check=True, capture_output=True,
)
subprocess.run(
["git", "config", "user.name", "Test User"],
cwd=second_clone, check=True, capture_output=True,
)
# Create branch 005 on the remote (higher than local 001)
subprocess.run(
["git", "checkout", "-b", "005-remote-only"],
cwd=second_clone, check=True, capture_output=True,
)
subprocess.run(
["git", "push", "origin", "005-remote-only"],
cwd=second_clone, check=True, capture_output=True,
)
# Primary repo: dry-run should see 005 via ls-remote and return 006
dry_result = run_script( dry_result = run_script(
git_repo, "--dry-run", "--short-name", "remote-test", "Remote test" git_repo, "--dry-run", "--short-name", "remote-test", "Remote test"
) )
@@ -884,7 +699,7 @@ class TestDryRun:
for line in dry_result.stdout.splitlines(): for line in dry_result.stdout.splitlines():
if line.startswith("BRANCH_NAME:"): if line.startswith("BRANCH_NAME:"):
dry_branch = line.split(":", 1)[1].strip() dry_branch = line.split(":", 1)[1].strip()
assert dry_branch == "006-remote-test", f"expected 006-remote-test, got: {dry_branch}" assert dry_branch == "002-remote-test", f"expected 002-remote-test, got: {dry_branch}"
def test_dry_run_json_includes_field(self, git_repo: Path): def test_dry_run_json_includes_field(self, git_repo: Path):
"""T015: JSON output includes DRY_RUN field when --dry-run is active.""" """T015: JSON output includes DRY_RUN field when --dry-run is active."""
@@ -910,7 +725,14 @@ class TestDryRun:
assert "DRY_RUN" not in data, f"DRY_RUN should not be in normal JSON: {data}" assert "DRY_RUN" not in data, f"DRY_RUN should not be in normal JSON: {data}"
def test_dry_run_with_timestamp(self, git_repo: Path): def test_dry_run_with_timestamp(self, git_repo: Path):
"""T017: Dry-run works with --timestamp flag.""" """T017: Dry-run works with --timestamp flag without mutating git state."""
before = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=git_repo,
capture_output=True,
text=True,
check=True,
).stdout.strip()
result = run_script( result = run_script(
git_repo, "--dry-run", "--timestamp", "--short-name", "ts-feat", "Timestamp feature" git_repo, "--dry-run", "--timestamp", "--short-name", "ts-feat", "Timestamp feature"
) )
@@ -921,15 +743,14 @@ class TestDryRun:
branch = line.split(":", 1)[1].strip() branch = line.split(":", 1)[1].strip()
assert branch is not None, "no BRANCH_NAME in output" assert branch is not None, "no BRANCH_NAME in output"
assert re.match(r"^\d{8}-\d{6}-ts-feat$", branch), f"unexpected: {branch}" assert re.match(r"^\d{8}-\d{6}-ts-feat$", branch), f"unexpected: {branch}"
# Verify no side effects after = subprocess.run(
branches = subprocess.run( ["git", "rev-parse", "--abbrev-ref", "HEAD"],
["git", "branch", "--list", "*ts-feat*"],
cwd=git_repo, cwd=git_repo,
capture_output=True, capture_output=True,
text=True, text=True,
) check=True,
assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}" ).stdout.strip()
assert branches.stdout.strip() == "" assert after == before
def test_dry_run_with_number(self, git_repo: Path): def test_dry_run_with_number(self, git_repo: Path):
"""T018: Dry-run works with --number flag.""" """T018: Dry-run works with --number flag."""
@@ -989,20 +810,27 @@ class TestPowerShellDryRun:
branch = line.split(":", 1)[1].strip() branch = line.split(":", 1)[1].strip()
assert branch == "002-ps-feat", f"expected 002-ps-feat, got: {branch}" assert branch == "002-ps-feat", f"expected 002-ps-feat, got: {branch}"
def test_ps_dry_run_no_branch_created(self, ps_git_repo: Path): def test_ps_dry_run_does_not_change_git_branch(self, ps_git_repo: Path):
"""PowerShell -DryRun does not create a git branch.""" """PowerShell -DryRun leaves the current git branch untouched."""
before = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=ps_git_repo,
capture_output=True,
text=True,
check=True,
).stdout.strip()
result = run_ps_script( result = run_ps_script(
ps_git_repo, "-DryRun", "-ShortName", "no-ps-branch", "No branch" ps_git_repo, "-DryRun", "-ShortName", "no-ps-branch", "No branch"
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
branches = subprocess.run( after = subprocess.run(
["git", "branch", "--list", "*no-ps-branch*"], ["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=ps_git_repo, cwd=ps_git_repo,
capture_output=True, capture_output=True,
text=True, text=True,
) check=True,
assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}" ).stdout.strip()
assert branches.stdout.strip() == "", "branch should not exist after dry-run" assert after == before
def test_ps_dry_run_no_spec_dir_created(self, ps_git_repo: Path): def test_ps_dry_run_no_spec_dir_created(self, ps_git_repo: Path):
"""PowerShell -DryRun does not create specs/ directory.""" """PowerShell -DryRun does not create specs/ directory."""
@@ -1046,10 +874,10 @@ class TestPowerShellDryRun:
@requires_bash @requires_bash
class TestGitBranchNameOverrideBash: class TestGitBranchNameOverrideBash:
"""Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.sh.""" """Tests for GIT_BRANCH_NAME env var override in extension create-new-feature-branch.sh."""
def _run_ext(self, ext_git_repo: Path, env_extras: dict, *extra_args: str): def _run_ext(self, ext_git_repo: Path, env_extras: dict, *extra_args: str):
script = ext_git_repo / ".specify" / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh" script = ext_git_repo / ".specify" / "extensions" / "git" / "scripts" / "bash" / "create-new-feature-branch.sh"
cmd = ["bash", str(script), "--json", *extra_args, "ignored"] cmd = ["bash", str(script), "--json", *extra_args, "ignored"]
return subprocess.run(cmd, cwd=ext_git_repo, capture_output=True, text=True, return subprocess.run(cmd, cwd=ext_git_repo, capture_output=True, text=True,
env={**os.environ, **env_extras}) env={**os.environ, **env_extras})
@@ -1101,10 +929,10 @@ class TestGitBranchNameOverrideBash:
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed") @pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
class TestGitBranchNameOverridePowerShell: class TestGitBranchNameOverridePowerShell:
"""Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.ps1.""" """Tests for GIT_BRANCH_NAME env var override in extension create-new-feature-branch.ps1."""
def _run_ext(self, ext_ps_git_repo: Path, env_extras: dict): def _run_ext(self, ext_ps_git_repo: Path, env_extras: dict):
script = ext_ps_git_repo / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1" script = ext_ps_git_repo / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature-branch.ps1"
return subprocess.run( return subprocess.run(
["pwsh", "-NoProfile", "-File", str(script), "-Json", "ignored"], ["pwsh", "-NoProfile", "-File", str(script), "-Json", "ignored"],
cwd=ext_ps_git_repo, capture_output=True, text=True, cwd=ext_ps_git_repo, capture_output=True, text=True,
@@ -1230,9 +1058,8 @@ class TestFeatureDirectoryResolution:
pytest.fail("FEATURE_DIR not found in output") pytest.fail("FEATURE_DIR not found in output")
@requires_bash @requires_bash
def test_fallback_to_branch_lookup(self, git_repo: Path): def test_errors_without_env_var_or_feature_json(self, git_repo: Path):
"""Without env var or feature.json, falls back to branch-based lookup.""" """Without env var or feature.json, get_feature_paths now errors."""
subprocess.run(["git", "checkout", "-q", "-b", "001-test-feat"], cwd=git_repo, check=True)
spec_dir = git_repo / "specs" / "001-test-feat" spec_dir = git_repo / "specs" / "001-test-feat"
spec_dir.mkdir(parents=True) spec_dir.mkdir(parents=True)
@@ -1242,14 +1069,8 @@ class TestFeatureDirectoryResolution:
capture_output=True, capture_output=True,
text=True, text=True,
) )
assert result.returncode == 0, result.stderr assert result.returncode != 0
for line in result.stdout.splitlines(): assert "Feature directory not found" in result.stderr
if line.startswith("FEATURE_DIR="):
val = line.split("=", 1)[1].strip("'\"")
assert val == str(spec_dir)
break
else:
pytest.fail("FEATURE_DIR not found in output")
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed") @pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
def test_ps_env_var_overrides_branch_lookup(self, git_repo: Path): def test_ps_env_var_overrides_branch_lookup(self, git_repo: Path):
@@ -1343,7 +1164,7 @@ class TestDescriptionQuoting:
ids=["apostrophe", "double-quotes", "backslashes", "mixed"], ids=["apostrophe", "double-quotes", "backslashes", "mixed"],
) )
def test_ext_script_handles_special_chars(self, ext_git_repo: Path, description: str): def test_ext_script_handles_special_chars(self, ext_git_repo: Path, description: str):
"""Extension create-new-feature.sh succeeds with special characters in description.""" """Extension create-new-feature-branch.sh succeeds with special characters in description."""
script = ( script = (
ext_git_repo ext_git_repo
/ ".specify" / ".specify"
@@ -1351,7 +1172,7 @@ class TestDescriptionQuoting:
/ "git" / "git"
/ "scripts" / "scripts"
/ "bash" / "bash"
/ "create-new-feature.sh" / "create-new-feature-branch.sh"
) )
result = subprocess.run( result = subprocess.run(
["bash", str(script), "--dry-run", "--short-name", "feat", description], ["bash", str(script), "--dry-run", "--short-name", "feat", description],

View File

@@ -1,6 +1,6 @@
"""Regression guard: utility and asset symbols importable from specify_cli.""" """Regression guard: utility and asset symbols importable from specify_cli."""
from specify_cli import ( from specify_cli import (
check_tool, is_git_repo, merge_json_files, check_tool, merge_json_files,
get_speckit_version, get_speckit_version,
CLAUDE_LOCAL_PATH, CLAUDE_NPM_LOCAL_PATH, CLAUDE_LOCAL_PATH, CLAUDE_NPM_LOCAL_PATH,
) )
@@ -9,7 +9,6 @@ from pathlib import Path
def test_utils_symbols_importable(): def test_utils_symbols_importable():
assert callable(check_tool) assert callable(check_tool)
assert callable(merge_json_files) assert callable(merge_json_files)
assert callable(is_git_repo)
def test_get_speckit_version_returns_string(): def test_get_speckit_version_returns_string():
version = get_speckit_version() version = get_speckit_version()

View File

@@ -658,6 +658,47 @@ class TestCommandStep:
# Claude is a SkillsIntegration so uses /speckit-specify # Claude is a SkillsIntegration so uses /speckit-specify
assert "/speckit-specify login" in call_args[0][0][2] assert "/speckit-specify login" in call_args[0][0][2]
def test_dispatch_uses_executable_override_for_fallback_preflight(self, tmp_path, monkeypatch):
"""Command preflight falls back to build_exec_args() argv[0]."""
from unittest.mock import MagicMock, patch
from specify_cli.workflows.steps.command import CommandStep
from specify_cli.workflows.base import StepContext, StepStatus
monkeypatch.setenv("SPECKIT_INTEGRATION_CLAUDE_EXECUTABLE", "/opt/claude")
seen_which: list[str] = []
def fake_which(name: str) -> str | None:
seen_which.append(name)
return name if name == "/opt/claude" else None
step = CommandStep()
ctx = StepContext(
inputs={"name": "login"},
default_integration="claude",
project_root=str(tmp_path),
)
config = {
"id": "test",
"command": "speckit.specify",
"input": {"args": "{{ inputs.name }}"},
}
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = '{"result": "done"}'
mock_result.stderr = ""
with patch("specify_cli.workflows.steps.command.shutil.which", side_effect=fake_which), \
patch("subprocess.run", return_value=mock_result) as mock_run:
result = step.execute(config, ctx)
assert result.status == StepStatus.COMPLETED
assert result.output["dispatched"] is True
assert seen_which[:2] == ["claude", "/opt/claude"]
call_args = mock_run.call_args
assert call_args[0][0][0] == "/opt/claude"
assert "/speckit-specify login" in call_args[0][0][2]
def test_dispatch_failure_returns_failed_status(self, tmp_path): def test_dispatch_failure_returns_failed_status(self, tmp_path):
"""When the CLI exits non-zero, the step should fail.""" """When the CLI exits non-zero, the step should fail."""
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
@@ -810,6 +851,46 @@ class TestPromptStep:
assert result.output["dispatched"] is True assert result.output["dispatched"] is True
assert result.output["exit_code"] == 0 assert result.output["exit_code"] == 0
def test_dispatch_uses_executable_override_for_fallback_preflight(self, tmp_path, monkeypatch):
"""Prompt preflight falls back to build_exec_args() argv[0]."""
from unittest.mock import MagicMock, patch
from specify_cli.workflows.steps.prompt import PromptStep
from specify_cli.workflows.base import StepContext, StepStatus
monkeypatch.setenv("SPECKIT_INTEGRATION_CLAUDE_EXECUTABLE", "/opt/claude")
seen_which: list[str] = []
def fake_which(name: str) -> str | None:
seen_which.append(name)
return name if name == "/opt/claude" else None
step = PromptStep()
ctx = StepContext(
default_integration="claude",
project_root=str(tmp_path),
)
config = {
"id": "ask",
"type": "prompt",
"prompt": "Explain this code",
}
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = "Here is the explanation"
mock_result.stderr = ""
with patch("specify_cli.workflows.steps.prompt.shutil.which", side_effect=fake_which), \
patch("subprocess.run", return_value=mock_result) as mock_run:
result = step.execute(config, ctx)
assert result.status == StepStatus.COMPLETED
assert result.output["dispatched"] is True
assert seen_which[:2] == ["claude", "/opt/claude"]
call_args = mock_run.call_args
assert call_args[0][0][0] == "/opt/claude"
assert call_args[0][0][2] == "Explain this code"
def test_validate_missing_prompt(self): def test_validate_missing_prompt(self):
from specify_cli.workflows.steps.prompt import PromptStep from specify_cli.workflows.steps.prompt import PromptStep