Compare commits

...

75 Commits

Author SHA1 Message Date
github-actions[bot]
b2314680fc chore: bump version to 0.8.13 2026-05-21 17:39:27 +00:00
Manfred Riem
616eba6a57 fix: while/do-while loop condition reads stale iteration-0 step output (#2662)
* fix: while/do-while loop condition reads stale iteration-0 step output

After executing namespaced loop body steps, copy each iteration's
results back to the original unprefixed step key so that
evaluate_condition() sees the latest values instead of stale
iteration-0 data.

Fixes #2592

* address review: cross-platform tests, preserve iteration-0 history

- Rewrite shell scripts in tests to use Python via script files
  instead of POSIX syntax, so they pass on Windows CI.
- Snapshot iteration-0 nested-step results under a namespaced key
  (parent:child:0) before the first copy-back overwrite, preserving
  complete per-iteration history for debugging.

* address review: skip copy-back on paused/failed iterations

Move the status check before the copy-back so that partial results
from paused or failed nested steps (e.g., a gate awaiting input)
do not overwrite the unprefixed key. This preserves correct resume
behavior.

* address review: quote paths in test shell commands

Quote both the Python executable and script file paths in the
run: commands to handle spaces in paths on Windows.

* address review: execute loop body with original IDs

Instead of namespacing step IDs for execution and copying results
back, execute the loop body with original (unprefixed) step IDs so
results naturally land at the right keys.  Snapshot previous
iteration results to namespaced keys (parent:child:N) for history
only.

This fixes multi-step loop bodies where step B references step A's
output within the same iteration — previously step B would see
stale data until the copy-back ran after the entire iteration.

* address review: namespaced execution with per-step copy-back

Revert to namespaced step IDs for execution (preserving unique
log entries and state keys per iteration) but copy each step's
result back to the unprefixed key immediately after it completes.

This preserves backward compatibility (same namespaced key format,
same log IDs) while fixing both the condition evaluation bug and
inter-step references within multi-step loop bodies.

* address review: alias after status check, add multi-step body test

- Move per-step aliasing below the PAUSED/FAILED/ABORTED status
  check so partial results from incomplete steps are not aliased
  back to the unprefixed key.
- Add test_while_loop_multi_step_body_inter_step_refs to exercise
  a multi-step loop body where step B reads step A's output within
  the same iteration, verifying per-step aliasing works correctly.

Addresses feedback from @doquanghuy (items 2 & 4) and Copilot
review on commit 9d0a222.

* address review: stable fallback IDs, expression-based inter-step test

- Use enumerate() for stable fallback IDs when loop body steps lack
  an explicit id (step-0, step-1, etc. instead of always step-0).
- Rewrite multi-step body test so step B uses expression
  substitution ({{ steps.step-a.output.stdout }}) instead of
  reading the counter file directly, making it a true regression
  test for per-step aliasing.
2026-05-21 12:25:03 -05:00
Hasik Choi
1bf4a6eb35 docs: fix directory hierarchy in README examples (#2639) 2026-05-21 08:38:35 -05:00
Quratulain-bilal
0dee2faf11 fix(catalogs): reject boolean priority in extension and preset catalog readers (#2589)
`bool` is a subclass of `int` in Python, so `int(True)` silently returns
`1`. The extension- and preset-catalog config readers coerced priority
with a bare `int(item.get("priority", idx + 1))`, which meant a YAML
config like:

    catalogs:
      - name: mine
        url: https://example.com/catalog.json
        priority: yes     # parses to True

was silently accepted as a valid priority of 1, quietly reordering the
catalog stack instead of raising the same `Invalid priority` error a
typo of `priority: not-a-number` already raises.

The sibling integration-catalog reader in `src/specify_cli/catalogs.py`
already guards this case (see `catalogs.py:137`). This change mirrors
that pattern in `extensions.py` and `presets.py` so the three catalog
validators stay consistent, and adds regression tests for both readers
matching the existing `test_load_catalog_config_rejects_boolean_priority`
template in `tests/integrations/test_integration_catalog.py`.
2026-05-21 08:21:13 -05:00
Manfred Riem
7fda89decb Update Agent Governance extension to v1.2.0 (#2659)
Update agent-governance extension submitted by @bigsmartben:
- extensions/catalog.community.json (version, download_url, description, tools)
- docs/community/extensions.md community extensions table

Closes #2624
2026-05-21 08:08:46 -05:00
Manfred Riem
0964f113b7 Add agentic workflows for community catalog submissions (#2655)
* Add agentic workflows for community catalog submissions

Add GitHub Agentic Workflows that automatically process community
extension and preset submission issues:

- add-community-extension.md: triggered by extension-submission issues,
  validates the submission, updates extensions/catalog.community.json
  and docs/community/extensions.md, then opens a draft PR
- add-community-preset.md: parallel workflow for preset-submission
  issues, updates presets/catalog.community.json and
  docs/community/presets.md

Both workflows:
- Trigger on opened, edited, or labeled events (maintainers can
  retroactively label pre-existing issues)
- Validate ID format, semver, repo existence, required files, release,
  and submission checklists
- Label issues with validation-passed or validation-failed
- Create draft PRs with Closes #N for maintainer review

Also includes gh-aw scaffolding (.github/aw/, .gitattributes lock file
rule, dependabot ignore for gh-aw-actions).

* Suppress whitespace checks on generated .lock.yml files

These files are auto-generated by gh aw compile and contain trailing
whitespace in the ASCII art header and indented YAML blocks that we
cannot control. Add -whitespace attribute to skip git whitespace
checks on them.
2026-05-21 07:13:11 -05:00
Pascal THUET
b4b83be51b feat: add self-check tip to check output (#2574)
* feat: add self-check tip to check output

* style: drop trailing period from self-check tip

Aligns the new tip with the other `Tip:` lines in `specify check`,
which don't end in a period. Per Copilot review feedback on #2574.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 21:21:11 -05:00
darion-yaphet
3d50f85875 fix(cli): clarify exception diagnostics (#2602)
Consolidate the CLI diagnostic plan, implementation, and test hardening into one reviewable change. The CLI now reports phase and target context for broad failure paths while preserving existing fail-fast behavior for real setup failures and warning-only behavior for optional best-effort work.

The workflow unit tests also avoid discovering real local agent CLIs, so developer machines with tools such as gemini installed do not hang pytest during metadata-only assertions.

Constraint: CLI setup failures must remain fail-fast, while optional preset and cleanup paths should continue with clear warnings.

Rejected: Replace broad handlers across the whole codebase in one pass | too broad for a targeted CLI diagnostic fix

Rejected: Add runtime timeouts to workflow agent dispatch | dispatch may legitimately be long-running and the observed hang was test isolation

Confidence: high

Scope-risk: moderate

Directive: Keep future best-effort CLI warnings tied to the failed phase and target so users can diagnose setup state.

Tested: uvx ruff check src/; uv run pytest tests/integrations/test_cli.py -v; uv run pytest tests/test_workflows.py::TestCommandStep::test_step_override_integration tests/test_workflows.py::TestPromptStep::test_execute_with_step_integration tests/test_workflows.py::TestPromptStep::test_execute_with_model -vv; uv run pytest

Not-tested: Real Nacos/PG/Redis-style external service failure injection; real interactive workflow dispatch against installed gemini CLI
2026-05-20 21:19:48 -05:00
Pascal THUET
0b9bd90021 ci: add diff whitespace check (#2572) 2026-05-20 20:57:00 -05:00
Manfred Riem
bae355a234 chore: release 0.8.12, begin 0.8.13.dev0 development (#2648)
* chore: bump version to 0.8.12

* chore: begin 0.8.13.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-20 09:15:27 -05:00
Chao Z
9735145289 fix(codex): inject dot-to-hyphen hook command note in Codex skills (#2503)
* fix(codex): inject dot-to-hyphen hook command note in Codex skills

Hook commands in `.specify/extensions.yml` use dotted ids like
`speckit.git.commit`, but Codex skills are named with hyphens
(`speckit-git-commit`). The Claude integration handles this via an
explicit instruction injected into each generated SKILL.md by
`ClaudeIntegration.post_process_skill_content`, but the Codex
integration had no such override, so Codex would emit
`/speckit.git.commit` (which does not resolve) instead of
`/speckit-git-commit`.

This adds the same `_inject_hook_command_note` helper and a
`post_process_skill_content` override to `CodexIntegration`, plus a
small `setup()` override that applies the post-process to each
generated SKILL.md (mirroring the pattern in `ClaudeIntegration`).

Also widens the existing
`test_non_claude_post_process_is_identity` test to use `agy`
(another `SkillsIntegration` with no override), since asserting
identity behavior on Codex would now incorrectly fail.

Tests:
- New `TestCodexHookCommandNote` class mirrors
  `TestClaudeHookCommandNote`: setup-level injection, no-op when
  no hook block is present, idempotency, and indentation
  preservation.
- `pytest tests/` → 2866 passed, 34 skipped.

Signed-off-by: Chao Zhang <1175468+picklebento@users.noreply.github.com>

* fix(codex): handle empty eol when instruction is final line without newline

The hook-note injection regex allowed end-of-string matches via ``$``,
which left the captured ``eol`` empty. When the matched indent was also
empty, the substitution concatenated the note onto the same line as the
instruction. Default ``eol`` to ``\n`` when the capture is empty.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Signed-off-by: Chao Zhang <1175468+picklebento@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 09:04:47 -05:00
Manfred Riem
68a031c768 Update Squad Bridge extension to v1.3.0 (#2645)
Update squad extension submitted by @jwill824:
- extensions/catalog.community.json (version, download_url, speckit_version, tools version, description, updated_at)
- docs/community/extensions.md community extensions table

Closes #2608
2026-05-20 06:56:50 -05:00
Manfred Riem
a59381ae30 Update Superpowers Implementation Bridge extension to v0.5.0 (#2644)
Update speckit-superpowers-bridge extension submitted by @lihan3238:
- extensions/catalog.community.json (version, download_url, updated_at)

Closes #2601
2026-05-20 06:35:56 -05:00
Manfred Riem
975498e11d Add Team Assign extension to community catalog (#2642)
Add team-assign extension submitted by @tarunkumarbhati to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #2597
2026-05-20 06:11:09 -05:00
WOLIKIMCHENG
51e6a140e2 refactor: migrate extension catalog stack parsing to shared base (#2576)
Co-authored-by: root <1647273252@qq.com>
2026-05-18 07:02:18 -05:00
Manfred Riem
81e9ecd4d9 Update Architecture Workflow extension to v1.1.0 (#2588)
Update arch extension submitted by @bigsmartben:
- extensions/catalog.community.json (version, download_url, description, commands count, updated_at)
- docs/community/extensions.md community extensions table

Closes #2577
2026-05-15 16:21:34 -05:00
Quratulain-bilal
409ec59704 fix(workflow): support integration: auto to follow project's initialized AI (#2421)
* fix(workflow): support integration: auto to follow project's initialized AI

Closes #2406

(squashed)

* fix(workflow): combine JSONDecodeError and UnicodeDecodeError handling

Address Copilot feedback: UnicodeDecodeError can be raised by both
read_text() and json.loads(), so combining the handlers ensures both
cases produce a consistent, clear error message.

* fix(workflows): honor integration_state schema guard and modern state in 'integration: auto'

Three Copilot follow-ups on PR #2421:

1. engine.py:799 — `_load_project_integration` was bypassing the same
   schema guard `_read_integration_json` enforces. It now reads the
   schema field directly, returns None on a future schema (so the
   workflow falls back to the literal 'auto' default rather than
   guessing), and routes through `normalize_integration_state` /
   `default_integration_key` so modern installs that record
   `default_integration` / `installed_integrations` (without the
   legacy top-level `integration` field) resolve correctly.

2. test_workflows.py — added two regression cases:
   - `integration: auto` resolves a modern normalized state file
   - `integration: auto` falls back when the state file declares a
     newer `integration_state_schema` than this CLI supports

3. test_cli.py — added a CLI-level regression for the `UnicodeDecodeError`
   branch in `_read_integration_json` to match the existing
   malformed-JSON coverage.

* refactor(integration): extract shared try_read_integration_json helper

Address Copilot review on PR #2421:

Both `_read_integration_json` (CLI) and `_load_project_integration`
(workflow engine) were parsing `.specify/integration.json` independently,
duplicating the schema guard and risking drift between the two readers.

Extract the parse + schema validation into a single low-level helper
`try_read_integration_json` in `integration_state.py` that returns either
the normalized state or a structured `IntegrationReadError`. Both callers
now delegate to this helper:

- CLI keeps its loud-fail UX: each error kind ("decode", "os",
  "not_object", "schema_too_new") is translated into the existing console
  message + typer.Exit(1).
- Engine keeps its silent fallback: any error simply returns None so
  `integration: auto` falls back to the workflow's literal default.

This eliminates the divergence Copilot flagged without changing observable
behavior for either caller.

* fix(integration): distinguish missing file from non-regular path

Address Copilot review on PR #2421:

`try_read_integration_json` was collapsing two distinct cases into a
single `(None, None)` return:

1. `.specify/integration.json` truly missing — silent fallback is correct.
2. Path exists but is a directory, socket, or other non-regular file —
   this is a misconfiguration the CLI should surface loudly.

Split the check: `exists()` falsey returns `(None, None)`; existing-but-
not-a-regular-file returns `(None, IntegrationReadError(kind="os", ...))`
so the CLI's loud-fail path produces an actionable error while the
engine still treats it as a fallback to the workflow's literal default.

* docs(workflow): clarify version pin, advisory integrations list, enum exemption

- workflow.yml: fix comment that said 0.8.3 was first release with auto
  resolution; the pin is >=0.8.5 so the comment now matches the pin.
- workflow.yml: clarify that requires.integrations.any is an advisory,
  non-exhaustive compatibility hint, not a closed set.
- engine.py: clarify that the auto-sentinel exemption only skips enum
  membership; declared type is still enforced through _coerce_input.

* fix(workflow): resolve auto sentinel for provided values; report stat errors

Two Copilot findings fixed:

1. _resolve_inputs only resolved the ``integration: auto`` sentinel when it
   came from the input default. A caller explicitly providing
   ``{"integration": "auto"}`` (which the workflow prompt advertises as a
   valid value) bypassed _resolve_default and the literal "auto" leaked
   to dispatch. Provided values now go through the same resolution path
   as defaults, and the enum-membership exemption applies in both cases.
   Regression test added.

2. try_read_integration_json used Path.exists() / Path.is_file() as a
   pre-check. Both return False on some OSErrors (e.g. permission errors
   during stat), which silently treated an unreadable-but-present file
   as missing — the engine fell back without warning and the CLI failed
   to surface the loud error. The pre-check is gone: read_text() is
   attempted directly, FileNotFoundError means missing (silent fallback),
   IsADirectoryError and other OSErrors become loud IntegrationReadError.

* fix(workflow): enforce declared type for string inputs, reject bool-as-number

Two Copilot findings fixed:

1. _coerce_input previously coerced/validated only ``number`` and
   ``boolean`` types, so ``type: string`` silently accepted any Python
   value (numbers, lists, dicts). A YAML authoring mistake like
   ``type: string`` + ``default: 5`` slipped through. Strings are now
   required to actually be strings; non-strings raise ValueError, which
   surfaces as an ``invalid default`` error from validate_workflow.

2. ``type: number`` accepted ``default: true`` because ``bool`` is a
   subclass of ``int`` (``float(True) == 1.0``). Bools are now rejected
   explicitly in the number path so the YAML mistake fails fast. The
   boolean path is also tightened to reject non-bool / non-string
   values for symmetry.

Comment on the auto-sentinel enum exemption updated to reflect the
stronger guarantee. Regression tests added for both rejections.

* fix(cli): drop unused normalize_integration_state import to satisfy ruff

CI's `uvx ruff check src/` flagged this as F401: the symbol was imported
under a private alias but never referenced. Tests stay green after
removal.
2026-05-15 16:03:33 -05:00
Manfred Riem
b36c34f171 Add Superpowers Implementation Bridge extension to community catalog (#2586)
Add speckit-superpowers-bridge extension submitted by @lihan3238 to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #2581
2026-05-15 15:41:59 -05:00
Manfred Riem
8bd20a2f5f Add Interactive HTML Preview extension to community catalog (#2585)
Add preview extension submitted by @bigsmartben to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #2578
2026-05-15 15:13:33 -05:00
Manfred Riem
4c610a20dc chore: release 0.8.11, begin 0.8.12.dev0 development (#2584)
* chore: bump version to 0.8.11

* chore: begin 0.8.12.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-15 15:08:38 -05:00
Manfred Riem
27700387b6 Update Agent Governance extension to v1.1.0 (#2583)
Update agent-governance extension submitted by @bigsmartben:
- extensions/catalog.community.json (version, download_url, updated_at)

Closes #2569
2026-05-15 14:59:46 -05:00
darion-yaphet
d947fda96f refactor: extract _version.py from __init__.py (PR-3/8) (#2550)
* refactor: extract _version.py from __init__.py (PR-3/8)

Move version-checking helpers and `specify self` sub-commands into a
focused `_version.py` module.

Moved symbols:
- GITHUB_API_LATEST — GitHub releases API endpoint constant
- _get_installed_version — importlib.metadata-based version lookup
- _normalize_tag — strip leading 'v' from release tag strings
- _is_newer — PEP 440 version comparison
- _fetch_latest_release_tag — single outbound call to GitHub API
- self_app — Typer sub-app for `specify self`
- self_check, self_upgrade — `specify self check/upgrade` commands

Dependency rule: _version.py imports only stdlib + packaging + ._console.

Backward compatibility: GITHUB_API_LATEST, self_check, self_upgrade
remain importable from specify_cli via re-exports in __init__.py.

Update test_upgrade.py to import helpers from specify_cli._version and
patch at the correct module path (specify_cli._version.*).
Add test_version_imports.py as regression guard.

* fix(tests): update _fetch_latest_release_tag import path in test_authentication.py

PR-3 moved _fetch_latest_release_tag from specify_cli into
specify_cli._version. test_upgrade.py was updated at the time, but
test_authentication.py::TestFetchLatestReleaseTagDelegation still
imported from the old location, causing ImportError on all three
delegation tests. Update all three inline imports to the correct
module path.
2026-05-15 13:12:24 -05:00
Manfred Riem
13c167e107 Add Time Machine extension to community catalog (#2580)
* Add Time Machine extension to community catalog

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

Closes #2568

* Fix alphabetical ordering of time-machine entry

Move time-machine before tinyspec in both catalog JSON (by ID)
and docs table (by name), since time < tiny alphabetically.
2026-05-15 08:43:02 -05:00
Nimra Akram
f684305e51 fix(powershell): ensure UTF-8 templates are written without BOM (#2280)
* fix(powershell): strip BOM from templates and ensure No-BOM output

* fix: address review feedback on encoding and naming for all ps scripts

* fix: address copilot feedback (encoding detection and variable naming)

* fix: remove duplicate comments in setup-plan.ps1

* test: verify spec.md is written without UTF-8 BOM

* test: also verify BOM-free output under Windows PowerShell 5.1

* fix

* fix: resolve merge conflict with main, add TestDescriptionQuoting

* fix: resolve TestDescriptionQuoting string quoting conflict with main

* test: restore PowerShell prefix-stripping parity test in TestGetFeaturePathsSinglePrefix

* fix: remove trailing whitespace from module docstring blank lines

* Potential fix for pull request finding

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

* test: seed ps_git_repo with BOM-prefixed template to exercise WriteAllText fix

* fix: remove duplicate ps_git_repo fixture, restore ext_ps_git_repo

* fix: remove unrelated TestDescriptionQuoting and restore original test_ps_specify_feature_prefixed_resolves_by_prefix

---------

Co-authored-by: Nimraakram22 <nimra.akram123451@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-15 07:39:17 -05:00
Asish Kumar
b774282058 docs: document high-assurance spec workflow (#2518)
* docs: document high-assurance workflow

* docs: clarify analyze workflow gate

Signed-off-by: Asish Kumar <officialasishkumar@gmail.com>

---------

Signed-off-by: Asish Kumar <officialasishkumar@gmail.com>
2026-05-15 07:12:59 -05:00
darion-yaphet
6322a4d429 docs: fix script name in directory tree examples (#2555)
* docs: fix script name in directory tree examples

Replace update-claude-md.sh with the actual filename setup-tasks.sh
in two directory tree examples (Steps 2 and 4 of the detailed walkthrough).

* docs: fix .specify/scripts layout to show bash/ and powershell/ subdirs

install_shared_infra() installs scripts under .specify/scripts/bash/
(script_type="sh") or .specify/scripts/powershell/ (script_type="ps"),
not directly under .specify/scripts/. Update docs/installation.md and
the _install_shared_infra docstring to reflect the actual on-disk layout.

* docs: update README tree examples to show scripts/bash/ subdirectory

Scripts are installed under .specify/scripts/bash/ (or powershell/)
not directly under .specify/scripts/. Fix both tree diagrams in the
Detailed Process walkthrough to match the actual on-disk layout.
2026-05-14 15:14:52 -05:00
WOLIKIMCHENG
be382804c7 Fix preset skill description precedence (#2538)
* Fix preset skill description precedence

* Fix skill description precedence during restore

---------

Co-authored-by: root <1647273252@qq.com>
2026-05-14 14:59:38 -05:00
Pascal THUET
c87081a50a fix(integration): clarify multi-install guidance (#2549) 2026-05-14 13:46:48 -05:00
Pascal THUET
e6afba9429 feat: add version feature reporting (#2548) 2026-05-14 12:52:14 -05:00
Manfred Riem
c1a1653aca Add Architecture Workflow extension to community catalog (#2565)
Add arch extension submitted by @bigsmartben to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #2556
2026-05-14 12:10:47 -05:00
Manfred Riem
0e5b59fcaa chore: release 0.8.10, begin 0.8.11.dev0 development (#2562)
* chore: bump version to 0.8.10

* chore: begin 0.8.11.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-14 10:20:54 -05:00
Manfred Riem
707e929c2a docs: streamline install section and add community overview (#2561)
- Shorten README.md install section to single uv command + link to
  installation guide for alternatives and troubleshooting
- Add explicit 'Initialize a project' step to README Get Started
- Remove duplicate Troubleshooting section from README
- Reorder 'Make it your own' card on docs landing page so extensions
  and presets are explained before the stats
- Update Community nav-card to link to new community overview
- Create docs/community/overview.md landing page (aligned with
  reference/overview.md)
- Create dedicated install sub-pages: pipx, one-time (uvx), air-gapped
- Update docs/installation.md to lead with persistent uv install and
  link to sub-pages instead of duplicating content
- Update docs/toc.yml with new pages
- Remove stale EOF file
2026-05-14 10:14:32 -05:00
Manfred Riem
59fa8b5947 Move community extensions table from README to docs site (#2560)
* Add Agent Governance extension to community catalog

Add agent-governance extension submitted by @bigsmartben to:
- extensions/catalog.community.json (alphabetical order)
- README.md community extensions table

Closes #2552

* Move community extensions table from README to docs site

- Create docs/community/extensions.md with full extensions table
- Replace ~120-line table in README.md with summary + link to docs site
- Add Extensions entry to docs/toc.yml under Community
- Update add-community-extension SKILL.md references
2026-05-14 09:20:37 -05:00
Manfred Riem
def1a05420 Add Agent Governance extension to community catalog (#2559)
Add agent-governance extension submitted by @bigsmartben to:
- extensions/catalog.community.json (alphabetical order)
- README.md community extensions table

Closes #2552
2026-05-14 08:15:55 -05:00
Manfred Riem
4f05eff4e4 Add Reqnroll BDD extension to community catalog (#2545)
Add reqnroll-bdd extension submitted by @stenyin (LoogaCY Studio) to:
- extensions/catalog.community.json (alphabetical order)
- README.md community extensions table

Closes #2544
2026-05-13 14:02:36 -05:00
Dyan Galih
59fdca5997 fix(cli): harden extension registration and discovery workflows (#2499)
* chore: update community catalog with latest extension versions

- Update memory-md from 0.7.9 to 0.8.0
- Update architecture-guard from 1.6.7 to 1.8.0

* fix(cli): harden extension registration with project-level tracking in extensions.yml

* test(cli): add comprehensive unit tests for extension registration logic

* chore: remove out-of-scope catalog changes

* refactor: address PR feedback for extension registration hardening

* fix: harden extension registration defensive logic and add comprehensive unregister_hooks tests

- Add dict guard to register_hooks() to handle corrupted extensions.yml (non-dict root)
- Add 5 comprehensive tests for unregister_hooks() workflow:
  * Full workflow with hooks + installed list removal
  * Resilience when config has no 'hooks' key
  * Corrupted YAML handling
  * Multiple extension scenarios
  * All 11 tests passing

* fix: sanitize installed to strings, guard unregister_hooks dict, handle null hook values

- register_extension(): filter non-string entries from installed before sort
- register_hooks(): normalize hooks to {} when missing or not a dict
- unregister_hooks(): add isinstance(config, dict) guard before key checks
- unregister_hooks(): coerce null/scalar hook lists to [] before iteration
- tests: add 3 regression tests for no-hooks manifest, mixed-type installed, null hook values
- All 14 tests passing

* fix(cli): persist sanitization results and harden hook registration

* Harden extension registration to always persist sanitization results

* Hardening extension registration: support mapping entries, improve persistence, and fix update rollback

* fix(cli): harden extension update and unregistration workflows

* fix(cli): move update sentinels outside try block to prevent NameError on rollback

* fix(cli): sanitize hook event lists in register_hooks to prevent crashes

* fix(cli): deduplicate hook entries and harden rollback hooks-restore guards

* test(cli): add regression tests for extension update and rollback hardening

* fix(cli): deduplicate installed list by id in register_extension

* fix(cli): consolidate and harden extension update rollback logic

* fix(cli): initialize backup_registry_entry before try block to prevent UnboundLocalError on rollback

* fix(tests): return Path from download_extension mock and add Path import

* fix(cli): normalize get_project_config() return to dict; deduplicate in unregister_extension()

* fix(cli): normalize hooks/installed/settings in get_project_config(); use tmp_path-scoped zip in tests

* fix(cli): set modified=True on hook coercion in rollback; sanitize hook event values in get_project_config(); harden test assertions

* fix(cli): filter non-dict hook entries in get_project_config(); remove dead MISSING sentinel

* fix(cli): gate extensions.yml rollback on backup_hooks is not None; update stale comment

* fix(cli): move _AgentReg import outside try block; assert result.exception is None in tests

* fix(extensions): consistent key order in default config; deep-copy backup_installed

* test: fix misleading comment; assert exit_code==1 in rollback test

* test: clean up duplicate imports in hardening tests

* refactor(extensions): extract _sanitize_installed_list helper; strengthen hook unregister assertion

* fix(extensions): validate extension IDs in _sanitize_installed_list; clarify test comment
2026-05-13 12:02:01 -05:00
darion-yaphet
2fb9d3bb4b refactor: extract _assets.py and _utils.py from __init__.py (PR-2/8) (#2543)
* refactor: extract _assets.py and _utils.py from __init__.py

Move bundle path resolution and version lookup into _assets.py (stdlib only,
zero internal imports), and system utilities (subprocess, tool detection,
file operations) into _utils.py (imports only from ._console). Re-export all
moved symbols from __init__.py for backward compatibility. Update
test_check_tool.py to patch both specify_cli and specify_cli._utils namespaces
since constants are now defined in _utils.

* style: apply PR-1 review patterns to _assets.py and _utils.py

- Add module docstring to _assets.py (stdlib-only, zero internal imports)
- Add blank line after `from __future__ import annotations` in both files
- Replace `Optional[X]` with `X | None` throughout _utils.py (PEP 604)
- Remove unused `Optional` import from _utils.py
- Use explicit re-export form (`X as X`) for public symbols in __init__.py
- Remove unused `subprocess` and `tempfile` imports from __init__.py (moved to _utils.py)
2026-05-13 11:20:36 -05:00
Marcus Burghardt
9732a4d092 fix(opencode): use commands/ directory (plural) to match OpenCode docs (#2453)
* fix(opencode): use commands/ directory (plural) to match OpenCode docs

OpenCode documentation (https://opencode.ai/docs/commands/) uses
.opencode/commands/ (plural) as the canonical command directory.
The OpenCode runtime supports both .opencode/command/ and
.opencode/commands/ via a {command,commands} glob, but the
singular form was the original convention and is now outdated.

Update the OpenCode integration to write to .opencode/commands/
instead of .opencode/command/, aligning with the documented
standard and the OpenSpec fix (Fission-AI/OpenSpec#748).

Signed-off-by: Marcus Burghardt <maburgha@redhat.com>
Assisted-by: OpenCode (claude-opus-4-6)

* feat(registrar): add legacy_dir fallback for backward-compatible directory migration

Add _resolve_agent_dir() to CommandRegistrar that checks a
legacy_dir fallback when the canonical directory does not exist.
When legacy_dir is found, a deprecation warning directs users to
run "specify integration upgrade" to migrate.

The OpenCode integration declares legacy_dir: ".opencode/command"
so that extension and preset registration, as well as command
cleanup, continue working for projects that have not yet migrated
to .opencode/commands/.

The legacy_dir mechanism is opt-in: integrations that do not
declare it get no fallback and no behavioral change.

Add end-to-end test verifying that "specify integration upgrade
opencode" migrates commands from legacy .opencode/command/ to
canonical .opencode/commands/ and removes stale files.

Signed-off-by: Marcus Burghardt <maburgha@redhat.com>
Assisted-by: OpenCode (claude-opus-4-6)

* fix(registrar): address PR review feedback on legacy_dir handling

- Fix deprecation warning formatting: quote paths and remove trailing
  '/.' that produced confusing '.opencode/commands/.' output
- Eliminate duplicate warnings: pass pre-resolved directory to
  register_commands() via _resolved_dir parameter so
  _resolve_agent_dir() is only called once per agent
- Fix unregister_commands() to clean both canonical and legacy dirs
  when both exist, preventing orphaned command files after upgrade
- Add test_unregister_cleans_legacy_when_both_dirs_exist regression
  test and tighten warning count assertion to exactly 1

Assisted-by: OpenCode (claude-opus-4-6)
Signed-off-by: Marcus Burghardt <maburgha@redhat.com>

---------

Signed-off-by: Marcus Burghardt <maburgha@redhat.com>
2026-05-13 09:55:56 -05:00
darion-yaphet
4f51e066c3 refactor: extract _console.py from __init__.py (PR-1/8) (#2474)
* refactor: extract _console.py from __init__.py

Move Rich UI primitives (BANNER, TAGLINE, StepTracker, get_key,
select_with_arrows, console, BannerGroup, show_banner) into a new
src/specify_cli/_console.py module. Re-export all symbols from
__init__.py to preserve the public API. Add regression guard tests.

* refactor(console): improve type annotations and add guard for empty options

- Add module-level docstring documenting the console layer's purpose and
  the dependency-layering rule (no imports from other specify_cli modules)
- Tighten select_with_arrows() signature: options typed as dict[str, str]
  and default_key as str | None to align with repo typing style
- Add early ValueError guard when options is empty, preventing downstream
  ZeroDivisionError / IndexError inside the Live loop

* refactor(console): improve type safety and code quality in _console.py

- Add Callable import from collections.abc for precise callback typing
- Annotate StepTracker._refresh_cb as Callable[[], None] | None
- Add parameter/return types to attach_refresh()
- Use explicit keyword form typer.Exit(code=1) across all error exits
- Add blank line between StepTracker class and get_key() (PEP 8)
- Add regression test for select_with_arrows() raising ValueError on
  empty options dict

* style(cli): add __all__ declaration to fix Ruff F401 lint warnings

- Add explicit __all__ for intentional re-exports (BANNER, TAGLINE, get_key)
- Prevent F401 unused import errors in CI lint checks
- Maintain backward compatibility for external imports

* Preserve public console imports

The CLI package intentionally re-exports console helpers for compatibility, so __all__ must track that public surface instead of narrowing star imports to a partial set.

Constraint: Existing tests import console helpers directly from specify_cli

Rejected: Remove __all__ entirely | keeping an explicit export list documents the intended compatibility surface

Confidence: high

Scope-risk: narrow

Directive: Keep __all__ synchronized when adding or removing specify_cli public re-exports

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

* Potential fix for pull request finding

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

* style(cli): use explicit re-export syntax to fix ruff F401 warnings

Use `X as X` form for BANNER, TAGLINE, and get_key imports
to mark them as intentional public re-exports and silence
ruff F401 lint errors.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-13 08:57:15 -05:00
Aqil Aziz
0aae1ec2b9 Fix constitution reference in README (#2491)
* Fix constitution reference in README

* docs: clarify constitution reference
2026-05-13 07:42:10 -05:00
Manfred Riem
31a06101ef chore: release 0.8.9, begin 0.8.10.dev0 development (#2532)
* chore: bump version to 0.8.9

* chore: begin 0.8.10.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-12 17:53:55 -05:00
Manfred Riem
efdff310a2 docs: revamp landing page with four-pillar card layout (#2531)
Rewrite docs/index.md from a philosophy essay into a landing page
organized around four pillars: Spec-driven by default, Use any coding
agent, Make it your own, and Integrate into your organization.

- Add hero section with GitHub Spec Kit branding and CTA buttons
- Add 2x2 pillar card grid with GitHub Primer color accents
- Add community stats section (96K stars, 200+ contributors, etc.)
- Add navigation cards and footer install CTA
- Move SDD philosophy content to docs/concepts/sdd.md
- Add custom DocFX template overlay with card CSS and dark mode
- Set landing layout for index.md via fileMetadata
- Update toc.yml and docfx.json for new concepts section
2026-05-12 17:39:38 -05:00
Dyan Galih
372b22a9bc feat(extensions): update governance ecosystem extensions to latest versions (#2514)
* feat(extensions): update governance ecosystem extensions to latest versions

* chore: update catalog timestamp to current time
2026-05-12 16:59:56 -05:00
Quratulain-bilal
765e60f1c4 Add changelog extension (#2177)
* Add Spec Scope extension to community catalog

Adds spec-kit-scope: effort estimation and scope tracking from spec artifacts.

4 commands:
- /speckit.scope.estimate — data-driven effort estimation with three-point ranges
- /speckit.scope.compare — side-by-side spec scope comparison
- /speckit.scope.creep — scope creep detection via git history
- /speckit.scope.budget — sprint-ready time budget generation

1 hook: after_specify (auto-estimation)

Turns "how long will this take?" into a data-driven answer.

* Add Spec Changelog extension to community catalog

* Add Spec Changelog extension to community catalog

* fix: drop accidental scope entry, restore Intelligent Agent Orchestrator README row, return Spec Reference Loader to original position

Per Copilot review on PR #2177: this branch is supposed to add only the
Spec Changelog extension. The diff against main also showed (1) a duplicate
'scope' catalog entry, (2) a deletion of the Intelligent Agent Orchestrator
README row, and (3) Spec Reference Loader moved out of alphabetical order.
All three were merge artifacts and have been cleaned up here.

* Potential fix for pull request finding

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

* fix: keep Spec Changelog row alphabetically sorted

Address Copilot review on PR #2177: the Community Extensions table is
sorted alphabetically by display name, and 'Changelog' precedes 'Critique',
'Diagram', 'Orchestrator', and 'Reference', so the Spec Changelog row
belongs right after Ship Release Extension. Move it into its sorted slot
and keep Spec Reference Loader in its original alphabetical position
(between Spec Orchestrator and Spec Refine).

* fix: remove duplicate Spec Reference Loader row from README

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 12:41:33 -05:00
flipthedog
92186124f3 Add install directory to docfx.json file references (#2522)
* Add install directory to docfx.json file references

Fixed broken link for https://github.github.com/spec-kit/install/uv.md

* Potential fix for pull request finding

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

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-12 09:48:43 -05:00
Manfred Riem
20ef9a72a9 feat(catalog): add BrownKit (brownkit) community extension (#2510) (#2520)
* feat(catalog): add BrownKit (brownkit) community extension (#2510)

* Potential fix for pull request finding

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

* fix: bump catalog-level updated_at to match newest entry

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-12 07:51:00 -05:00
Eldar Shlomi
cba00ab9a5 fix(kiro-cli): replace literal $ARGUMENTS with prose fallback (#2482)
* fix(kiro-cli): replace literal $ARGUMENTS with prose fallback

Kiro CLI file-based prompts do not natively substitute any
argument placeholder (kirodotdev/Kiro#4141, kiro.dev/docs/cli
manage-prompts), so the literal "$ARGUMENTS" set in
KiroCliIntegration.registrar_config["args"] reached the model
verbatim and broke the prompt — every parameterized SpecKit
command under Kiro CLI was unusable.

Replace the placeholder with a prose fallback that instructs
the model to take its argument from the user's next message,
mirroring the convention used by other integrations whose
target CLI lacks native argument injection.

Add two regression tests in TestKiroCliIntegration:
  - test_rendered_prompts_do_not_contain_raw_arguments
  - test_rendered_prompts_contain_kiro_arg_placeholder
and override the inherited test_registrar_config so it does
not require args == "$ARGUMENTS".

Fixes #1926

* test(kiro-cli): tighten args regression guard + document quirk

Address review feedback on PR #2482.

Two changes that bracket the original bug fix from both sides — code AND
documentation:

1. Test layer (Copilot finding at lines 27, 56)

The previous test_registrar_config asserted only that args != "$ARGUMENTS"
and that args is truthy. That would silently pass if a future change
swapped $ARGUMENTS for $INPUT, {{userMessage}}, <args>, or any other
unsubstituted placeholder syntax — defeating the regression guard for
issue #1926.

Replace with a dual-layer guard:

  - test_registrar_config_args_is_exact_prose_fallback pins args to the
    imported _KIRO_ARG_FALLBACK constant. Wording drift now requires a
    deliberate paired commit (production constant + test).

  - test_registrar_config_args_does_not_look_like_a_placeholder_token is
    an independent regression guard built on a 7-pattern regex set
    covering Bash ($X, ${X}, ${X:-default}), Mustache/Handlebars/Jinja
    ({{X}}, {{{X}}}), Liquid/Jinja control ({% %}), Python str.format /
    .NET ({0}, {var}), angle-bracket (<X>), and Windows (%X%). Patterns
    are anchored to the full string so legitimate prose mentioning a
    placeholder ("the {{magic}} of placeholders") is not flagged.

Also fix the line-56 tautology by importing _KIRO_ARG_FALLBACK directly
into test_rendered_prompts_contain_kiro_arg_placeholder, instead of
reading the constant back from registrar_config["args"]. The test now
verifies the FALLBACK STRING reaches the rendered output, independent
of the integration's own config staying correct.

2. Docs layer (mnriem CHANGES_REQUESTED)

The Kiro CLI row in docs/reference/integrations.md only documented its
alias. Update the notes column to lead with the limitation — Kiro CLI
does not substitute $ARGUMENTS in file-based prompts, so Spec Kit ships
a prose fallback at render time — with inline links to upstream Kiro
"Manage prompts" docs and issue #1926. Style follows the Pi row
("limitation first, alias preserved at end").

Refs #1926
2026-05-12 07:48:25 -05:00
adaumann
a7f6800fcc Preset: Add game-narrative-writing preset to community catalog (#2454)
* Add game-narrative-writing  preset to community catalog

- Preset ID: game-narrative-writing
- Version: 1.0.0
- Author: Andreas Daumann
- Description: Spec-Driven Development for interactive game narrative for 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.

Co-authored-by: Copilot <copilot@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>

* 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 <copilot@github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-12 07:35:37 -05:00
Asish Kumar
cd951acb9e docs: clarify CLI upgrade discovery (#2519) 2026-05-12 07:19:40 -05:00
WOLIKIMCHENG
756d632129 fix: make template metadata line breaks markdownlint-safe (#2505)
Co-authored-by: root <1647273252@qq.com>
2026-05-12 07:07:41 -05:00
Pascal THUET
0593565607 refactor(catalogs): extract integration catalog config loading (#2497) 2026-05-11 15:25:00 -05:00
Quratulain-bilal
bf47e89249 test(presets): silence expected UserWarnings in self-test composition… (#2373)
* test(presets): silence expected UserWarnings in self-test composition tests

The self-test preset that ships with the repo provides a wrap-strategy
command (speckit.wrap-test) intentionally without a corresponding core
base layer, exercising the 'no base layer' branch of
_reconcile_composed_commands().

Eighteen tests across TestSelfTestPreset and TestPresetSkills install
this preset and trigger an expected UserWarning. Running the suite with
-W error::UserWarning surfaces them as test noise that could obscure
unrelated warnings.

Add class-level pytest.mark.filterwarnings filters to acknowledge the
two known messages ('Cannot compose command speckit.wrap-test' and
'Post-install reconciliation failed for self-test') so other UserWarning
sources still propagate normally.

Fixes #2363

* test(presets): scope filterwarnings to UserWarning category

Address Copilot review on #2373: the previous filterwarnings entries
omitted the warning category, so any warning class with a matching
message would have been silenced. Append :UserWarning to the four
filters so only the deliberately-emitted UserWarnings from
_reconcile_composed_commands() are ignored.

* test(presets): narrow self-test warning filter to install helper only

Address Copilot feedback: the class-level @pytest.mark.filterwarnings on
TestPresetSkills was too broad. The 'Post-install reconciliation failed'
filter could mask real reconciliation regressions, since that warning is
only emitted when _reconcile_composed_commands/_reconcile_skills raises.

Tests in TestPresetSkills already call install_self_test_preset(), which
scopes a narrow filter to the expected wrap-strategy 'Cannot compose'
warning. The class-level filters are redundant for those calls and unsafe
elsewhere, so they are removed.

* test(presets): align TestSelfTestPreset docstring with helper-based filtering

Address Copilot feedback: docstring referred to 'filters above', but the
fix uses warnings.filterwarnings inside install_self_test_preset rather
than class-level decorators. Updated the docstring to describe the actual
mechanism.

* test(presets): remove extra blank line between helper and class (PEP 8)

Address Copilot feedback: PEP 8 expects two blank lines between top-level
definitions; reduce the three blank lines between install_self_test_preset
and TestSelfTestPreset to two.
2026-05-11 15:16:55 -05:00
Manfred Riem
81f772c60b chore: release 0.8.8, begin 0.8.9.dev0 development (#2516)
* chore: bump version to 0.8.8

* chore: begin 0.8.9.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-11 12:10:36 -05:00
dependabot[bot]
e1b531c648 chore(deps): bump actions/checkout from 4.3.1 to 6.0.2 (#2486)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.3.1 to 6.0.2.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4.3.1...de0fac2e4500dabe0009e67214ff5f5447ce83dd)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.2
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 12:03:32 -05:00
Julio Cesar Franco
b5db159394 feat(catalog): add Spec Kit Schedule (schedule) community extension (#2473)
* feat(catalog): add Spec Kit Schedule (schedule) community extension

CP-SAT scheduler for spec-kit projects with multi-agent task
optimization. Adds catalog entry for v0.5.2 release.

Pre-flight verification:
- archive/refs/tags/v0.5.2.zip resolves (HTTP 200, 718322 bytes,
  SHA-256 00d4dab1df680e5888e0d0e861eb4696ace00661d40669bf719a75dc379b40b5)
- extension.yml schema_version 1.0, id 'schedule', 3 commands
  (speckit.schedule.run, speckit.schedule.portfolio, speckit.schedule.visualize)
- 566 tests passing on Ubuntu 3.10/3.11/3.12 + macOS 3.12 (all blocking)
- 92.51% line coverage, mypy --strict on 28 modules
- Sigstore attestations via attest-build-provenance@v2 (gh attestation
  verify exit 0 confirmed)
- 4 worked examples + replan demo runnable via bash bin/run-examples.sh

License: MIT
speckit_version: >=0.4.0

* fix(catalog): update Spec Kit Schedule entry to v0.5.3

v0.5.2 had two real-world install bugs caught when a user tried the
documented commands:

1. README/INSTALL showed 'specify extension add --from URL' (missing
   the EXTENSION positional arg). The canonical form is
   'specify extension add schedule --from URL'. Fixed in v0.5.3.

2. Release zip was ~5x bigger than peer extensions due to dev cruft
   (.github/, tests/, benchmarks/, build metadata). Added .gitattributes
   export-ignore in v0.5.3, dropping the zip from 718 KB to 590 KB.

v0.5.3 archive verified HTTP 200, sigstore attestations active.

* fix(catalog): bump Spec Kit Schedule entry to v0.5.4

Adds an opt-in after_tasks hook so users get prompted to run the
scheduler immediately after /speckit.tasks, without forcing it.
Mirrors the canonical pattern used by the bundled 'git' extension.

* fix(catalog): bump Spec Kit Schedule entry to v0.5.5

Documents the after_tasks hook in README and rewrites the
/speckit.schedule.portfolio command to autodetect the project's
tech stack via solver.autodetect, then refine interactively
against the matching recipe in docs/portfolio-design.md, instead
of starting from a blank slate.

* fix(catalog): bump Spec Kit Schedule entry to v0.6.0

State now encapsulated under .specify/, /speckit.schedule.run is
idempotent with auto-bootstrap, and portfolio detection is
AI-aware (reads .specify/integration.json and discovers the user's
fleet from the canonical location for whichever spec-kit AI
assistant they chose: claude, copilot, cursor-agent, gemini, or any
of the other 26 supported integrations).

* fix(catalog): bump Spec Kit Schedule entry to v0.6.1

Per-AI portfolio templates with verified May 2026 GA models
(gpt-5.5 flagship, claude-opus-4-7, gemini-2.5-flash). Critical
price unit fix (cost_aware reported $ figures 1000× inflated
in v0.6.0). Plus calibration feedback loop and inline summary.

* fix(readme): add Spec Kit Schedule row to Community Extensions table

Per Copilot review on PR #2473: the publishing guide requires an
accepted submission to update both extensions/catalog.community.json
AND the root README's Community Extensions table. Without the README
row the extension wouldn't appear in the primary browsable list.

Inserted alphabetically between 'Spec Diagram' and 'Spec Orchestrator'.
Category: process. Effect: Read+Write.

* fix(catalog): provides.commands 3→4 (schedule only) + bump top-level updated_at

Surgical edit responding to two Copilot review nits on PR #2473.
Previous attempt used str.replace too broadly and was reverted —
this version uses unique anchors to mutate only the schedule
entry and the top-level updated_at field.

1. extensions/catalog.community.json schedule entry had
   provides.commands: 3, but the extension exposes 4 commands
   (run, portfolio, visualize, calibrate — calibrate was added
   in v0.6.0 Build 2 / calibration feedback loop).

2. Top-level catalog updated_at was 2026-05-06T22:28:55Z but
   per-entry updated_at for our schedule entry is 2026-05-07.
   Since this PR modifies the catalog, the top-level timestamp
   advances too.

* fix(catalog): bump Spec Kit Schedule entry to v0.6.2

Adds /speckit.schedule.status (5th command) — self-diagnose
installation state, distinguishes 'expected-missing' (will
bootstrap automatically) from 'missing' (real problem). Closes
the audit-tool false-alarm gap where schedule-config.yml absence
post-install was misread as broken state.

---------

Co-authored-by: Julio César Franco Ardila <noreply@anthropic.com>
2026-05-11 11:00:23 -05:00
Quratulain-bilal
947b4398c7 fix(integration): refresh shared infra on integration switch (#2375)
* fix(integration): refresh shared infra on integration switch

* fix(integration): address Copilot review on switch shared-infra refresh

- Clarify install_shared_infra docstring: force overwrites regular files
  but always preserves symlinks (safe-destination check refuses to follow).
- Print refresh_hint only for preserved_user_files; skipped_files keeps
  the generic remediation. Avoids misleading guidance when files were
  merely skipped (not detected as customized).
- Catch ValueError from the safe-destination check and bucket the path
  under a new symlinked_files warning instead of aborting the switch.
- Restore templates/constitution-template.md to upstream (drop accidental
  leading blank lines).

* fix(integration): narrow symlink bucketing to dedicated exception

Address Copilot feedback on shared_infra.py:305 — _safe_dest_or_bucket
caught any ValueError as 'symlinked', which masked genuine safety errors
(path escape, parent-not-a-directory).

- Introduce SymlinkedSharedPathError(ValueError) raised only by the
  symlink-specific branches in _ensure_safe_shared_*().
- _safe_dest_or_bucket() now catches only SymlinkedSharedPathError;
  other ValueErrors propagate so the operation aborts with the real
  cause instead of being silently bucketed.
- Wrap top-level dest_scripts/dest_variant/dest_templates mkdir calls
  in the same bucket helper so a symlinked .specify/scripts or
  .specify/templates is preserved with a warning rather than aborting
  the switch (matches the documented 'preserve customizations' behavior).
- Update tests to expect the new bucket+warn behavior for leaf-level
  symlinked destinations.

* fix(integration): tailor shared-infra warnings and rename preflight test

Address Copilot review on PR #2375:

- skipped_files hint now uses refresh_hint when refresh_managed=True
  so integration switch suggests --refresh-shared-infra instead of the
  generic init/upgrade flags.
- symlinked-files warning header says "path(s)" rather than "file(s)"
  since symlinked directories (e.g. .specify/scripts/bash) are also
  bucketed there.
- Rename test_shared_infra_install_preflights_before_writing to
  test_shared_infra_install_buckets_unsafe_destinations_and_continues
  to match the new bucket-and-continue semantics.

* test: rename symlink bucketing tests to reflect bucket-and-continue behavior

The two file-bucketing tests at line 300/320 were named *_refuses_*, but
the new behavior buckets symlinked file destinations with a warning while
safe destinations in the same install still complete. Rename to
*_buckets_* and update docstrings to match.

The remaining *_refuses_* tests (line 342/362/381) genuinely raise on
symlinked dirs/manifests and keep their names.

---------

Co-authored-by: Quratulain-bilal <quratulain.bilal@users.noreply.github.com>
2026-05-11 10:49:48 -05:00
Manfred Riem
28145b9a3a Add MDE preset to community catalog (#2513)
Add Model Driven Engineering preset by @ralphhanna to the community
catalog and docs website.

Closes #2493
2026-05-11 07:39:28 -05:00
Manfred Riem
cec0d2db5e Add MDE extension to community catalog (#2512)
Add the MDE (Model Driven Engineering) extension to the community
catalog and README extensions table.

Closes #2492

Co-authored-by: ralphhanna <11893416+ralphhanna@users.noreply.github.com>
2026-05-11 07:25:13 -05:00
Dyan Galih
688ca1b3c5 chore: update community catalog with latest extension versions (#2490)
- Update memory-md from 0.7.9 to 0.8.0
- Update architecture-guard from 1.6.7 to 1.8.0
2026-05-08 16:27:31 -05:00
dependabot[bot]
2b4a33e1fd chore(deps): bump actions/setup-dotnet from 4.3.1 to 5.2.0 (#2489)
Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 4.3.1 to 5.2.0.
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](67a3573c9a...c2fa09f4bd)

---
updated-dependencies:
- dependency-name: actions/setup-dotnet
  dependency-version: 5.2.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-08 16:12:40 -05:00
dependabot[bot]
2be4ef713d chore(deps): bump actions/github-script from 7 to 9 (#2488)
Bumps [actions/github-script](https://github.com/actions/github-script) from 7 to 9.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v9)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-08 16:11:29 -05:00
dependabot[bot]
282a1f7d1b chore(deps): bump DavidAnson/markdownlint-cli2-action (#2487)
Bumps [DavidAnson/markdownlint-cli2-action](https://github.com/davidanson/markdownlint-cli2-action) from 23.1.0 to 23.2.0.
- [Release notes](https://github.com/davidanson/markdownlint-cli2-action/releases)
- [Commits](6b51ade7a9...ded1f9488f)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-08 15:48:49 -05:00
dependabot[bot]
b0674243d2 chore(deps): bump github/codeql-action from 4.35.3 to 4.35.4 (#2485)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.35.3 to 4.35.4.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](e46ed2cbd0...68bde559de)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.35.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-08 15:43:51 -05:00
Quratulain-bilal
abb5fe7090 feat(catalog): add API Evolve (api-evolve) community extension (#2479)
* feat(catalog): add API Evolve (api-evolve) community extension

* chore(catalog): refresh top-level updated_at
2026-05-07 14:40:40 -05:00
Copilot
f0998348be feat: Config-driven opt-in authentication registry with multi-platform support (#2393)
* Initial plan

* feat: add authentication provider registry (GitHub + Azure DevOps)

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/da7ecfd0-e1c9-48dc-b692-27be0879e976

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* feat: add try-each-provider HTTP helper and wire all catalog fetches through auth registry

- Add authentication/http.py with open_url() that tries each configured
  provider in registry order, falling through on 401/403 to the next,
  and finally to unauthenticated
- Add build_request() for one-shot request construction
- Add configured_providers() to registry __init__
- Remove api_base_url() from AuthProvider ABC (unused)
- Remove hosts attribute from providers (no host matching)
- Replace _github_http.py usage in ExtensionCatalog and PresetCatalog
- Wire IntegrationCatalog and WorkflowCatalog through open_url (were unauthenticated)
- Wire _fetch_latest_release_tag() through open_url
- Wire all inline --from-url downloads through open_url
- Fix unused stub variable flagged by code-quality bot
- 49 auth tests (positive + negative), 1805 total tests passing

* fix: address review — fix stale docstrings, restore Accept header, add extra_headers to open_url

- Fix _open_url() docstrings in extensions.py and presets.py that
  incorrectly claimed redirect stripping behavior
- Add extra_headers parameter to open_url() so callers can pass
  additional headers (e.g. Accept) that persist across retries
- Restore Accept: application/vnd.github+json header in
  _fetch_latest_release_tag() via extra_headers

* feat: config-driven opt-in auth via ~/.specify/auth.json

Security-first redesign: no credentials are sent unless the user
explicitly creates ~/.specify/auth.json mapping hosts to providers.

- Add authentication/config.py: loads and validates auth.json with
  host-to-provider mappings, supports token/token_env/azure-ad/azure-cli
- Refactor AuthProvider ABC: auth_headers(token, scheme) + resolve_token(entry)
- Refactor GitHubAuth: bearer scheme only, token from config entry
- Refactor AzureDevOpsAuth: 4 schemes (basic-pat, bearer, azure-cli, azure-ad)
  with dynamic token acquisition for azure-cli and azure-ad
- Rewrite authentication/http.py: host matching, redirect stripping,
  provider fallthrough on 401/403, unauthenticated fallback
- Add docs/reference/authentication.md with full reference and template
- 1823 tests passing (67 auth-specific)

* fix: address review — unused imports, host normalization, provider+scheme validation, security hardening

- Remove unused imports (os, field, Any) in config.py
- Normalize hosts during load (strip + lowercase)
- Validate token/token_env are non-empty strings during load
- Validate provider+scheme compatibility during load
- Fix extra_headers order: auth headers applied last, cannot be overridden
- Remove unused 'tried' variable in http.py
- Warn (once) on malformed auth.json instead of silent fallback
- URL-encode OAuth2 client credentials body in azure_devops.py
- Update 403 message to mention auth.json configuration
- Fix registry leak in test_register_duplicate (try/finally)
- Fix import style consistency in test_authentication.py
- Add azure-cli and azure-ad token acquisition tests (mock subprocess/urlopen)
- Add autouse fixture to isolate upgrade tests from real auth.json
- 1829 tests passing

* fix: reject unknown providers, validate azure-ad fields, strip Authorization from extra_headers

- Reject unknown provider keys during auth.json load with clear error message
- Validate azure-ad tenant_id/client_id/client_secret_env as non-empty strings
- Strip Authorization from extra_headers in both build_request and open_url
  to prevent accidental or intentional bypass of provider-configured auth
- Add tests for unknown provider and incompatible scheme validation
- 1831 tests passing

* fix: extract shared auth test helpers, global config isolation, align docstring

- Move _inject_github_config / make_github_auth_entry to tests/auth_helpers.py
  to eliminate duplication across test_extensions, test_presets, test_upgrade
- Move auth config isolation fixture to global conftest.py (autouse) so ALL
  tests are isolated from ~/.specify/auth.json, not just test_upgrade
- Align load_auth_config docstring with actual behavior: ValueError may be
  caught by higher-level HTTP helpers that warn and continue unauthenticated
- 1831 tests passing

* fix: preserve auth header across multi-hop redirect chains

- Read Authorization from both headers and unredirected_hdrs in
  _StripAuthOnRedirect to survive multi-hop chains within allowed hosts
- Add test_multi_hop_redirect_within_hosts_preserves_auth
- 1832 tests passing

* fix: use resolved config path in warning/error messages and patch build_opener in no-network test

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/86df9557-54f1-4fe4-a25f-9501cb2356cf

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* fix: assert full resolved config path in rate-limit output test

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/86df9557-54f1-4fe4-a25f-9501cb2356cf

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* fix: close HTTPError on 401/403, remove _VALID_AUTH_SCHEMES, catch TimeoutExpired, skip POSIX test on Windows, remove unused import

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/a1e29737-dd6e-4287-96c1-509e0c96fb21

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* fix: use stable ~/.specify/auth.json in rate-limit message, skip POSIX permission check on Windows

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/4636bcdb-87ae-45d6-9545-a40e4effd617

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* fix: validate host patterns, cache auth config per-process

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/889b58a7-7f8c-47e2-8056-931ebcc671cc

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* fix: clarify _is_valid_host_pattern docstring, clean up test sentinel type

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/889b58a7-7f8c-47e2-8056-931ebcc671cc

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* fix: improve _is_valid_host_pattern docstring and test observability

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/889b58a7-7f8c-47e2-8056-931ebcc671cc

Co-authored-by: mnriem <15701806+mnriem@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-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-07 12:51:20 -05:00
Manfred Riem
5563269831 chore: release 0.8.7, begin 0.8.8.dev0 development (#2480)
* chore: bump version to 0.8.7

* chore: begin 0.8.8.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-07 10:46:05 -05:00
Pragya Chaurasia
5b9f0040e7 feat: add agent-orchestrator to community extension catalog (#2236)
Register the Intelligent Agent Orchestrator as a community extension.
Extension code is hosted externally at:
https://github.com/pragya247/spec-kit-orchestrator

Changes:
- Add agent-orchestrator entry to extensions/catalog.community.json
- Add Agent Orchestrator row to README.md community extensions table

Co-authored-by: pragya247 <pragya@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 10:39:02 -05:00
Dyan Galih
11f49ebfb2 chore: update extension versions in community catalog (#2468)
* chore: update extension versions in community catalog

- Update architecture-guard from v1.4.0 to v1.6.7
- Update memory-md from v0.7.5 to v0.7.9
- Update security-review from v1.4.2 to v1.4.5

All extensions now point to latest release downloads.

* chore: update timestamps in community catalog

Co-authored-by: Copilot <copilot@github.com>

---------

Co-authored-by: Copilot <copilot@github.com>
2026-05-06 17:47:33 -05:00
natelastname
cd44dc2147 fix(goose): Declare args parameter in generated recipes (#2402) 2026-05-06 17:21:48 -05:00
qiyang.yuan
f5b675e9ee feat: Add lingma support (#2348)
* add lingma support

* fix

* fix context file

* Update CONTEXT_FILE path in test integration

* fix IntegrationOption.default

* fix IntegrationOption.defaultfix

* fix: address Copilot review feedback

- Add blank line after __future__ import (PEP 8)
- Remove trailing whitespace at end of lingma/__init__.py
- Bump integrations/catalog.json updated_at timestamp
- Add Lingma to supported agent list in README.md

* fix: address Copilot review feedback (round 4)

- Reword module docstring: Lingma is a brand-new skills-only integration
  with no prior command-mode history, so 'deprecated since v0.5.1'
  wording (copied from Trae) was misleading
- Remove Lingma from README CLI-tool check list: Lingma is IDE-based
  (requires_cli=False) and is explicitly skipped by specify init /
  specify check tool detection
2026-05-06 16:12:13 -05:00
Copilot
38bb88bde1 docs: Add uv installation guide and inline callouts (#2465)
* Initial plan

* docs: Add uv installation guide and inline callouts

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/027c81a0-57f2-4f67-ab54-4c72f93eb254

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* docs: improve uv install guide PATH and Windows instructions

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/f56bcfb8-2cf5-44a5-b5e5-0fd6c3caa46f

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* docs: clarify uv note in README applies only to uv commands not pipx

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/a6ada1f7-522d-4a31-ac5b-880e763f9c97

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* docs: clarify uv note in installation.md applies only to uvx commands not pipx

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/4ec791dd-b048-4606-8db3-671bc8956b05

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
2026-05-06 15:05:14 -05:00
Manfred Riem
0facb1bdc2 Add fx-to-dotnet to community extension catalog (#2471)
* Add fx-to-dotnet to community extension catalog

- Extension ID: fx-to-dotnet
- Version: 0.8.0
- Author: RogerBestMsft
- .NET Framework to Modern .NET Migration

Closes #2469

* Address review: remove tool version, fix table ordering

- Remove meaningless >=0.0.0 version from required tool entry
- Move .NET Framework row to correct alphabetical position (after Multi-Model Review)
- Lowercase link label to match table conventions
2026-05-06 13:23:23 -05:00
Andrii Furmanets
2d5e63005d fix: default non-interactive init to copilot integration (#2414)
* fix: default non-interactive init integration

* chore: clarify non-interactive init default integration

* Address non-interactive init review feedback

* Fix interactive init test after fallback
2026-05-06 12:48:50 -05:00
Eric Rodriguez Suazo
793632089a fix(forge): use hyphen notation for command refs in Forge integration (#2462)
* fix(forge): use hyphen notation for command refs in Forge integration

- Add invoke_separator = "-" class attribute to ForgeIntegration so
  effective_invoke_separator() returns "-" for shared-template installs
- Add "invoke_separator": "-" to ForgeIntegration.registrar_config so
  agents.py CommandRegistrar can resolve refs with the correct separator
- Pass invoke_separator to process_template() in ForgeIntegration.setup()
  so all .forge/commands/*.md bodies use /speckit-foo notation
- Replace literal /speckit.specify with __SPECKIT_COMMAND_SPECIFY__ in
  extensions/git/commands/speckit.git.feature.md so every agent resolves
  the reference through its own separator
- Apply resolve_command_refs re.sub in agents.py register_commands() after
  argument-placeholder substitution so extension commands registered for
  Forge get /speckit-foo refs; all other agents continue to get /speckit.foo

Fixes ZSH compatibility: dot-notation command invocations (/speckit.specify)
are misinterpreted by ZSH as file-path operations; hyphen notation
(/speckit-specify) works correctly in all shells.

* fix(agents): propagate invoke_separator from integration class into AGENT_CONFIGS

Skills-based agents (claude, codex, kimi, …) inherit invoke_separator="-"
from SkillsIntegration but do not repeat it in their registrar_config dicts.
_build_agent_configs() was copying registrar_config verbatim, so
register_commands() fell back to "." when resolving __SPECKIT_COMMAND_*__
tokens for those agents — emitting /speckit.specify instead of the correct
/speckit-specify for extension commands like speckit.git.feature.

Fix: after copying registrar_config, inject invoke_separator from the
integration's class attribute when it is not already declared explicitly.
This makes the integration class the single source of truth for all agents,
without requiring each SkillsIntegration subclass to duplicate the field.

Also replace the inline re.sub in register_commands() with a call to
IntegrationBase.resolve_command_refs() (deferred import to avoid the
existing circular dependency) so token-resolution logic is not duplicated.

Adds two tests in test_agent_config_consistency.py:
- test_skills_agents_have_hyphen_invoke_separator_in_agent_configs: asserts
  every /SKILL.md agent has invoke_separator="-" in AGENT_CONFIGS.
- test_skills_agent_command_token_resolves_with_hyphen: end-to-end check via
  CommandRegistrar that the git extension's speckit.git.feature command is
  installed for Claude with /speckit-specify (not /speckit.specify).

Addresses review comment on PR #2462.
2026-05-06 12:19:10 -05:00
Quratulain-bilal
c0bf5d0c64 feat(catalog): add Cost Tracker (cost) community extension (#2448)
* feat(catalog): add Cost Tracker (cost) community extension

Adds a new entry for spec-kit-cost — track real LLM dollar cost across
SDD workflows with per-feature budgets, per-integration comparison,
and finance-ready exports.

Repo: https://github.com/Quratulain-bilal/spec-kit-cost
Release: v1.0.0

* docs(catalog): add Cost Tracker README row, bump updated_at

Address Copilot review feedback:
- Add Cost Tracker row to README community extensions table
- Bump top-level updated_at per EXTENSION-PUBLISHING-GUIDE.md

* fix(catalog): address Copilot feedback on cost extension entry

- Move cost entry after confluence so the c* block is alphabetized
- Bump top-level updated_at to 2026-05-05 per EXTENSION-PUBLISHING-GUIDE
- Use documented 'visibility' category in README (not 'analytics'),
  matching Token Consumption Analyzer's classification
- Replace 'analytics' tag with 'visibility' in catalog tags for consistency

* fix(catalog): bump top-level updated_at for cost entry addition

Address Copilot feedback: the file-level updated_at must be bumped on
every catalog change per EXTENSION-PUBLISHING-GUIDE.md:204-205.

---------

Co-authored-by: Quratulain-bilal <quratulain.bilal@users.noreply.github.com>
2026-05-06 12:07:02 -05:00
98 changed files with 12693 additions and 1945 deletions

2
.gitattributes vendored
View File

@@ -1 +1,3 @@
* text=auto eol=lf
.github/workflows/*.lock.yml linguist-generated=true merge=ours -whitespace

14
.github/aw/actions-lock.json vendored Normal file
View File

@@ -0,0 +1,14 @@
{
"entries": {
"actions/github-script@v9.0.0": {
"repo": "actions/github-script",
"version": "v9.0.0",
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
},
"github/gh-aw-actions/setup@v0.74.8": {
"repo": "github/gh-aw-actions/setup",
"version": "v0.74.8",
"sha": "efa55847f72aadb03490d955263ff911bf758700"
}
}
}

View File

@@ -1,11 +1,12 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
- directory: /
package-ecosystem: pip
schedule:
interval: weekly
- directory: /
ignore:
- dependency-name: "github/gh-aw-actions/**" # Managed by gh aw compile. Version-locked to the gh-aw compiler; do not bump.
package-ecosystem: github-actions
schedule:
interval: weekly
version: 2

View File

@@ -0,0 +1,169 @@
---
name: add-community-extension
description: 'Add a community extension to the Spec Kit catalog from a GitHub issue submission. USE FOR: processing extension submission issues, validating catalog entries, updating catalog.community.json and docs/community/extensions.md, creating PRs. DO NOT USE FOR: creating new extensions from scratch, or first-party extension work.'
argument-hint: 'GitHub issue URL or number for the extension submission'
---
# Add Community Extension
Process an extension submission issue and add or update it in the community catalog.
## When to Use
- A new `[Extension]` submission issue is filed
- An existing extension submits an update issue (new version, changed metadata)
- You need to add or update a community extension in `extensions/catalog.community.json` and `docs/community/extensions.md`
## Procedure
### 1. Fetch the submission issue
Read the GitHub issue to extract all metadata:
- Extension ID, name, version, description, author
- Repository URL, download URL, homepage, documentation, changelog
- License, required spec-kit version, optional tool dependencies
- Number of commands and hooks
- Tags
### 2. Validate against publishing rules
Check **all** of the following (per `extensions/EXTENSION-PUBLISHING-GUIDE.md`):
| Check | How |
|-------|-----|
| Repository exists and is public | Fetch the repository URL |
| `extension.yml` manifest present | Confirm in repo file listing |
| README.md present | Confirm in repo file listing |
| LICENSE file present | Confirm in repo file listing |
| GitHub release exists matching version | Check releases on the repo page |
| Download URL is accessible | Verify it follows `archive/refs/tags/vX.Y.Z.zip` pattern and release exists |
| Extension ID is lowercase-with-hyphens only | Regex: `^[a-z][a-z0-9-]*$` |
| Version follows semver | Format: `X.Y.Z` |
| Submission checklists are all checked | Confirm in issue body |
### 3. Determine if this is an add or update
Search `extensions/catalog.community.json` for the extension ID.
- **Not found** → this is a **new addition**. Proceed to step 4.
- **Found** → this is an **update**. Proceed to step 4 but replace the existing entry in-place instead of inserting.
### 4. Add or update `extensions/catalog.community.json`
**New extension:** Insert the entry in **alphabetical order** by extension ID.
**Update:** Replace the existing entry in-place. Update only the fields that changed (typically `version`, `download_url`, `description`, `provides`, `requires`, `tags`, `updated_at`). Preserve `created_at` and `downloads`/`stars` from the existing entry.
Use the existing entries as the format template. Required fields:
```json
{
"<id>": {
"name": "<name>",
"id": "<id>",
"description": "<description>",
"author": "<author>",
"version": "<version>",
"download_url": "<download_url>",
"repository": "<repository>",
"homepage": "<homepage>",
"documentation": "<documentation>",
"changelog": "<changelog>",
"license": "<license>",
"requires": {
"speckit_version": "<speckit_version>"
},
"provides": {
"commands": <N>,
"hooks": <N>
},
"tags": ["<tag1>", "<tag2>"],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "<today>T00:00:00Z",
"updated_at": "<today>T00:00:00Z"
}
}
```
If the extension has optional tool dependencies, add a `"tools"` array inside `"requires"`:
```json
"tools": [{ "name": "<tool>", "required": false }]
```
Also update the top-level `"updated_at"` timestamp in the catalog.
After editing, **validate the JSON** by running:
```bash
python3 -c "import json; json.load(open('extensions/catalog.community.json')); print('Valid JSON')"
```
### 5. Add or update `docs/community/extensions.md` community extensions table
**New extension:** Insert a new row into the `# Community Extensions` table in **alphabetical order** by extension name.
**Update:** Find the existing row and update the description or other changed fields in-place.
Determine the category and effect from the extension's behavior:
```
| <Name> | <Description> | `<category>` | <Effect> | [<repo-name>](<repository-url>) |
```
**Category** — one of: `docs`, `code`, `process`, `integration`, `visibility`
**Effect**`Read-only` (produces reports only) or `Read+Write` (modifies project files)
### 6. Commit, push, and open PR
Use `add-` for new extensions, `update-` for updates:
```bash
# New extension
git checkout -b add-<extension-id>-extension
# Update
git checkout -b update-<extension-id>-extension
```
```bash
git add extensions/catalog.community.json docs/community/extensions.md
# New extension
git commit -m "Add <Name> extension to community catalog
Add <id> extension submitted by @<issue-author> to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table
Closes #<issue-number>"
# Update
git commit -m "Update <Name> extension to v<version>
Update <id> extension submitted by @<issue-author>:
- extensions/catalog.community.json (version, download_url, etc.)
- docs/community/extensions.md community extensions table
Closes #<issue-number>"
git push origin <branch-name>
```
Then create a PR to `upstream` (`github/spec-kit`) with:
- **Title:** `Add <Name> extension to community catalog` (or `Update <Name> extension to v<version>`)
- **Body:** Include validation summary, `Closes #<issue-number>`, and `cc @<issue-author>`
- **Head:** `<fork-owner>:<branch-name>`
- **Base:** `main`
## Common Pitfalls
- **Alphabetical order matters** — entries must be sorted by ID in the JSON and by name in the docs table.
- **Don't forget the catalog `updated_at`** — the top-level timestamp in `catalog.community.json` must be refreshed.
- **Validate JSON after editing** — a trailing comma or missing brace will break the catalog.
- **Use `Closes` not `Fixes`** — `Closes #N` is the correct keyword for submission issues.
- **Match the proposed entry but verify** — the issue may include a proposed JSON block, but always validate field values against the actual repository state.
- **Preserve `created_at` on updates** — keep the original `created_at` value; only change `updated_at`.
- **Preserve `downloads` and `stars` on updates** — these reflect usage metrics and must not be reset.

1579
.github/workflows/add-community-extension.lock.yml generated vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,288 @@
---
description: "Process community extension submission issues — validate, add to catalog, and open a PR for maintainer review"
emoji: "🧩"
on:
issues:
types: [opened, edited, labeled]
skip-bots: [github-actions, copilot, dependabot]
tools:
edit:
bash: ["echo", "cat", "head", "tail", "grep", "wc", "sort", "python3", "jq", "date"]
github:
toolsets: [issues, repos]
web-fetch:
permissions:
contents: read
issues: read
checkout:
fetch-depth: 0
safe-outputs:
create-pull-request:
title-prefix: "[extension] "
labels: [extension-submission, automated]
draft: true
max: 1
protected-files:
policy: blocked
exclude:
- README.md
- CHANGELOG.md
add-comment:
max: 2
add-labels:
allowed: [extension-submission, validation-passed, validation-failed, needs-info]
max: 3
---
# Add Community Extension from Issue Submission
You are a catalog maintenance agent for the Spec Kit project. Your job is to
process community extension submission issues and create pull requests that add
or update entries in the community extension catalog.
## Triggering Conditions
This workflow triggers on issue events. **Only process the issue if ALL of these
conditions are met:**
1. The issue has the `extension-submission` label
2. The issue title starts with `[Extension]:`
If the issue does not meet these conditions, add a brief comment explaining that
this workflow only processes extension submission issues, then stop.
## Step 1 — Read and Parse the Issue
Read issue #${{ github.event.issue.number }}.
Extract the following fields from the structured issue body (GitHub issue form
fields):
| Field | Issue Form ID | Required |
|-------|--------------|----------|
| Extension ID | `extension-id` | Yes |
| Extension Name | `extension-name` | Yes |
| Version | `version` | Yes |
| Description | `description` | Yes |
| Author | `author` | Yes |
| Repository URL | `repository` | Yes |
| Download URL | `download-url` | Yes |
| License | `license` | Yes |
| Homepage | `homepage` | No |
| Documentation URL | `documentation` | No |
| Changelog URL | `changelog` | No |
| Required Spec Kit Version | `speckit-version` | Yes |
| Required Tools | `required-tools` | No |
| Number of Commands | `commands-count` | Yes |
| Number of Hooks | `hooks-count` | No (default 0) |
| Tags | `tags` | Yes |
| Proposed Catalog Entry | `catalog-entry` | Yes |
The issue body uses GitHub's issue form format. Each field appears under a
heading matching the field label (e.g., `### Extension ID` followed by the
value). Parse accordingly.
## Step 2 — Validate the Submission
Run **all** of the following validation checks. Collect all results before
deciding pass/fail:
### 2a. Extension ID format
- Must match regex: `^[a-z][a-z0-9-]*$`
- Must be lowercase with hyphens only
### 2b. Version format
- Must follow semver: `X.Y.Z` (digits only, no `v` prefix)
### 2c. Repository validation
- Fetch the repository URL — confirm it exists and is publicly accessible
- Confirm the repository contains an `extension.yml` file
- Confirm the repository contains a `README.md` file
- Confirm the repository contains a `LICENSE` file
### 2d. Release and download URL validation
- The download URL should follow the pattern
`https://github.com/<owner>/<repo>/archive/refs/tags/v<version>.zip`
or
`https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>.zip`
- Verify a GitHub release exists matching the submitted version
### 2e. Submission checklists
- Confirm that all required checkboxes in the Testing Checklist and Submission
Requirements sections are checked (`[x]`)
### Validation outcome
If **any** validation fails:
1. Add a comment on the issue listing each failed check with a clear explanation
of what's wrong and how to fix it
2. Add the `validation-failed` label
3. **Stop — do not proceed further**
If all validations pass:
1. Add the `validation-passed` label
2. Continue to Step 3
## Step 3 — Determine Add vs Update
Search `extensions/catalog.community.json` for the extension ID.
- **Not found** → this is a **new addition**
- **Found** → this is an **update** — replace the existing entry in-place;
preserve `created_at`, `downloads`, and `stars` from the existing entry
## Step 4 — Update `extensions/catalog.community.json`
Edit `extensions/catalog.community.json` to add or update the extension entry.
### For a new extension
Insert the entry in **alphabetical order by extension ID** within the
`"extensions"` object. Use this structure:
```json
{
"<id>": {
"name": "<name>",
"id": "<id>",
"description": "<description>",
"author": "<author>",
"version": "<version>",
"download_url": "<download_url>",
"repository": "<repository>",
"homepage": "<homepage or repository>",
"documentation": "<documentation or repository README>",
"changelog": "<changelog or empty string>",
"license": "<license>",
"requires": {
"speckit_version": "<speckit_version>"
},
"provides": {
"commands": <N>,
"hooks": <N>
},
"tags": ["<tag1>", "<tag2>"],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "<today>T00:00:00Z",
"updated_at": "<today>T00:00:00Z"
}
}
```
If the extension has optional tool dependencies, add a `"tools"` array inside
`"requires"`:
```json
"tools": [{ "name": "<tool>", "required": false }]
```
### For an update
Replace only the changed fields (typically `version`, `download_url`,
`description`, `provides`, `requires`, `tags`, `updated_at`). **Preserve**
`created_at`, `downloads`, and `stars` from the existing entry.
### After editing
Update the **top-level `"updated_at"` timestamp** in the catalog to today's date
in ISO 8601 format.
Validate the JSON by running:
```bash
python3 -c "import json; json.load(open('extensions/catalog.community.json')); print('Valid JSON')"
```
If validation fails, fix the JSON and re-validate before continuing.
## Step 5 — Update `docs/community/extensions.md`
Edit `docs/community/extensions.md` to add or update a row in the Community
Extensions table.
### For a new extension
Insert a new row in **alphabetical order by extension name**:
```
| <Name> | <Description> | `<category>` | <Effect> | [<repo-name>](<repository-url>) |
```
Determine the category from the extension's behavior:
- `docs` — reads, validates, or generates spec artifacts
- `code` — reviews, validates, or modifies source code
- `process` — orchestrates workflow across phases
- `integration` — syncs with external platforms
- `visibility` — reports on project health or progress
Determine the effect:
- `Read-only` — produces reports only
- `Read+Write` — modifies project files
### For an update
Find the existing row and update any changed fields in-place.
## Step 6 — Create Pull Request
Create a pull request with the changes. Use this branch naming convention:
- **New extension:** `add-<extension-id>-extension`
- **Update:** `update-<extension-id>-extension`
### Commit message
For a new extension:
```
Add <Name> extension to community catalog
Add <id> extension submitted by @<issue-author> to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table
Closes #<issue-number>
```
For an update:
```
Update <Name> extension to v<version>
Update <id> extension submitted by @<issue-author>:
- extensions/catalog.community.json (version, download_url, etc.)
- docs/community/extensions.md community extensions table
Closes #<issue-number>
```
### PR description
Include:
- A summary of what changed
- Validation results (all checks passed)
- `Closes #${{ github.event.issue.number }}`
- `cc @<issue-author>` — mention the submitter
## Important Rules
- **Alphabetical order matters** — entries must be sorted by ID in the JSON and
by name in the docs table
- **Always validate JSON** after editing — a trailing comma or missing brace
will break the catalog
- **Use `Closes` not `Fixes`** — `Closes #N` is the correct keyword for
submission issues
- **Match the proposed entry but verify** — the issue may include a proposed
JSON block, but always validate field values against the actual repository
state rather than blindly trusting the submitter's JSON
- **Preserve `created_at` on updates** — keep the original value; only update
`updated_at`
- **Preserve `downloads` and `stars` on updates** — these reflect usage metrics
and must not be reset
- **Do not modify any other files** — only `extensions/catalog.community.json`
and `docs/community/extensions.md`

1579
.github/workflows/add-community-preset.lock.yml generated vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,282 @@
---
description: "Process community preset submission issues — validate, add to catalog, and open a PR for maintainer review"
emoji: "🎨"
on:
issues:
types: [opened, edited, labeled]
skip-bots: [github-actions, copilot, dependabot]
tools:
edit:
bash: ["echo", "cat", "head", "tail", "grep", "wc", "sort", "python3", "jq", "date"]
github:
toolsets: [issues, repos]
web-fetch:
permissions:
contents: read
issues: read
checkout:
fetch-depth: 0
safe-outputs:
create-pull-request:
title-prefix: "[preset] "
labels: [preset-submission, automated]
draft: true
max: 1
protected-files:
policy: blocked
exclude:
- README.md
- CHANGELOG.md
add-comment:
max: 2
add-labels:
allowed: [preset-submission, validation-passed, validation-failed, needs-info]
max: 3
---
# Add Community Preset from Issue Submission
You are a catalog maintenance agent for the Spec Kit project. Your job is to
process community preset submission issues and create pull requests that add
or update entries in the community preset catalog.
## Triggering Conditions
This workflow triggers on issue events. **Only process the issue if ALL of these
conditions are met:**
1. The issue has the `preset-submission` label
2. The issue title starts with `[Preset]:`
If the issue does not meet these conditions, add a brief comment explaining that
this workflow only processes preset submission issues, then stop.
## Step 1 — Read and Parse the Issue
Read issue #${{ github.event.issue.number }}.
Extract the following fields from the structured issue body (GitHub issue form
fields):
| Field | Issue Form ID | Required |
|-------|--------------|----------|
| Preset ID | `preset-id` | Yes |
| Preset Name | `preset-name` | Yes |
| Version | `version` | Yes |
| Description | `description` | Yes |
| Author | `author` | Yes |
| Repository URL | `repository` | Yes |
| Download URL | `download-url` | Yes |
| License | `license` | Yes |
| Required Spec Kit Version | `speckit-version` | Yes |
| Required Extensions | `required-extensions` | No |
| Templates Provided | `templates-provided` | Yes |
| Commands Provided | `commands-provided` | Yes |
| Number of Scripts | `scripts-count` | No (default 0) |
| Tags | `tags` | Yes |
The issue body uses GitHub's issue form format. Each field appears under a
heading matching the field label (e.g., `### Preset ID` followed by the
value). Parse accordingly.
## Step 2 — Validate the Submission
Run **all** of the following validation checks. Collect all results before
deciding pass/fail:
### 2a. Preset ID format
- Must match regex: `^[a-z][a-z0-9-]*$`
- Must be lowercase with hyphens only
### 2b. Version format
- Must follow semver: `X.Y.Z` (digits only, no `v` prefix)
### 2c. Repository validation
- Fetch the repository URL — confirm it exists and is publicly accessible
- Confirm the repository contains a `preset.yml` file
- Confirm the repository contains a `README.md` file
- Confirm the repository contains a `LICENSE` file
### 2d. Release and download URL validation
- The download URL should follow the pattern
`https://github.com/<owner>/<repo>/archive/refs/tags/v<version>.zip`
or
`https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>.zip`
- Verify a GitHub release exists matching the submitted version
### 2e. Submission checklists
- Confirm that all required checkboxes in the Testing Checklist and Submission
Requirements sections are checked (`[x]`)
### Validation outcome
If **any** validation fails:
1. Add a comment on the issue listing each failed check with a clear explanation
of what's wrong and how to fix it
2. Add the `validation-failed` label
3. **Stop — do not proceed further**
If all validations pass:
1. Add the `validation-passed` label
2. Continue to Step 3
## Step 3 — Determine Add vs Update
Search `presets/catalog.community.json` for the preset ID.
- **Not found** → this is a **new addition**
- **Found** → this is an **update** — replace the existing entry in-place;
preserve `created_at` from the existing entry
## Step 4 — Update `presets/catalog.community.json`
Edit `presets/catalog.community.json` to add or update the preset entry.
### For a new preset
Insert the entry in **alphabetical order by preset ID** within the
`"presets"` object. Use this structure:
```json
{
"<id>": {
"name": "<name>",
"id": "<id>",
"version": "<version>",
"description": "<description>",
"author": "<author>",
"repository": "<repository>",
"download_url": "<download_url>",
"homepage": "<homepage or repository>",
"documentation": "<documentation or repository README>",
"license": "<license>",
"requires": {
"speckit_version": "<speckit_version>"
},
"provides": {
"templates": <N>,
"commands": <N>
},
"tags": ["<tag1>", "<tag2>"],
"created_at": "<today>T00:00:00Z",
"updated_at": "<today>T00:00:00Z"
}
}
```
If the preset has required extensions, add an `"extensions"` array inside
`"requires"`:
```json
"requires": {
"speckit_version": "<speckit_version>",
"extensions": ["<extension-id>"]
}
```
If the preset provides scripts, add `"scripts": <N>` inside `"provides"`.
### For an update
Replace only the changed fields (typically `version`, `download_url`,
`description`, `provides`, `requires`, `tags`, `updated_at`). **Preserve**
`created_at` from the existing entry.
### Counting templates and commands
Parse the "Templates Provided" and "Commands Provided" issue fields:
- Count the number of list items (lines starting with `-`)
- If the field says "None", the count is 0
### After editing
Update the **top-level `"updated_at"` timestamp** in the catalog to today's date
in ISO 8601 format.
Validate the JSON by running:
```bash
python3 -c "import json; json.load(open('presets/catalog.community.json')); print('Valid JSON')"
```
If validation fails, fix the JSON and re-validate before continuing.
## Step 5 — Update `docs/community/presets.md`
Edit `docs/community/presets.md` to add or update a row in the Community
Presets table.
### For a new preset
Insert a new row in **alphabetical order by preset name**:
```
| <Name> | <Description> | <N> templates, <N> commands | <Requires> | [<repo-name>](<repository-url>) |
```
For the Requires column:
- Use `—` if no extensions are required
- List required extension names if any (e.g., `AIDE extension`)
If the preset provides scripts, include them: `<N> templates, <N> commands, <N> scripts`
### For an update
Find the existing row and update any changed fields in-place.
## Step 6 — Create Pull Request
Create a pull request with the changes. Use this branch naming convention:
- **New preset:** `add-<preset-id>-preset`
- **Update:** `update-<preset-id>-preset`
### Commit message
For a new preset:
```
Add <Name> preset to community catalog
Add <id> preset submitted by @<issue-author> to:
- presets/catalog.community.json (alphabetical order)
- docs/community/presets.md community presets table
Closes #<issue-number>
```
For an update:
```
Update <Name> preset to v<version>
Update <id> preset submitted by @<issue-author>:
- presets/catalog.community.json (version, download_url, etc.)
- docs/community/presets.md community presets table
Closes #<issue-number>
```
### PR description
Include:
- A summary of what changed
- Validation results (all checks passed)
- `Closes #${{ github.event.issue.number }}`
- `cc @<issue-author>` — mention the submitter
## Important Rules
- **Alphabetical order matters** — entries must be sorted by ID in the JSON and
by name in the docs table
- **Always validate JSON** after editing — a trailing comma or missing brace
will break the catalog
- **Use `Closes` not `Fixes`** — `Closes #N` is the correct keyword for
submission issues
- **Preserve `created_at` on updates** — keep the original value; only update
`updated_at`
- **Do not modify any other files** — only `presets/catalog.community.json`
and `docs/community/presets.md`

View File

@@ -19,7 +19,7 @@ jobs:
permissions:
issues: write
steps:
- uses: actions/github-script@v7
- uses: actions/github-script@v9
with:
script: |
const issue = context.payload.issue;

View File

@@ -19,14 +19,14 @@ jobs:
language: [ 'actions', 'python' ]
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Initialize CodeQL
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
with:
languages: ${{ matrix.language }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
with:
category: "/language:${{ matrix.language }}"

View File

@@ -35,7 +35,7 @@ jobs:
fetch-depth: 0 # Fetch all history for git info
- name: Setup .NET
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
with:
dotnet-version: '8.x'

View File

@@ -13,9 +13,31 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 1
- name: Run git diff --check
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PUSH_BEFORE_SHA: ${{ github.event.before }}
GITHUB_SHA: ${{ github.sha }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "pull_request" ]; then
git fetch --no-tags --depth=1 origin "+${PR_BASE_SHA}:refs/checks/pr-base"
git diff --check refs/checks/pr-base HEAD
elif [ "$PUSH_BEFORE_SHA" = "0000000000000000000000000000000000000000" ]; then
git diff-tree --check --no-commit-id --root -r "$GITHUB_SHA"
else
git fetch --no-tags --depth=1 origin "+${PUSH_BEFORE_SHA}:refs/checks/push-before"
git diff --check refs/checks/push-before HEAD
fi
- name: Run markdownlint-cli2
uses: DavidAnson/markdownlint-cli2-action@6b51ade7a9e4a75a7ad929842dd298a3804ebe8b # v23
uses: DavidAnson/markdownlint-cli2-action@ded1f9488f68a970bc66ea5619e13e9b52e601cd # v23
with:
globs: |
'**/*.md'

View File

@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
@@ -34,7 +34,7 @@ jobs:
python-version: ["3.11", "3.12", "3.13"]
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0

View File

@@ -2,6 +2,116 @@
<!-- insert new changelog below this comment -->
## [0.8.13] - 2026-05-21
### Changed
- fix: while/do-while loop condition reads stale iteration-0 step output (#2662)
- docs: fix directory hierarchy in README examples (#2639)
- fix(catalogs): reject boolean priority in extension and preset catalog readers (#2589)
- Update Agent Governance extension to v1.2.0 (#2659)
- Add agentic workflows for community catalog submissions (#2655)
- feat: add self-check tip to check output (#2574)
- fix(cli): clarify exception diagnostics (#2602)
- ci: add diff whitespace check (#2572)
- chore: release 0.8.12, begin 0.8.13.dev0 development (#2648)
## [0.8.12] - 2026-05-20
### Changed
- fix(codex): inject dot-to-hyphen hook command note in Codex skills (#2503)
- Update Squad Bridge extension to v1.3.0 (#2645)
- Update Superpowers Implementation Bridge extension to v0.5.0 (#2644)
- Add Team Assign extension to community catalog (#2642)
- refactor: migrate extension catalog stack parsing to shared base (#2576)
- Update Architecture Workflow extension to v1.1.0 (#2588)
- fix(workflow): support integration: auto to follow project's initialized AI (#2421)
- Add Superpowers Implementation Bridge extension to community catalog (#2586)
- Add Interactive HTML Preview extension to community catalog (#2585)
- chore: release 0.8.11, begin 0.8.12.dev0 development (#2584)
- Update Agent Governance extension to v1.1.0 (#2583)
## [0.8.11] - 2026-05-15
### Changed
- refactor: extract _version.py from __init__.py (PR-3/8) (#2550)
- Add Time Machine extension to community catalog (#2580)
- fix(powershell): ensure UTF-8 templates are written without BOM (#2280)
- docs: document high-assurance spec workflow (#2518)
- docs: fix script name in directory tree examples (#2555)
- Fix preset skill description precedence (#2538)
- fix(integration): clarify multi-install guidance (#2549)
- feat: add version feature reporting (#2548)
- Add Architecture Workflow extension to community catalog (#2565)
- chore: release 0.8.10, begin 0.8.11.dev0 development (#2562)
## [0.8.10] - 2026-05-14
### Changed
- docs: streamline install section and add community overview (#2561)
- Move community extensions table from README to docs site (#2560)
- Add Agent Governance extension to community catalog (#2559)
- Add Reqnroll BDD extension to community catalog (#2545)
- fix(cli): harden extension registration and discovery workflows (#2499)
- refactor: extract _assets.py and _utils.py from __init__.py (PR-2/8) (#2543)
- fix(opencode): use commands/ directory (plural) to match OpenCode docs (#2453)
- refactor: extract _console.py from __init__.py (PR-1/8) (#2474)
- Fix constitution reference in README (#2491)
- chore: release 0.8.9, begin 0.8.10.dev0 development (#2532)
## [0.8.9] - 2026-05-12
### Changed
- docs: revamp landing page with four-pillar card layout (#2531)
- feat(extensions): update governance ecosystem extensions to latest versions (#2514)
- Add changelog extension (#2177)
- Add install directory to docfx.json file references (#2522)
- feat(catalog): add BrownKit (brownkit) community extension (#2510) (#2520)
- fix(kiro-cli): replace literal $ARGUMENTS with prose fallback (#2482)
- Preset: Add game-narrative-writing preset to community catalog (#2454)
- docs: clarify CLI upgrade discovery (#2519)
- fix: make template metadata line breaks markdownlint-safe (#2505)
- refactor(catalogs): extract integration catalog config loading (#2497)
- test(presets): silence expected UserWarnings in self-test composition… (#2373)
- chore: release 0.8.8, begin 0.8.9.dev0 development (#2516)
## [0.8.8] - 2026-05-11
### Changed
- chore(deps): bump actions/checkout from 4.3.1 to 6.0.2 (#2486)
- feat(catalog): add Spec Kit Schedule (schedule) community extension (#2473)
- fix(integration): refresh shared infra on `integration switch` (#2375)
- Add MDE preset to community catalog (#2513)
- Add MDE extension to community catalog (#2512)
- chore: update community catalog with latest extension versions (#2490)
- chore(deps): bump actions/setup-dotnet from 4.3.1 to 5.2.0 (#2489)
- chore(deps): bump actions/github-script from 7 to 9 (#2488)
- chore(deps): bump DavidAnson/markdownlint-cli2-action (#2487)
- chore(deps): bump github/codeql-action from 4.35.3 to 4.35.4 (#2485)
- feat(catalog): add API Evolve (api-evolve) community extension (#2479)
- feat: Config-driven opt-in authentication registry with multi-platform support (#2393)
- chore: release 0.8.7, begin 0.8.8.dev0 development (#2480)
## [0.8.7] - 2026-05-07
### Changed
- feat: add agent-orchestrator to community extension catalog (#2236)
- chore: update extension versions in community catalog (#2468)
- fix(goose): Declare args parameter in generated recipes (#2402)
- feat: Add lingma support (#2348)
- docs: Add uv installation guide and inline callouts (#2465)
- Add fx-to-dotnet to community extension catalog (#2471)
- fix: default non-interactive init to copilot integration (#2414)
- fix(forge): use hyphen notation for command refs in Forge integration (#2462)
- feat(catalog): add Cost Tracker (cost) community extension (#2448)
- chore: release 0.8.6, begin 0.8.7.dev0 development (#2463)
## [0.8.6] - 2026-05-06
### Changed

0
EOF
View File

302
README.md
View File

@@ -35,8 +35,7 @@
- [🔧 Prerequisites](#-prerequisites)
- [📖 Learn More](#-learn-more)
- [📋 Detailed Process](#-detailed-process)
- [🔍 Troubleshooting](#-troubleshooting)
- [💬 Support](#-support)
- [ Support](#-support)
- [🙏 Acknowledgements](#-acknowledgements)
- [📄 License](#-license)
@@ -48,80 +47,22 @@ Spec-Driven Development **flips the script** on traditional software development
### 1. Install Specify CLI
Choose your preferred installation method:
> **Important:** The only official, maintained packages for Spec Kit are published from this GitHub repository. Any packages with the same name on PyPI are **not** affiliated with this project and are not maintained by the Spec Kit maintainers. Always install directly from GitHub as shown below.
#### Option 1: Persistent Installation (Recommended)
Install once and use everywhere. Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest):
Requires **[uv](https://docs.astral.sh/uv/)** ([install uv](./docs/install/uv.md)). Replace `vX.Y.Z` with the latest tag from [Releases](https://github.com/github/spec-kit/releases):
```bash
# Install a specific stable release (recommended — replace vX.Y.Z with the latest tag)
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z
# Or install latest from main (may include unreleased changes)
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git
# Alternative: using pipx (also works)
pipx install git+https://github.com/github/spec-kit.git@vX.Y.Z
pipx install git+https://github.com/github/spec-kit.git
```
Then verify the correct version is installed:
See the [Installation Guide](./docs/installation.md) for alternative methods, verification, upgrade, and troubleshooting.
### 2. Initialize a project
```bash
specify version
specify init my-project --integration copilot
cd my-project
```
And use the tool directly:
```bash
# Create new project
specify init <PROJECT_NAME>
# Or initialize in existing project
specify init . --integration copilot
# or
specify init --here --integration copilot
# Check installed tools
specify check
```
To upgrade Specify, see the [Upgrade Guide](./docs/upgrade.md) for detailed instructions. Quick upgrade:
```bash
uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z
# pipx users: pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z
```
#### Option 2: One-time Usage
Run directly without installing:
```bash
# Create new project (pinned to a stable release — replace vX.Y.Z with the latest tag)
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <PROJECT_NAME>
# Or initialize in existing project
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init . --integration copilot
# or
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --integration copilot
```
**Benefits of persistent installation:**
- Tool stays installed and available in PATH
- No need to create shell aliases
- Better tool management with `uv tool list`, `uv tool upgrade`, `uv tool uninstall`
- Cleaner shell configuration
#### Option 3: Enterprise / Air-Gapped Installation
If your environment blocks access to PyPI or GitHub, see the [Enterprise / Air-Gapped Installation](./docs/installation.md#enterprise--air-gapped-installation) guide for step-by-step instructions on using `pip download` to create portable, OS-specific wheel bundles on a connected machine.
### 2. 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.
@@ -131,7 +72,7 @@ Use the **`/speckit.constitution`** command to create your project's governing p
/speckit.constitution Create principles focused on code quality, testing standards, user experience consistency, and performance requirements
```
### 3. Create the spec
### 4. Create the spec
Use the **`/speckit.specify`** command to describe what you want to build. Focus on the **what** and **why**, not the tech stack.
@@ -139,7 +80,7 @@ Use the **`/speckit.specify`** command to describe what you want to build. Focus
/speckit.specify Build an application that can help me organize my photos in separate photo albums. Albums are grouped by date and can be re-organized by dragging and dropping on the main page. Albums are never in other nested albums. Within each album, photos are previewed in a tile-like interface.
```
### 4. Create a technical implementation plan
### 5. Create a technical implementation plan
Use the **`/speckit.plan`** command to provide your tech stack and architecture choices.
@@ -147,7 +88,7 @@ Use the **`/speckit.plan`** command to provide your tech stack and architecture
/speckit.plan The application uses Vite with minimal number of libraries. Use vanilla HTML, CSS, and JavaScript as much as possible. Images are not uploaded anywhere and metadata is stored in a local SQLite database.
```
### 5. Break down into tasks
### 6. Break down into tasks
Use **`/speckit.tasks`** to create an actionable task list from your implementation plan.
@@ -155,7 +96,7 @@ Use **`/speckit.tasks`** to create an actionable task list from your implementat
/speckit.tasks
```
### 6. Execute implementation
### 7. Execute implementation
Use **`/speckit.implement`** to execute all tasks and build your feature according to the plan.
@@ -173,116 +114,10 @@ Want to see Spec Kit in action? Watch our [video overview](https://www.youtube.c
## 🧩 Community Extensions
Community-contributed extensions add new commands, hooks, and capabilities to Spec Kit. See the full list on the [Community Extensions](https://github.github.io/spec-kit/community/extensions.html) page.
> [!NOTE]
> Community extensions are independently created and maintained by their respective authors. Maintainers only verify that catalog entries are complete and correctly formatted — they do **not review, audit, endorse, or support the extension code itself**. The Community Extensions website is also a third-party resource. Review extension source code before installation and use at your own discretion.
🔍 **Browse and search community extensions on the [Community Extensions website](https://speckit-community.github.io/extensions/).**
The following community-contributed extensions are available in [`catalog.community.json`](extensions/catalog.community.json):
**Categories:**
- `docs` — reads, validates, or generates spec artifacts
- `code` — reviews, validates, or modifies source code
- `process` — orchestrates workflow across phases
- `integration` — syncs with external platforms
- `visibility` — reports on project health or progress
**Effect:**
- `Read-only` — produces reports without modifying files
- `Read+Write` — modifies files, creates artifacts, or updates specs
| Extension | Purpose | Category | Effect | URL |
|-----------|---------|----------|--------|-----|
| Agent Assign | Assign specialized Claude Code agents to spec-kit tasks for targeted execution | `process` | Read+Write | [spec-kit-agent-assign](https://github.com/xymelon/spec-kit-agent-assign) |
| AI-Driven Engineering (AIDE) | A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation | `process` | Read+Write | [aide](https://github.com/mnriem/spec-kit-extensions/tree/main/aide) |
| Architect Impact Previewer | Predicts architectural impact, complexity, and risks of proposed changes before implementation. | `visibility` | Read-only | [spec-kit-architect-preview](https://github.com/UmmeHabiba1312/spec-kit-architect-preview) |
| Architecture Guard | Continuous architecture governance for AI-assisted development. Reviews specs, plans, and code for architecture drift, producing structured refactor tasks and evolution proposals. | `process` | Read+Write | [spec-kit-architecture-guard](https://github.com/DyanGalih/spec-kit-architecture-guard) |
| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
| Blueprint | Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs | `docs` | Read+Write | [spec-kit-blueprint](https://github.com/chordpli/spec-kit-blueprint) |
| Branch Convention | Configurable branch and folder naming conventions for /specify with presets and custom patterns | `process` | Read+Write | [spec-kit-branch-convention](https://github.com/Quratulain-bilal/spec-kit-branch-convention) |
| Brownfield Bootstrap | Bootstrap spec-kit for existing codebases — auto-discover architecture and adopt SDD incrementally | `process` | Read+Write | [spec-kit-brownfield](https://github.com/Quratulain-bilal/spec-kit-brownfield) |
| Bugfix Workflow | Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically | `process` | Read+Write | [spec-kit-bugfix](https://github.com/Quratulain-bilal/spec-kit-bugfix) |
| Canon | Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation. | `process` | Read+Write | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon/tree/master/extension) |
| Catalog CI | Automated validation for spec-kit community catalog entries — structure, URLs, diffs, and linting | `process` | Read-only | [spec-kit-catalog-ci](https://github.com/Quratulain-bilal/spec-kit-catalog-ci) |
| CI Guard | Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps | `process` | Read-only | [spec-kit-ci-guard](https://github.com/Quratulain-bilal/spec-kit-ci-guard) |
| Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) |
| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |
| 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) |
| 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) |
| Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) |
| Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) |
| FixIt Extension | Spec-aware bug fixing — maps bugs to spec artifacts, proposes a plan, applies minimal changes | `code` | Read+Write | [spec-kit-fixit](https://github.com/speckit-community/spec-kit-fixit) |
| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |
| GitHub Issues Integration 1 | Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability | `integration` | Read+Write | [spec-kit-github-issues](https://github.com/Fatima367/spec-kit-github-issues) |
| GitHub Issues Integration 2 | Creates and syncs local specs from an existing GitHub issue | `integration` | Read+Write | [spec-kit-issue](https://github.com/aaronrsun/spec-kit-issue) |
| 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) |
| 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) |
| 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 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 GitHub Projects Integration | GitHub Projects v2 integration for MAQA — syncs draft issues and Status columns as features progress | `integration` | Read+Write | [spec-kit-maqa-github-projects](https://github.com/GenieRobot/spec-kit-maqa-github-projects) |
| MAQA Jira Integration | Jira integration for MAQA — syncs Stories and Subtasks as features progress through the board | `integration` | Read+Write | [spec-kit-maqa-jira](https://github.com/GenieRobot/spec-kit-maqa-jira) |
| MAQA Linear Integration | Linear integration for MAQA — syncs issues and sub-issues across workflow states as features progress | `integration` | Read+Write | [spec-kit-maqa-linear](https://github.com/GenieRobot/spec-kit-maqa-linear) |
| MAQA Trello Integration | Trello board integration for MAQA — populates board from specs, moves cards, real-time checklist ticking | `integration` | Read+Write | [spec-kit-maqa-trello](https://github.com/GenieRobot/spec-kit-maqa-trello) |
| MarkItDown Document Converter | Convert documents (PDF, Word, PowerPoint, Excel, and more) to Markdown for use as spec reference material | `docs` | Read+Write | [spec-kit-markitdown](https://github.com/BenBtg/spec-kit-markitdown) |
| Memory Loader | Loads .specify/memory/ files before lifecycle commands so LLM agents have project governance context | `docs` | Read-only | [spec-kit-memory-loader](https://github.com/KevinBrown5280/spec-kit-memory-loader) |
| Memory MD | Spec Kit extension for repository-native Markdown memory that captures durable decisions, bugs, and project context | `docs` | Read+Write | [spec-kit-memory-hub](https://github.com/DyanGalih/spec-kit-memory-hub) |
| MemoryLint | Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) |
| Microsoft 365 Integration | Fetch Teams messages, meeting transcripts, and SharePoint/OneDrive files as local Markdown for spec generation | `integration` | Read+Write | [spec-kit-m365](https://github.com/BenBtg/spec-kit-m365) |
| Multi-Model Review | Cross-model Spec Kit handoffs for spec authoring, implementation routing, and review. | `process` | Read+Write | [multi-model-review](https://github.com/formin/multi-model-review) |
| Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) |
| Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) |
| OWASP LLM Threat Model | OWASP Top 10 for LLM Applications 2025 threat analysis on agent artifacts | `code` | Read-only | [spec-kit-threatmodel](https://github.com/NaviaSamal/spec-kit-threatmodel) |
| Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) |
| PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) |
| 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 — portfolio, lite mode, monorepo, optional V-Model | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) |
| 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) |
| QA Testing Extension | Systematic QA testing with browser-driven or CLI-based validation of acceptance criteria from spec | `code` | Read-only | [spec-kit-qa](https://github.com/arunt14/spec-kit-qa) |
| 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) |
| 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) |
| 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) |
| Retro Extension | Sprint retrospective analysis with metrics, spec accuracy assessment, and improvement suggestions | `process` | Read+Write | [spec-kit-retro](https://github.com/arunt14/spec-kit-retro) |
| Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | `docs` | Read+Write | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) |
| Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) |
| Ripple | Detect side effects that tests can't catch after implementation — delta-anchored analysis across 9 domain-agnostic categories | `code` | Read+Write | [spec-kit-ripple](https://github.com/chordpli/spec-kit-ripple) |
| SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) |
| Security Review | Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews | `code` | Read+Write | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) |
| SFSpeckit | Enterprise Salesforce SDLC with 18 commands for the full SDD lifecycle. | `process` | Read+Write | [spec-kit-sf](https://github.com/ysumanth06/spec-kit-sf) |
| Ship Release Extension | Automates release pipeline: pre-flight checks, branch sync, changelog generation, CI verification, and PR creation | `process` | Read+Write | [spec-kit-ship](https://github.com/arunt14/spec-kit-ship) |
| Spec Reference Loader | Reads the ## References section from the feature spec and loads only the listed docs into context | `docs` | Read-only | [spec-kit-spec-reference-loader](https://github.com/KevinBrown5280/spec-kit-spec-reference-loader) |
| Spec Critique Extension | Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives | `docs` | Read-only | [spec-kit-critique](https://github.com/arunt14/spec-kit-critique) |
| Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) |
| Spec Orchestrator | Cross-feature orchestration — track state, select tasks, and detect conflicts across parallel specs | `process` | Read-only | [spec-kit-orchestrator](https://github.com/Quratulain-bilal/spec-kit-orchestrator) |
| 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 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 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) |
| 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) |
| 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) |
| 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 (WangX0111) | 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) |
| TinySpec | Lightweight single-file workflow for small tasks — skip the heavy multi-step SDD process | `process` | Read+Write | [spec-kit-tinyspec](https://github.com/Quratulain-bilal/spec-kit-tinyspec) |
| Token Consumption Analyzer | Captures, analyzes, and compares token consumption across SDD workflows | `visibility` | Read-only | [spec-kit-token-analyzer](https://github.com/coderandhiker/spec-kit-token-analyzer) |
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |
| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) |
| Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) |
| Version Guard | Verify tech stack versions against live npm registries before planning and implementation | `process` | Read-only | [spec-kit-version-guard](https://github.com/KevinBrown5280/spec-kit-version-guard) |
| What-if Analysis | Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them | `visibility` | Read-only | [spec-kit-whatif](https://github.com/DevAbdullah90/spec-kit-whatif) |
| Wireframe Visual Feedback Loop | SVG wireframe generation, review, and sign-off for spec-driven development. Approved wireframes become spec constraints honored by /speckit.plan, /speckit.tasks, and /speckit.implement | `visibility` | Read+Write | [spec-kit-extension-wireframe](https://github.com/TortoiseWolfe/spec-kit-extension-wireframe) |
| Work IQ | Integrate Microsoft 365 organizational knowledge into spec-driven development workflows | `integration` | Read-only | [spec-kit-workiq](https://github.com/sakitA/spec-kit-workiq) |
| Worktree Isolation | Spawn isolated git worktrees for parallel feature development without checkout switching | `process` | Read+Write | [spec-kit-worktree](https://github.com/Quratulain-bilal/spec-kit-worktree) |
| Worktrees | Default-on worktree isolation for parallel agents — sibling or nested layout | `process` | Read+Write | [spec-kit-worktree-parallel](https://github.com/dango85/spec-kit-worktree-parallel) |
> Community extensions are independently created and maintained by their respective authors. Maintainers only verify that catalog entries are complete and correctly formatted — they do **not review, audit, endorse, or support the extension code itself**. Review extension source code before installation and use at your own discretion.
To submit your own extension, see the [Extension Publishing Guide](extensions/EXTENSION-PUBLISHING-GUIDE.md).
@@ -484,7 +319,7 @@ specify init --here --force
![Specify CLI bootstrapping a new project in the terminal](./media/specify_cli.gif)
You will be prompted to select the coding agent integration you are using. You can also proactively specify it directly in the terminal:
In an interactive terminal, you will be prompted to select the coding agent integration you are using. In non-interactive sessions, such as CI or piped runs, `specify init` defaults to GitHub Copilot unless you pass `--integration`. You can also proactively specify the integration directly in the terminal:
```bash
specify init <project_name> --integration copilot
@@ -565,22 +400,24 @@ The produced specification should contain a set of user stories and functional r
At this stage, your project folder contents should resemble the following:
```text
└── .specify
├── memory
│ └── constitution.md
├── scripts
│ ├── check-prerequisites.sh
── common.sh
├── create-new-feature.sh
├── setup-plan.sh
└── update-claude-md.sh
├── specs
└── 001-create-taskify
└── spec.md
── templates
├── plan-template.md
── spec-template.md
└── tasks-template.md
.
├── .specify
├── memory
│ └── constitution.md
├── scripts
── bash
├── check-prerequisites.sh
│ │ ├── common.sh
├── create-new-feature.sh
│ │ ├── setup-plan.sh
│ │ └── setup-tasks.sh
└── templates
── plan-template.md
├── spec-template.md
── tasks-template.md
└── specs
└── 001-create-taskify
└── spec.md
```
### **STEP 3:** Functional specification clarification (required before planning)
@@ -627,29 +464,31 @@ The output of this step will include a number of implementation detail documents
```text
.
├── CLAUDE.md
├── memory
── constitution.md
├── scripts
│ ├── check-prerequisites.sh
├── common.sh
├── create-new-feature.sh
├── setup-plan.sh
└── update-claude-md.sh
├── specs
└── 001-create-taskify
├── contracts
├── api-spec.json
│ └── signalr-spec.md
│ ├── data-model.md
── plan.md
│ ├── quickstart.md
── research.md
── spec.md
└── templates
├── CLAUDE-template.md
├── plan-template.md
├── spec-template.md
└── tasks-template.md
├── .specify
── memory
│ │ └── constitution.md
├── scripts
│ └── bash
│ ├── check-prerequisites.sh
│ ├── common.sh
│ ├── create-new-feature.sh
│ │ ├── setup-plan.sh
│ └── setup-tasks.sh
└── templates
│ ├── CLAUDE-template.md
├── plan-template.md
├── spec-template.md
── tasks-template.md
└── specs
── 001-create-taskify
── contracts
│ ├── api-spec.json
│ └── signalr-spec.md
├── data-model.md
├── plan.md
├── quickstart.md
├── research.md
└── spec.md
```
Check the `research.md` document to ensure that the right tech stack is used, based on your instructions. You can ask Claude Code to refine it if any of the components stand out, or even have it check the locally-installed version of the platform/framework you want to use (e.g., .NET).
@@ -696,7 +535,7 @@ This helps refine the implementation plan and helps you avoid potential blind sp
You can also ask Claude Code (if you have the [GitHub CLI](https://docs.github.com/en/github-cli/github-cli) installed) to go ahead and create a pull request from your current branch to `main` with a detailed description, to make sure that the effort is properly tracked.
> [!NOTE]
> Before you have the agent implement it, it's also worth prompting Claude Code to cross-check the details to see if there are any over-engineered pieces (remember - it can be over-eager). If over-engineered components or decisions exist, you can ask Claude Code to resolve them. Ensure that Claude Code follows the [constitution](base/memory/constitution.md) as the foundational piece that it must adhere to when establishing the plan.
> Before you have the agent implement it, it's also worth prompting Claude Code to cross-check the details to see if there are any over-engineered pieces (remember - it can be over-eager). If over-engineered components or decisions exist, you can ask Claude Code to resolve them. Ensure that Claude Code follows the constitution in `.specify/memory/constitution.md` as the foundational piece that it must adhere to when establishing the plan.
### **STEP 6:** Generate task breakdown with /speckit.tasks
@@ -742,26 +581,7 @@ Once the implementation is complete, test the application and resolve any runtim
---
## 🔍 Troubleshooting
### Git Credential Manager on Linux
If you're having issues with Git authentication on Linux, you can install Git Credential Manager:
```bash
#!/usr/bin/env bash
set -e
echo "Downloading Git Credential Manager v2.6.1..."
wget https://github.com/git-ecosystem/git-credential-manager/releases/download/v2.6.1/gcm-linux_amd64.2.6.1.deb
echo "Installing Git Credential Manager..."
sudo dpkg -i gcm-linux_amd64.2.6.1.deb
echo "Configuring Git to use GCM..."
git config --global credential.helper manager
echo "Cleaning up..."
rm gcm-linux_amd64.2.6.1.deb
```
## 💬 Support
## Support
For support, please open a [GitHub issue](https://github.com/github/spec-kit/issues/new). We welcome bug reports, feature requests, and questions about using Spec-Driven Development.

View File

@@ -0,0 +1,129 @@
# Community Extensions
> [!NOTE]
> Community extensions are independently created and maintained by their respective authors. Maintainers only verify that catalog entries are complete and correctly formatted — they do **not review, audit, endorse, or support the extension code itself**. The Community Extensions website is also a third-party resource. Review extension source code before installation and use at your own discretion.
🔍 **Browse and search community extensions on the [Community Extensions website](https://speckit-community.github.io/extensions/).**
The following community-contributed extensions are available in [`catalog.community.json`](https://github.com/github/spec-kit/blob/main/extensions/catalog.community.json):
**Categories:**
- `docs` — reads, validates, or generates spec artifacts
- `code` — reviews, validates, or modifies source code
- `process` — orchestrates workflow across phases
- `integration` — syncs with external platforms
- `visibility` — reports on project health or progress
**Effect:**
- `Read-only` — produces reports without modifying files
- `Read+Write` — modifies files, creates artifacts, or updates specs
| Extension | Purpose | Category | Effect | URL |
|-----------|---------|----------|--------|-----|
| Agent Assign | Assign specialized Claude Code agents to spec-kit tasks for targeted execution | `process` | Read+Write | [spec-kit-agent-assign](https://github.com/xymelon/spec-kit-agent-assign) |
| Agent Governance | Generate agent-platform repository governance files from Spec Kit metadata | `process` | Read+Write | [spec-kit-agent-governance](https://github.com/bigsmartben/spec-kit-agent-governance) |
| AI-Driven Engineering (AIDE) | A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation | `process` | Read+Write | [aide](https://github.com/mnriem/spec-kit-extensions/tree/main/aide) |
| API Evolve | Managed API contract evolution — breaking-change detection, semver enforcement, deprecation orchestration, and lifecycle gates across REST, GraphQL, and gRPC | `process` | Read+Write | [spec-kit-api-evolve](https://github.com/Quratulain-bilal/spec-kit-api-evolve) |
| Architect Impact Previewer | Predicts architectural impact, complexity, and risks of proposed changes before implementation. | `visibility` | Read-only | [spec-kit-architect-preview](https://github.com/UmmeHabiba1312/spec-kit-architect-preview) |
| Architecture Guard | Continuous architecture governance for AI-assisted development. Reviews specs, plans, and code for architecture drift, producing structured refactor tasks and evolution proposals. | `process` | Read+Write | [spec-kit-architecture-guard](https://github.com/DyanGalih/spec-kit-architecture-guard) |
| Architecture Workflow | Generate or reverse project-level 4+1 architecture view artifacts and synthesis | `docs` | Read+Write | [spec-kit-arch](https://github.com/bigsmartben/spec-kit-arch) |
| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
| Blueprint | Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs | `docs` | Read+Write | [spec-kit-blueprint](https://github.com/chordpli/spec-kit-blueprint) |
| Branch Convention | Configurable branch and folder naming conventions for /specify with presets and custom patterns | `process` | Read+Write | [spec-kit-branch-convention](https://github.com/Quratulain-bilal/spec-kit-branch-convention) |
| Brownfield Bootstrap | Bootstrap spec-kit for existing codebases — auto-discover architecture and adopt SDD incrementally | `process` | Read+Write | [spec-kit-brownfield](https://github.com/Quratulain-bilal/spec-kit-brownfield) |
| BrownKit | Evidence-driven capability discovery, security and QA risk assessment for existing codebases | `process` | Read+Write | [BrownKit](https://github.com/MaksimShevtsov/BrownKit) |
| Bugfix Workflow | Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically | `process` | Read+Write | [spec-kit-bugfix](https://github.com/Quratulain-bilal/spec-kit-bugfix) |
| Canon | Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation. | `process` | Read+Write | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon/tree/master/extension) |
| Catalog CI | Automated validation for spec-kit community catalog entries — structure, URLs, diffs, and linting | `process` | Read-only | [spec-kit-catalog-ci](https://github.com/Quratulain-bilal/spec-kit-catalog-ci) |
| CI Guard | Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps | `process` | Read-only | [spec-kit-ci-guard](https://github.com/Quratulain-bilal/spec-kit-ci-guard) |
| Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) |
| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |
| 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) |
| 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) |
| Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) |
| Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) |
| FixIt Extension | Spec-aware bug fixing — maps bugs to spec artifacts, proposes a plan, applies minimal changes | `code` | Read+Write | [spec-kit-fixit](https://github.com/speckit-community/spec-kit-fixit) |
| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |
| GitHub Issues Integration 1 | Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability | `integration` | Read+Write | [spec-kit-github-issues](https://github.com/Fatima367/spec-kit-github-issues) |
| GitHub Issues Integration 2 | Creates and syncs local specs from an existing GitHub issue | `integration` | Read+Write | [spec-kit-issue](https://github.com/aaronrsun/spec-kit-issue) |
| 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) |
| 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) |
| 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) |
| 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 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 GitHub Projects Integration | GitHub Projects v2 integration for MAQA — syncs draft issues and Status columns as features progress | `integration` | Read+Write | [spec-kit-maqa-github-projects](https://github.com/GenieRobot/spec-kit-maqa-github-projects) |
| MAQA Jira Integration | Jira integration for MAQA — syncs Stories and Subtasks as features progress through the board | `integration` | Read+Write | [spec-kit-maqa-jira](https://github.com/GenieRobot/spec-kit-maqa-jira) |
| MAQA Linear Integration | Linear integration for MAQA — syncs issues and sub-issues across workflow states as features progress | `integration` | Read+Write | [spec-kit-maqa-linear](https://github.com/GenieRobot/spec-kit-maqa-linear) |
| MAQA Trello Integration | Trello board integration for MAQA — populates board from specs, moves cards, real-time checklist ticking | `integration` | Read+Write | [spec-kit-maqa-trello](https://github.com/GenieRobot/spec-kit-maqa-trello) |
| MarkItDown Document Converter | Convert documents (PDF, Word, PowerPoint, Excel, and more) to Markdown for use as spec reference material | `docs` | Read+Write | [spec-kit-markitdown](https://github.com/BenBtg/spec-kit-markitdown) |
| MDE | Minimal model-driven engineering workflow with setup, next, and status commands | `process` | Read+Write | [spec-kit-mde](https://github.com/AI-MDE/spec-kit-mde) |
| Memory Loader | Loads .specify/memory/ files before lifecycle commands so LLM agents have project governance context | `docs` | Read-only | [spec-kit-memory-loader](https://github.com/KevinBrown5280/spec-kit-memory-loader) |
| Memory MD | Spec Kit extension for repository-native Markdown memory that captures durable decisions, bugs, and project context | `docs` | Read+Write | [spec-kit-memory-hub](https://github.com/DyanGalih/spec-kit-memory-hub) |
| MemoryLint | Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) |
| Microsoft 365 Integration | Fetch Teams messages, meeting transcripts, and SharePoint/OneDrive files as local Markdown for spec generation | `integration` | Read+Write | [spec-kit-m365](https://github.com/BenBtg/spec-kit-m365) |
| Multi-Model Review | Cross-model Spec Kit handoffs for spec authoring, implementation routing, and review. | `process` | Read+Write | [multi-model-review](https://github.com/formin/multi-model-review) |
| .NET Framework to Modern .NET Migration | Orchestrate end-to-end .NET Framework to modern .NET migration across 7 phases, with SDD lifecycle integration | `process` | Read+Write | [spec-kit-fx-to-net](https://github.com/RogerBestMsft/spec-kit-FxToNet) |
| Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) |
| Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) |
| OWASP LLM Threat Model | OWASP Top 10 for LLM Applications 2025 threat analysis on agent artifacts | `code` | Read-only | [spec-kit-threatmodel](https://github.com/NaviaSamal/spec-kit-threatmodel) |
| Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) |
| PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) |
| 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 — portfolio, lite mode, monorepo, optional V-Model | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) |
| 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) |
| QA Testing Extension | Systematic QA testing with browser-driven or CLI-based validation of acceptance criteria from spec | `code` | Read-only | [spec-kit-qa](https://github.com/arunt14/spec-kit-qa) |
| 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) |
| 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) |
| 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) |
| Retro Extension | Sprint retrospective analysis with metrics, spec accuracy assessment, and improvement suggestions | `process` | Read+Write | [spec-kit-retro](https://github.com/arunt14/spec-kit-retro) |
| Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | `docs` | Read+Write | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) |
| Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) |
| Ripple | Detect side effects that tests can't catch after implementation — delta-anchored analysis across 9 domain-agnostic categories | `code` | Read+Write | [spec-kit-ripple](https://github.com/chordpli/spec-kit-ripple) |
| SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) |
| Security Review | Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews | `code` | Read+Write | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) |
| SFSpeckit | Enterprise Salesforce SDLC with 18 commands for the full SDD lifecycle. | `process` | Read+Write | [spec-kit-sf](https://github.com/ysumanth06/spec-kit-sf) |
| Ship Release Extension | Automates release pipeline: pre-flight checks, branch sync, changelog generation, CI verification, and PR creation | `process` | Read+Write | [spec-kit-ship](https://github.com/arunt14/spec-kit-ship) |
| Spec Changelog | Auto-generate changelogs and release notes from spec git history and requirement diffs | `docs` | Read-only | [spec-kit-changelog](https://github.com/Quratulain-bilal/spec-kit-changelog) |
| Spec Critique Extension | Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives | `docs` | Read-only | [spec-kit-critique](https://github.com/arunt14/spec-kit-critique) |
| Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) |
| Spec Kit Schedule | Optimal multi-agent task scheduling via CP-SAT — DAG precedence, hallucination-aware caps, file-conflict avoidance, stochastic durations, replanning, and interactive HTML output | `process` | Read+Write | [spec-kit-schedule](https://github.com/jfranc38/spec-kit-schedule) |
| Spec Orchestrator | Cross-feature orchestration — track state, select tasks, and detect conflicts across parallel specs | `process` | Read-only | [spec-kit-orchestrator](https://github.com/Quratulain-bilal/spec-kit-orchestrator) |
| Spec Reference Loader | Reads the ## References section from the feature spec and loads only the listed docs into context | `docs` | Read-only | [spec-kit-spec-reference-loader](https://github.com/KevinBrown5280/spec-kit-spec-reference-loader) |
| 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 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 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) |
| 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) |
| 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) |
| 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 (WangX0111) | 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) |
| 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) |
| 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) |
| Time Machine | Retroactively apply the full SDD workflow to existing codebases — analyse, spec, and ship feature-by-feature | `process` | Read+Write | [spec-kit-time-machine](https://github.com/teeyo/spec-kit-time-machine) |
| TinySpec | Lightweight single-file workflow for small tasks — skip the heavy multi-step SDD process | `process` | Read+Write | [spec-kit-tinyspec](https://github.com/Quratulain-bilal/spec-kit-tinyspec) |
| Token Consumption Analyzer | Captures, analyzes, and compares token consumption across SDD workflows | `visibility` | Read-only | [spec-kit-token-analyzer](https://github.com/coderandhiker/spec-kit-token-analyzer) |
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |
| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) |
| Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) |
| Version Guard | Verify tech stack versions against live npm registries before planning and implementation | `process` | Read-only | [spec-kit-version-guard](https://github.com/KevinBrown5280/spec-kit-version-guard) |
| What-if Analysis | Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them | `visibility` | Read-only | [spec-kit-whatif](https://github.com/DevAbdullah90/spec-kit-whatif) |
| Wireframe Visual Feedback Loop | SVG wireframe generation, review, and sign-off for spec-driven development. Approved wireframes become spec constraints honored by /speckit.plan, /speckit.tasks, and /speckit.implement | `visibility` | Read+Write | [spec-kit-extension-wireframe](https://github.com/TortoiseWolfe/spec-kit-extension-wireframe) |
| Work IQ | Integrate Microsoft 365 organizational knowledge into spec-driven development workflows | `integration` | Read-only | [spec-kit-workiq](https://github.com/sakitA/spec-kit-workiq) |
| Worktree Isolation | Spawn isolated git worktrees for parallel feature development without checkout switching | `process` | Read+Write | [spec-kit-worktree](https://github.com/Quratulain-bilal/spec-kit-worktree) |
| Worktrees | Default-on worktree isolation for parallel agents — sibling or nested layout | `process` | Read+Write | [spec-kit-worktree-parallel](https://github.com/dango85/spec-kit-worktree-parallel) |
To submit your own extension, see the [Extension Publishing Guide](https://github.com/github/spec-kit/blob/main/extensions/EXTENSION-PUBLISHING-GUIDE.md).

View File

@@ -0,0 +1,27 @@
# Community
The Spec Kit community builds extensions, presets, walkthroughs, and companion projects that expand what you can do with Spec-Driven Development. All community contributions are independently created and maintained by their respective authors.
## Extensions
Extensions add new capabilities to Spec Kit — domain-specific commands, external tool integrations, quality gates, and more. Over 90 community extensions are available from 50+ authors, covering everything from accessibility governance to multi-agent orchestration.
[Browse community extensions →](extensions.md)
## Presets
Presets customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. Community presets range from language localizations to entirely different development methodologies.
[Browse community presets →](presets.md)
## Walkthroughs
Step-by-step guides that show Spec-Driven Development in action across different scenarios, languages, and frameworks.
[Browse community walkthroughs →](walkthroughs.md)
## Friends
Community projects that extend, visualize, or build on Spec Kit — including VS Code extensions, Claude Code plugins, and more.
[Browse friend projects →](friends.md)

View File

@@ -16,8 +16,10 @@ The following community-contributed presets customize how Spec Kit behaves — o
| 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) |
| 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. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. | 22 templates, 27 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) |
| 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) |
| Model Driven Engineering | Focuses on streamlined commands, app repository support, cross-spec support, and capability-aware project memory for model-driven engineering workflows | 6 templates, 11 commands | MDE extension | [spec-kit-preset-mde](https://github.com/AI-MDE/spec-kit-preset-mde) |
| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) |
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) |

46
docs/concepts/sdd.md Normal file
View File

@@ -0,0 +1,46 @@
# What is Spec-Driven Development?
Spec-Driven Development **flips the script** on traditional software development. For decades, code has been king — specifications were just scaffolding we built and discarded once the "real work" of coding began. Spec-Driven Development changes this: **specifications become executable**, directly generating working implementations rather than just guiding them.
## Core Philosophy
Spec-Driven Development is a structured process that emphasizes:
- **Intent-driven development** where specifications define the "*what*" before the "*how*"
- **Rich specification creation** using guardrails and organizational principles
- **Multi-step refinement** rather than one-shot code generation from prompts
- **Heavy reliance** on advanced AI model capabilities for specification interpretation
## Development Phases
| Phase | Focus | Key Activities |
|-------|-------|----------------|
| **0-to-1 Development** ("Greenfield") | Generate from scratch | <ul><li>Start with high-level requirements</li><li>Generate specifications</li><li>Plan implementation steps</li><li>Build production-ready applications</li></ul> |
| **Creative Exploration** | Parallel implementations | <ul><li>Explore diverse solutions</li><li>Support multiple technology stacks & architectures</li><li>Experiment with UX patterns</li></ul> |
| **Iterative Enhancement** ("Brownfield") | Brownfield modernization | <ul><li>Add features iteratively</li><li>Modernize legacy systems</li><li>Adapt processes</li></ul> |
## Experimental Goals
Our research and experimentation focus on:
### Technology Independence
- Create applications using diverse technology stacks
- Validate the hypothesis that Spec-Driven Development is a process not tied to specific technologies, programming languages, or frameworks
### Enterprise Constraints
- Demonstrate mission-critical application development
- Incorporate organizational constraints (cloud providers, tech stacks, engineering practices)
- Support enterprise design systems and compliance requirements
### User-Centric Development
- Build applications for different user cohorts and preferences
- Support various development approaches (from vibe-coding to AI-native development)
### Creative & Iterative Processes
- Validate the concept of parallel implementation exploration
- Provide robust iterative feature development workflows
- Extend processes to handle upgrades and modernization tasks

View File

@@ -6,7 +6,9 @@
"*.md",
"toc.yml",
"community/*.md",
"reference/*.md"
"concepts/*.md",
"reference/*.md",
"install/*.md"
]
},
{
@@ -49,7 +51,8 @@
"fileMetadataFiles": [],
"template": [
"default",
"modern"
"modern",
"template"
],
"postProcessors": [],
"markdownEngineName": "markdig",
@@ -67,6 +70,11 @@
"repo": "https://github.com/github/spec-kit",
"branch": "main"
}
},
"fileMetadata": {
"_layout": {
"index.md": "landing"
}
}
}
}

View File

@@ -1,67 +1,152 @@
# Spec Kit
<div class="landing-hero">
*Build high-quality software faster.*
# GitHub Spec Kit
**An effort to allow organizations to focus on product scenarios rather than writing undifferentiated code with the help of Spec-Driven Development.**
**Define what to build before building it — with any AI coding agent.**
## What is Spec-Driven Development?
Spec Kit is a toolkit for [Spec-Driven Development](concepts/sdd.md) (SDD), a methodology that puts specifications at the center of AI-assisted software development. Instead of jumping straight to code, you describe *what* to build, refine it through structured phases, and let your AI coding agent implement it.
Spec-Driven Development **flips the script** on traditional software development. For decades, code has been king — specifications were just scaffolding we built and discarded once the "real work" of coding began. Spec-Driven Development changes this: **specifications become executable**, directly generating working implementations rather than just guiding them.
<a href="installation.md" class="btn btn-primary btn-lg">Install Spec Kit</a>&nbsp;
<a href="quickstart.md" class="btn btn-outline-primary btn-lg">Quick Start</a>
## Getting Started
</div>
- [Installation Guide](installation.md)
- [Quick Start Guide](quickstart.md)
- [Upgrade Guide](upgrade.md)
- [Local Development](local-development.md)
---
## Core Philosophy
<div class="pillar-grid">
Spec-Driven Development is a structured process that emphasizes:
<div class="pillar-card">
- **Intent-driven development** where specifications define the "*what*" before the "*how*"
- **Rich specification creation** using guardrails and organizational principles
- **Multi-step refinement** rather than one-shot code generation from prompts
- **Heavy reliance** on advanced AI model capabilities for specification interpretation
### Spec-driven by default
## Development Phases
The core SDD process ships ready to use: **Spec → Plan → Tasks → Implement**.
| Phase | Focus | Key Activities |
|-------|-------|----------------|
| **0-to-1 Development** ("Greenfield") | Generate from scratch | <ul><li>Start with high-level requirements</li><li>Generate specifications</li><li>Plan implementation steps</li><li>Build production-ready applications</li></ul> |
| **Creative Exploration** | Parallel implementations | <ul><li>Explore diverse solutions</li><li>Support multiple technology stacks & architectures</li><li>Experiment with UX patterns</li></ul> |
| **Iterative Enhancement** ("Brownfield") | Brownfield modernization | <ul><li>Add features iteratively</li><li>Modernize legacy systems</li><li>Adapt processes</li></ul> |
Define what to build before building it. Rich templates, quality checklists, and cross-artifact analysis come out of the box. Each phase produces a Markdown artifact that feeds the next — giving your AI coding agent structured context instead of ad-hoc prompts.
## Experimental Goals
<a href="quickstart.md" class="pillar-link">Walk through the workflow →</a>
Our research and experimentation focus on:
</div>
### Technology Independence
<div class="pillar-card">
- Create applications using diverse technology stacks
- Validate the hypothesis that Spec-Driven Development is a process not tied to specific technologies, programming languages, or frameworks
### Use any coding agent
### Enterprise Constraints
<span class="pillar-stat">30 integrations</span> — Copilot, Gemini, Codex, Windsurf, Claude, Forge, Kiro, and more. Switch freely between agents with a single command. No lock-in.
- Demonstrate mission-critical application development
- Incorporate organizational constraints (cloud providers, tech stacks, engineering practices)
- Support enterprise design systems and compliance requirements
Run `specify init` with your agent of choice and Spec Kit sets up the right command files, context rules, and directory structures automatically. If your agent isn't listed, the `generic` integration is an escape hatch for any tool.
### User-Centric Development
<a href="reference/integrations.md" class="pillar-link">See all integrations →</a>
- Build applications for different user cohorts and preferences
- Support various development approaches (from vibe-coding to AI-native development)
</div>
### Creative & Iterative Processes
<div class="pillar-card">
- Validate the concept of parallel implementation exploration
- Provide robust iterative feature development workflows
- Extend processes to handle upgrades and modernization tasks
### Make it your own
## Contributing
<span class="pillar-stat">91 community extensions</span> (50+ authors), <span class="pillar-stat">18 presets</span>, and growing. Tune the core process with presets, extend it with extensions, orchestrate it with workflows, or replace it entirely. Build and publish your own.
Please see our [Contributing Guide](https://github.com/github/spec-kit/blob/main/CONTRIBUTING.md) for information on how to contribute to this project.
Including entirely different SDD processes:
## Support
- **AIDE** — 7-step AI-driven engineering lifecycle
- **Canon** — baseline-driven workflows (spec-first, code-first, spec-drift)
- **Product Forge** — product-management-oriented SDD
- **FX→.NET** — end-to-end .NET Framework migration across 7 phases
- **MAQA** — multi-agent orchestration with quality assurance gates
For support, please check our [Support Guide](https://github.com/github/spec-kit/blob/main/SUPPORT.md) or open an issue on GitHub.
<a href="community/presets.md" class="pillar-link">Browse community presets →</a>
</div>
<div class="pillar-card">
### Integrate into your organization
Works offline, behind firewalls, and on **Windows, macOS, and Linux**. Host your own extension and preset catalogs so your organization controls what gets installed.
Community extensions like CI Guard and Architecture Guard add compliance gates and governance that fit the way your team already works.
<a href="installation.md" class="pillar-link">Installation guide →</a>&nbsp;&nbsp;
<a href="reference/extensions.md" class="pillar-link">Extensions reference →</a>
</div>
</div>
---
<div class="community-section">
## Built by the community
**200+ contributors** power the Spec Kit ecosystem — from core integrations to entirely new development processes. Anyone can create and publish an extension, preset, or workflow.
<div class="stats-grid">
<div class="stat-item">
<span class="stat-number">96K+</span>
<span class="stat-label">GitHub stars</span>
</div>
<div class="stat-item">
<span class="stat-number">200+</span>
<span class="stat-label">Contributors</span>
</div>
<div class="stat-item">
<span class="stat-number">30</span>
<span class="stat-label">Integrations</span>
</div>
<div class="stat-item">
<span class="stat-number">91</span>
<span class="stat-label">Extensions</span>
</div>
<div class="stat-item">
<span class="stat-number">18</span>
<span class="stat-label">Presets</span>
</div>
<div class="stat-item">
<span class="stat-number">4</span>
<span class="stat-label">Friends projects</span>
</div>
</div>
<a href="community/presets.md">Presets</a> · <a href="community/walkthroughs.md">Walkthroughs</a> · <a href="community/friends.md">Friends</a>
</div>
---
## Explore the docs
<div class="nav-cards">
<a href="quickstart.md" class="nav-card">
<strong>Getting Started</strong>
<span>Install, configure, and run your first SDD workflow</span>
</a>
<a href="reference/overview.md" class="nav-card">
<strong>Reference</strong>
<span>Core commands, integrations, extensions, presets, and workflows</span>
</a>
<a href="community/overview.md" class="nav-card">
<strong>Community</strong>
<span>Extensions, presets, walkthroughs, and friend projects</span>
</a>
<a href="local-development.md" class="nav-card">
<strong>Development</strong>
<span>Contribute to Spec Kit</span>
</a>
<a href="concepts/sdd.md" class="nav-card">
<strong>What is SDD?</strong>
<span>The philosophy behind Spec-Driven Development</span>
</a>
</div>
---
<div class="footer-cta">
```bash
uvx --from git+https://github.com/github/spec-kit.git
specify init my-project --integration copilot
```
Ready to start? Follow the [Quick Start Guide](quickstart.md).
</div>

View File

@@ -0,0 +1,59 @@
# Enterprise / Air-Gapped Installation
If your environment blocks access to PyPI or GitHub, you can create a portable wheel bundle on a connected machine and transfer it to the air-gapped target.
## Step 1: Build the wheel on a connected machine
> **Important:** `pip download` resolves platform-specific wheels (e.g., PyYAML includes native extensions). You must run this step on a machine with the **same OS and Python version** as the air-gapped target. If you need to support multiple platforms, repeat this step on each target OS (Linux, macOS, Windows) and Python version.
```bash
# Clone the repository
git clone https://github.com/github/spec-kit.git
cd spec-kit
# Build the wheel
pip install build
python -m build --wheel --outdir dist/
# Download the wheel and all its runtime dependencies
pip download -d dist/ dist/specify_cli-*.whl
```
## Step 2: Transfer the `dist/` directory
Copy the entire `dist/` directory (which contains the `specify-cli` wheel and all dependency wheels) to the target machine via USB, network share, or other approved transfer method.
## Step 3: Install on the air-gapped machine
```bash
pip install --no-index --find-links=./dist specify-cli
```
## Step 4: Initialize a project
No network access is required — bundled assets are used by default:
```bash
specify init my-project --integration copilot
```
> **Note:** Python 3.11+ is required.
> **Windows note:** Offline scaffolding requires PowerShell 7+ (`pwsh`), not Windows PowerShell 5.x (`powershell.exe`). Install from https://aka.ms/powershell.
## Git Credential Manager on Linux
If you're having issues with Git authentication on Linux, you can install Git Credential Manager:
```bash
#!/usr/bin/env bash
set -e
echo "Downloading Git Credential Manager v2.6.1..."
wget https://github.com/git-ecosystem/git-credential-manager/releases/download/v2.6.1/gcm-linux_amd64.2.6.1.deb
echo "Installing Git Credential Manager..."
sudo dpkg -i gcm-linux_amd64.2.6.1.deb
echo "Configuring Git to use GCM..."
git config --global credential.helper manager
echo "Cleaning up..."
rm gcm-linux_amd64.2.6.1.deb
```

32
docs/install/one-time.md Normal file
View File

@@ -0,0 +1,32 @@
# One-time Usage (uvx)
If you want to try Spec Kit without installing it permanently, use `uvx` to run it directly. This downloads the tool into a temporary environment that is discarded after the command finishes.
> [!NOTE]
> The commands below require **[uv](https://docs.astral.sh/uv/)**. If you see `command not found: uvx`, [install uv first](uv.md).
## Run Specify CLI
```bash
# Create a new project (latest from main)
uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME>
# Or target a specific release (replace vX.Y.Z with a tag from Releases)
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <PROJECT_NAME>
# Initialize in the current directory
uvx --from git+https://github.com/github/spec-kit.git specify init . --integration copilot
# Or use the --here flag
uvx --from git+https://github.com/github/spec-kit.git specify init --here --integration copilot
```
## When to use persistent installation instead
If you plan to use Spec Kit regularly, a persistent installation is recommended:
- Tool stays installed and available in PATH
- No re-download on every invocation
- Better tool management with `uv tool list`, `uv tool upgrade`, `uv tool uninstall`
See the main [Installation Guide](../installation.md) for persistent installation instructions.

37
docs/install/pipx.md Normal file
View File

@@ -0,0 +1,37 @@
# Installing with pipx
[pipx](https://pypa.github.io/pipx/) is a tool for installing Python CLI applications in isolated environments. It does not require [uv](https://docs.astral.sh/uv/).
## Install Specify CLI
Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest):
```bash
# Install a specific stable release (recommended — replace vX.Y.Z with the latest tag)
pipx install git+https://github.com/github/spec-kit.git@vX.Y.Z
# Or install latest from main (may include unreleased changes)
pipx install git+https://github.com/github/spec-kit.git
```
## Verify
```bash
specify version
```
## Upgrade
```bash
pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z
```
## Uninstall
```bash
pipx uninstall specify-cli
```
## Next steps
Head to the [Quick Start](../quickstart.md) to initialize your first project.

60
docs/install/uv.md Normal file
View File

@@ -0,0 +1,60 @@
# Installing uv
[uv](https://docs.astral.sh/uv/) is a fast Python package manager by [Astral](https://astral.sh/). Spec Kit uses `uv` (via `uvx` or `uv tool install`) to run the `specify` CLI without polluting your global Python environment.
> [!NOTE]
> **Already have uv?** Run `uv --version` to confirm it is installed, then head back to the [Installation Guide](../installation.md).
## Installation
### macOS and Linux — Standalone Installer
The quickest way to install uv on macOS or Linux is the official shell script:
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```
After the script finishes, follow any instructions printed by the installer to add uv to your `PATH`, then open a new terminal.
### Windows — Standalone Installer
Run the following in **Command Prompt or PowerShell**:
```powershell
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
```
After the script finishes, open a new terminal so the `uv` binary is on your `PATH`.
### macOS — Homebrew
```bash
brew install uv
```
### Windows — WinGet
```powershell
winget install --id=astral-sh.uv -e
```
### Windows — Scoop
```powershell
scoop install uv
```
## Verification
Confirm that uv is installed and on your `PATH`:
```bash
uv --version
```
You should see output similar to `uv 0.x.y (...)`.
## Further Reading
For advanced options (self-update, proxy settings, uninstall, etc.) see the official [uv installation docs](https://docs.astral.sh/uv/getting-started/installation/).

View File

@@ -10,45 +10,47 @@
## Installation
> **Important:** The only official, maintained packages for Spec Kit come from the [github/spec-kit](https://github.com/github/spec-kit) GitHub repository. Any packages with the same name available on PyPI (e.g. `specify-cli` on pypi.org) are **not** affiliated with this project and are not maintained by the Spec Kit maintainers. For normal installs, use the GitHub-based commands shown below. For offline or air-gapped environments, locally built wheels created from this repository are also valid.
> [!IMPORTANT]
> The only official, maintained packages for Spec Kit come from the [github/spec-kit](https://github.com/github/spec-kit) GitHub repository. Any packages with the same name available on PyPI (e.g. `specify-cli` on pypi.org) are **not** affiliated with this project and are not maintained by the Spec Kit maintainers. For normal installs, use the GitHub-based commands shown below. For offline or air-gapped environments, locally built wheels created from this repository are also valid.
### Initialize a New Project
### Persistent Installation (Recommended)
The easiest way to get started is to initialize a new project. Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest):
```bash
# Install from a specific stable release (recommended — replace vX.Y.Z with the latest tag)
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <PROJECT_NAME>
# Or install latest from main (may include unreleased changes)
uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME>
```
Install once and use everywhere. Replace `vX.Y.Z` with a tag from [Releases](https://github.com/github/spec-kit/releases):
> [!NOTE]
> For a persistent installation, `pipx` works equally well:
> ```bash
> pipx install git+https://github.com/github/spec-kit.git@vX.Y.Z
> ```
> The project uses a standard `hatchling` build backend and has no uv-specific dependencies.
Or initialize in the current directory:
> The command below requires **[uv](https://docs.astral.sh/uv/)**. If you see `command not found: uv`, [install uv first](./install/uv.md).
```bash
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init .
# or use the --here flag
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z
```
Then initialize a project:
```bash
specify init <PROJECT_NAME> --integration copilot
```
### One-time Usage
Run directly without installing — see the [One-time usage (uvx)](install/one-time.md) guide.
### Alternative Package Managers
- **pipx** — see the [pipx installation guide](install/pipx.md)
- **Enterprise / Air-Gapped** — see the [air-gapped installation guide](install/air-gapped.md)
### Specify Integration
Interactive terminals prompt you to choose a coding agent integration during initialization. Non-interactive sessions, such as CI or piped runs, default to GitHub Copilot unless you pass `--integration`.
You can proactively specify your coding agent integration during initialization:
```bash
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --integration claude
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --integration gemini
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --integration copilot
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --integration codebuddy
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --integration pi
specify init <project_name> --integration claude
specify init <project_name> --integration gemini
specify init <project_name> --integration copilot
specify init <project_name> --integration codebuddy
specify init <project_name> --integration pi
```
### Specify Script Type (Shell vs PowerShell)
@@ -64,8 +66,8 @@ Auto behavior:
Force a specific script type:
```bash
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --script sh
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --script ps
specify init <project_name> --script sh
specify init <project_name> --script ps
```
### Ignore Agent Tools Check
@@ -73,7 +75,7 @@ uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <proje
If you prefer to get the templates without checking for the right tools:
```bash
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --integration claude --ignore-agent-tools
specify init <project_name> --integration claude --ignore-agent-tools
```
## Verification
@@ -92,67 +94,17 @@ After initialization, you should see the following commands available in your co
- `/speckit.plan` - Generate implementation plans
- `/speckit.tasks` - Break down into actionable tasks
The `.specify/scripts` directory will contain both `.sh` and `.ps1` scripts.
Scripts are installed into a variant subdirectory matching the chosen script type:
- `.specify/scripts/bash/` — contains `.sh` scripts (default on Linux/macOS)
- `.specify/scripts/powershell/` — contains `.ps1` scripts (default on Windows)
## Troubleshooting
### Enterprise / Air-Gapped Installation
If your environment blocks access to PyPI (you see 403 errors when running `uv tool install` or `pip install`), you can create a portable wheel bundle on a connected machine and transfer it to the air-gapped target.
**Step 1: Build the wheel on a connected machine (same OS and Python version as the target)**
```bash
# Clone the repository
git clone https://github.com/github/spec-kit.git
cd spec-kit
# Build the wheel
pip install build
python -m build --wheel --outdir dist/
# Download the wheel and all its runtime dependencies
pip download -d dist/ dist/specify_cli-*.whl
```
> **Important:** `pip download` resolves platform-specific wheels (e.g., PyYAML includes native extensions). You must run this step on a machine with the **same OS and Python version** as the air-gapped target. If you need to support multiple platforms, repeat this step on each target OS (Linux, macOS, Windows) and Python version.
**Step 2: Transfer the `dist/` directory to the air-gapped machine**
Copy the entire `dist/` directory (which contains the `specify-cli` wheel and all dependency wheels) to the target machine via USB, network share, or other approved transfer method.
**Step 3: Install on the air-gapped machine**
```bash
pip install --no-index --find-links=./dist specify-cli
```
**Step 4: Initialize a project (no network required)**
```bash
# Initialize a project — no GitHub access needed
specify init my-project --integration claude
```
Bundled assets are used by default — no network access is required.
> **Note:** Python 3.11+ is required.
> **Windows note:** Offline scaffolding requires PowerShell 7+ (`pwsh`), not Windows PowerShell 5.x (`powershell.exe`). Install from https://aka.ms/powershell.
If your environment blocks access to PyPI or GitHub, see the [Enterprise / Air-Gapped Installation](install/air-gapped.md) guide for step-by-step instructions on creating portable wheel bundles.
### Git Credential Manager on Linux
If you're having issues with Git authentication on Linux, you can install Git Credential Manager:
```bash
#!/usr/bin/env bash
set -e
echo "Downloading Git Credential Manager v2.6.1..."
wget https://github.com/git-ecosystem/git-credential-manager/releases/download/v2.6.1/gcm-linux_amd64.2.6.1.deb
echo "Installing Git Credential Manager..."
sudo dpkg -i gcm-linux_amd64.2.6.1.deb
echo "Configuring Git to use GCM..."
git config --global credential.helper manager
echo "Cleaning up..."
rm gcm-linux_amd64.2.6.1.deb
```
If you're having issues with Git authentication on Linux, see the [Air-Gapped Installation guide](install/air-gapped.md#git-credential-manager-on-linux) for Git Credential Manager setup instructions.

View File

@@ -5,11 +5,19 @@ This guide will help you get started with Spec-Driven Development using Spec Kit
> [!NOTE]
> All automation scripts now provide both Bash (`.sh`) and PowerShell (`.ps1`) variants. The `specify` CLI auto-selects based on OS unless you pass `--script sh|ps`.
## The 6-Step Process
## Recommended Workflow
> [!TIP]
> **Context Awareness**: Spec Kit commands automatically detect the active feature based on your current Git branch (e.g., `001-feature-name`). To switch between different specifications, simply switch Git branches.
After installing Spec Kit and defining your project constitution, quick experiments can use the lean feature path: `/speckit.specify` -> `/speckit.plan` -> `/speckit.tasks` -> `/speckit.implement`. For production features or any work with meaningful ambiguity, treat `/speckit.clarify`, `/speckit.checklist`, and `/speckit.analyze` as regular quality gates:
```text
/speckit.constitution -> /speckit.specify -> /speckit.clarify -> /speckit.checklist -> /speckit.plan -> /speckit.tasks -> /speckit.analyze -> /speckit.implement
```
Use `/speckit.clarify` to reduce requirement ambiguity before planning, `/speckit.checklist` to validate requirements quality before planning, and `/speckit.analyze` to check spec/plan/task consistency before implementation starts. You can repeat `/speckit.analyze` after implementation as an extra review, but keep the first analysis before `/speckit.implement` so gaps are caught while the plan and tasks can still be adjusted.
### Step 1: Install Specify
**In your terminal**, run the `specify` CLI command to initialize your project:
@@ -24,10 +32,13 @@ uvx --from git+https://github.com/github/spec-kit.git specify init .
> [!NOTE]
> You can also install the CLI persistently with `pipx`:
>
> ```bash
> pipx install git+https://github.com/github/spec-kit.git
> ```
>
> After installing with `pipx`, run `specify` directly instead of `uvx --from ... specify`, for example:
>
> ```bash
> specify init <PROJECT_NAME>
> specify init .
@@ -56,7 +67,7 @@ uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME
/speckit.specify Build an application that can help me organize my photos in separate photo albums. Albums are grouped by date and can be re-organized by dragging and dropping on the main page. Albums are never in other nested albums. Within each album, photos are previewed in a tile-like interface.
```
### Step 4: Refine the Spec
### Step 4: Refine and Validate the Spec
**In the chat**, use the `/speckit.clarify` slash command to identify and resolve ambiguities in your specification. You can provide specific focus areas as arguments.
@@ -64,6 +75,12 @@ uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME
/speckit.clarify Focus on security and performance requirements.
```
Then validate the requirements with `/speckit.checklist` before creating the technical plan:
```bash
/speckit.checklist
```
### Step 5: Create a Technical Implementation Plan
**In the chat**, use the `/speckit.plan` slash command to provide your tech stack and architecture choices.
@@ -72,7 +89,7 @@ uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME
/speckit.plan The application uses Vite with minimal number of libraries. Use vanilla HTML, CSS, and JavaScript as much as possible. Images are not uploaded anywhere and metadata is stored in a local SQLite database.
```
### Step 6: Break Down and Implement
### Step 6: Break Down, Analyze, and Implement
**In the chat**, use the `/speckit.tasks` slash command to create an actionable task list.
@@ -80,13 +97,13 @@ uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME
/speckit.tasks
```
Optionally, validate the plan with `/speckit.analyze`:
Validate cross-artifact consistency with `/speckit.analyze` before implementation:
```markdown
/speckit.analyze
```
Then, use the `/speckit.implement` slash command to execute the plan.
Use the `/speckit.implement` slash command to execute the plan.
```markdown
/speckit.implement
@@ -159,7 +176,7 @@ Generate an actionable task list using the `/speckit.tasks` command:
### Step 7: Validate and Implement
Have your coding agent audit the implementation plan using `/speckit.analyze`:
Have your coding agent audit the spec, plan, and tasks with `/speckit.analyze` before implementation:
```bash
/speckit.analyze
@@ -179,7 +196,7 @@ Finally, implement the solution:
- **Be explicit** about what you're building and why
- **Don't focus on tech stack** during specification phase
- **Iterate and refine** your specifications before implementation
- **Validate** the plan before coding begins
- **Validate** requirements and plans before coding begins
- **Let the coding agent handle** the implementation details
## Next Steps

View File

@@ -0,0 +1,181 @@
# Authentication
Specify CLI uses **opt-in authentication** for HTTP requests to catalog
sources, extension downloads, and release checks. No credentials are
sent unless you explicitly configure them.
## Configuration
Create `~/.specify/auth.json` to enable authentication:
```json
{
"providers": [
{
"hosts": ["github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"],
"provider": "github",
"auth": "bearer",
"token_env": "GH_TOKEN"
}
]
}
```
> **Security:** Restrict the file to owner-only access:
> ```bash
> chmod 600 ~/.specify/auth.json
> ```
Without this file, all HTTP requests are unauthenticated.
## Fields
Each entry in the `providers` array has the following fields:
| Field | Required | Description |
|---|---|---|
| `hosts` | Yes | Array of hostnames this entry applies to. Supports exact hostnames, or a leading `*.` wildcard for subdomains only (for example, `*.visualstudio.com`). `*.visualstudio.com` matches `foo.visualstudio.com`, but not `visualstudio.com`. Other glob patterns such as `*github.com` or `gith?b.com` are not supported. |
| `provider` | Yes | Built-in provider key: `github` or `azure-devops`. |
| `auth` | Yes | Auth scheme (see below). |
| `token` | No | Token value (inline). Use `token_env` instead when possible. |
| `token_env` | No | Environment variable name to read the token from. |
For `azure-ad` auth, additional fields are required:
| Field | Required | Description |
|---|---|---|
| `tenant_id` | Yes | Azure AD tenant ID. |
| `client_id` | Yes | Service principal client ID. |
| `client_secret_env` | Yes | Environment variable containing the client secret. |
Either `token` or `token_env` must be set for `bearer` and `basic-pat` schemes.
## Providers and auth schemes
### GitHub (`github`)
| Scheme | Header | Use for |
|---|---|---|
| `bearer` | `Authorization: Bearer <token>` | PATs, fine-grained PATs, OAuth tokens, GitHub App tokens |
**Example — PAT via environment variable:**
```json
{
"hosts": ["github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"],
"provider": "github",
"auth": "bearer",
"token_env": "GH_TOKEN"
}
```
### Azure DevOps (`azure-devops`)
| Scheme | Header | Use for |
|---|---|---|
| `basic-pat` | `Authorization: Basic base64(:<PAT>)` | Personal Access Tokens |
| `bearer` | `Authorization: Bearer <token>` | Pre-acquired OAuth / Azure AD tokens |
| `azure-cli` | `Authorization: Bearer <token>` | Token acquired via `az account get-access-token` |
| `azure-ad` | `Authorization: Bearer <token>` | Token acquired via OAuth2 client credentials flow |
**Example — PAT via environment variable:**
```json
{
"hosts": ["dev.azure.com"],
"provider": "azure-devops",
"auth": "basic-pat",
"token_env": "AZURE_DEVOPS_PAT"
}
```
**Example — Azure CLI (interactive login):**
```json
{
"hosts": ["dev.azure.com"],
"provider": "azure-devops",
"auth": "azure-cli"
}
```
Requires `az login` to have been run beforehand.
**Example — Azure AD service principal (CI/automation):**
```json
{
"hosts": ["dev.azure.com"],
"provider": "azure-devops",
"auth": "azure-ad",
"tenant_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"client_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"client_secret_env": "AZURE_CLIENT_SECRET"
}
```
## Multiple entries
You can configure multiple entries for different hosts or organizations:
```json
{
"providers": [
{
"hosts": ["github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"],
"provider": "github",
"auth": "bearer",
"token_env": "GH_TOKEN"
},
{
"hosts": ["dev.azure.com"],
"provider": "azure-devops",
"auth": "basic-pat",
"token_env": "AZURE_DEVOPS_PAT"
}
]
}
```
## How it works
1. For each outbound HTTP request, the URL hostname is matched against
the `hosts` patterns in `auth.json`.
2. If a match is found, the corresponding provider resolves the token
and attaches the appropriate `Authorization` header.
3. If the request receives a 401 or 403, the next matching entry is tried.
4. After all matching entries are exhausted, an unauthenticated request
is attempted as a final fallback.
5. On redirects, the `Authorization` header is stripped if the redirect
target leaves the entry's declared hosts — preventing credential
leakage to CDNs or third-party services.
## Template
A reference `auth.json` with GitHub pre-configured:
```json
{
"providers": [
{
"hosts": [
"github.com",
"api.github.com",
"raw.githubusercontent.com",
"codeload.github.com"
],
"provider": "github",
"auth": "bearer",
"token_env": "GH_TOKEN"
}
]
}
```
To use it:
```bash
mkdir -p ~/.specify
# Copy the JSON above into ~/.specify/auth.json
chmod 600 ~/.specify/auth.json
```

View File

@@ -28,6 +28,8 @@ Creates a new Spec Kit project with the necessary directory structure, templates
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.
When `--integration` is omitted, interactive terminals prompt you to choose an integration. Non-interactive sessions, such as CI or piped runs, default to GitHub Copilot; pass `--integration <key>` to choose a different integration explicitly.
### Examples
```bash
@@ -67,6 +69,8 @@ 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.
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.
## Version Information
```bash
@@ -75,6 +79,16 @@ specify version
Displays the Spec Kit CLI version, Python version, platform, and architecture.
To inspect local CLI capabilities without checking the network:
```bash
specify version --features
specify version --features --json
```
The JSON form is intended for scripts and coding agents that need to choose a
workflow based on the installed CLI's supported features.
A quick version check is also available via:
```bash

View File

@@ -23,7 +23,8 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Junie](https://junie.jetbrains.com/) | `junie` | |
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | |
| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; supports `--migrate-legacy` for dotted→hyphenated directory migration |
| [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Alias: `--integration kiro` |
| [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Kiro CLI does not substitute `$ARGUMENTS` in file-based prompts, so Spec Kit ships a prose fallback at render time (see [Manage prompts](https://kiro.dev/docs/cli/chat/manage-prompts/) and issue [#1926](https://github.com/github/spec-kit/issues/1926)). Alias: `--integration kiro` |
| [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically |
| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | |
| [opencode](https://opencode.ai/) | `opencode` | |
| [Pi Coding Agent](https://pi.dev) | `pi` | Pi doesn't have MCP support out of the box, so `taskstoissues` won't work as intended. MCP support can be added via [extensions](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent#extensions) |
@@ -64,6 +65,8 @@ Installing an additional integration does not change the default integration. Us
> **Note:** All integration management commands require a project already initialized with `specify init`. To start a new project with a specific agent, use `specify init <project> --integration <key>` instead.
**Version note:** Controlled multi-install support was introduced in Spec Kit 0.8.5. If `specify integration install <key>` says another integration is already installed and only suggests `switch` or `uninstall`, check your local CLI with `specify version` and upgrade it. Running a one-shot command such as `uvx --from git+https://github.com/github/spec-kit.git specify ...` uses a temporary copy for that command only; it does not update the persistent `specify` executable on your `PATH`.
## Uninstall an Integration
```bash

264
docs/template/public/main.css vendored Normal file
View File

@@ -0,0 +1,264 @@
/* Spec Kit landing page — GitHub Primer colors */
:root {
/* GitHub Primer palette */
--gh-blue: #0969da;
--gh-green: #1a7f37;
--gh-purple: #8250df;
--gh-coral: #cf222e;
--gh-orange: #bf8700;
--gh-blue-subtle: #ddf4ff;
--gh-green-subtle: #dafbe1;
--gh-purple-subtle: #fbefff;
--gh-coral-subtle: #ffebe9;
}
[data-bs-theme="dark"] {
--gh-blue: #58a6ff;
--gh-green: #3fb950;
--gh-purple: #bc8cff;
--gh-coral: #f85149;
--gh-orange: #d29922;
--gh-blue-subtle: #0d1d30;
--gh-green-subtle: #0d1d14;
--gh-purple-subtle: #1c0d2e;
--gh-coral-subtle: #2d0f0d;
}
/* Override Bootstrap primary with GitHub blue */
body[data-layout="landing"] {
--bs-primary: var(--gh-blue);
--bs-primary-rgb: 9, 105, 218;
--bs-link-color: var(--gh-blue);
--bs-link-hover-color: var(--gh-blue);
}
[data-bs-theme="dark"] body[data-layout="landing"],
body[data-layout="landing"][data-bs-theme="dark"] {
--bs-primary-rgb: 88, 166, 255;
}
/* Hero section */
.landing-hero {
text-align: center;
padding: 3rem 0 1.5rem;
}
.landing-hero h1 {
font-size: 2.6rem;
font-weight: 800;
margin-bottom: 0.5rem;
background: linear-gradient(135deg, var(--gh-blue), var(--gh-purple));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.landing-hero p {
font-size: 1.15rem;
max-width: 640px;
margin: 0 auto 1.5rem;
opacity: 0.85;
}
.landing-hero .btn-primary {
background-color: var(--gh-blue);
border-color: var(--gh-blue);
color: #fff;
}
.landing-hero .btn-primary:hover {
background-color: #0860ca;
border-color: #0860ca;
}
.landing-hero .btn-outline-primary {
color: var(--gh-blue);
border-color: var(--gh-blue);
}
.landing-hero .btn-outline-primary:hover {
background-color: var(--gh-blue);
border-color: var(--gh-blue);
color: #fff;
}
/* Pillar cards grid */
.pillar-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
margin: 2rem 0;
}
@media (max-width: 768px) {
.pillar-grid {
grid-template-columns: 1fr;
}
}
.pillar-card {
border: 1px solid var(--bs-border-color);
border-radius: 0.5rem;
padding: 1.5rem;
background: var(--bs-body-bg);
transition: box-shadow 0.2s ease-in-out, border-color 0.2s ease-in-out;
border-top: 3px solid transparent;
}
/* Each pillar gets a distinct GitHub color accent */
.pillar-card:nth-child(1) { border-top-color: var(--gh-green); }
.pillar-card:nth-child(2) { border-top-color: var(--gh-blue); }
.pillar-card:nth-child(3) { border-top-color: var(--gh-purple); }
.pillar-card:nth-child(4) { border-top-color: var(--gh-coral); }
.pillar-card:nth-child(1):hover { box-shadow: 0 4px 16px rgba(26, 127, 55, 0.12); }
.pillar-card:nth-child(2):hover { box-shadow: 0 4px 16px rgba(9, 105, 218, 0.12); }
.pillar-card:nth-child(3):hover { box-shadow: 0 4px 16px rgba(130, 80, 223, 0.12); }
.pillar-card:nth-child(4):hover { box-shadow: 0 4px 16px rgba(207, 34, 46, 0.12); }
[data-bs-theme="dark"] .pillar-card:nth-child(1):hover { box-shadow: 0 4px 16px rgba(63, 185, 80, 0.15); }
[data-bs-theme="dark"] .pillar-card:nth-child(2):hover { box-shadow: 0 4px 16px rgba(88, 166, 255, 0.15); }
[data-bs-theme="dark"] .pillar-card:nth-child(3):hover { box-shadow: 0 4px 16px rgba(188, 140, 255, 0.15); }
[data-bs-theme="dark"] .pillar-card:nth-child(4):hover { box-shadow: 0 4px 16px rgba(248, 81, 73, 0.15); }
.pillar-card h3 {
font-size: 1.2rem;
font-weight: 600;
margin-bottom: 0.75rem;
}
/* Pillar headings pick up their card's accent color */
.pillar-card:nth-child(1) h3 { color: var(--gh-green); }
.pillar-card:nth-child(2) h3 { color: var(--gh-blue); }
.pillar-card:nth-child(3) h3 { color: var(--gh-purple); }
.pillar-card:nth-child(4) h3 { color: var(--gh-coral); }
.pillar-card .pillar-stat {
font-weight: 600;
color: var(--gh-blue);
}
.pillar-card:nth-child(3) .pillar-stat {
color: var(--gh-purple);
}
.pillar-card p:last-child {
margin-bottom: 0;
}
.pillar-card ul {
padding-left: 1.2rem;
margin-bottom: 0.5rem;
}
.pillar-card .pillar-link {
display: inline-block;
margin-top: 0.5rem;
font-size: 0.9rem;
font-weight: 500;
}
.pillar-card:nth-child(1) .pillar-link { color: var(--gh-blue); }
.pillar-card:nth-child(2) .pillar-link { color: var(--gh-green); }
.pillar-card:nth-child(3) .pillar-link { color: var(--gh-purple); }
.pillar-card:nth-child(4) .pillar-link { color: var(--gh-coral); }
/* Community stats section */
.community-section {
text-align: center;
padding: 2rem 0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin: 1.5rem auto;
max-width: 700px;
}
@media (max-width: 576px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.stat-item {
padding: 1rem;
}
.stat-item .stat-number {
display: block;
font-size: 1.8rem;
font-weight: 700;
color: var(--gh-blue);
line-height: 1.2;
}
.stat-item .stat-label {
display: block;
font-size: 0.85rem;
opacity: 0.75;
margin-top: 0.25rem;
}
/* Nav cards */
.nav-cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin: 1.5rem 0;
}
@media (max-width: 576px) {
.nav-cards {
grid-template-columns: 1fr;
}
}
.nav-card {
border: 1px solid var(--bs-border-color);
border-radius: 0.5rem;
padding: 1rem 1.25rem;
text-decoration: none;
color: inherit;
transition: box-shadow 0.2s ease-in-out, border-color 0.2s ease-in-out;
display: block;
border-left: 3px solid var(--gh-blue);
}
.nav-card:hover {
border-color: var(--gh-blue);
border-left-color: var(--gh-blue);
box-shadow: 0 2px 8px rgba(9, 105, 218, 0.1);
text-decoration: none;
color: inherit;
}
[data-bs-theme="dark"] .nav-card:hover {
box-shadow: 0 2px 8px rgba(88, 166, 255, 0.12);
}
.nav-card strong {
display: block;
margin-bottom: 0.25rem;
color: var(--gh-blue);
}
.nav-card span {
font-size: 0.9rem;
opacity: 0.75;
}
/* Footer CTA */
.footer-cta {
text-align: center;
padding: 2rem 0 1rem;
}
.footer-cta code {
font-size: 1.05rem;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
}

View File

@@ -11,6 +11,14 @@
href: quickstart.md
- name: Upgrade
href: upgrade.md
- name: Install uv
href: install/uv.md
- name: Install with pipx
href: install/pipx.md
- name: One-time Usage (uvx)
href: install/one-time.md
- name: Enterprise / Air-Gapped
href: install/air-gapped.md
# Reference
- name: Reference
@@ -28,6 +36,12 @@
- name: Workflows
href: reference/workflows.md
# Concepts
- name: Concepts
items:
- name: What is SDD?
href: concepts/sdd.md
# Development workflows
- name: Development
items:
@@ -36,7 +50,12 @@
# Community
- name: Community
href: community/overview.md
items:
- name: Overview
href: community/overview.md
- name: Extensions
href: community/extensions.md
- name: Presets
href: community/presets.md
- name: Walkthroughs

View File

@@ -19,6 +19,12 @@
The CLI tool (`specify`) is separate from your project files. Upgrade it to get the latest features and bug fixes.
Before upgrading, you can check whether a newer released version is available:
```bash
specify self check
```
### If you installed with `uv tool install`
Upgrade to a specific release (check [Releases](https://github.com/github/spec-kit/releases) for the latest tag):
@@ -35,6 +41,8 @@ Specify the desired release tag:
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --integration copilot
```
`uvx` runs a temporary copy of Spec Kit for that single command. It does not update a persistent `specify` installed with `uv tool install`, `pipx`, or another tool manager. If a newer feature works through `uvx` but your local `specify` still reports an older version, upgrade the persistent CLI with the command that matches your install method.
### If you installed with `pipx`
Upgrade to a specific release:
@@ -49,7 +57,7 @@ pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z
specify check
```
This shows installed tools and confirms the CLI is working.
This shows installed tools and confirms the CLI is working. Use `specify version` to confirm which persistent CLI version is currently on your `PATH`.
---
@@ -380,6 +388,14 @@ Only Spec Kit infrastructure files:
### "CLI upgrade doesn't seem to work"
If a command behaves like an older Spec Kit version, first check for local CLI drift:
```bash
specify self check
```
`specify check` is an offline environment scan; `specify self check` is the CLI version lookup.
Verify the installation:
```bash

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-05-05T07:26:00Z",
"updated_at": "2026-05-21T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -68,6 +68,143 @@
"created_at": "2026-03-31T00:00:00Z",
"updated_at": "2026-03-31T00:00:00Z"
},
"agent-governance": {
"name": "Agent Governance",
"id": "agent-governance",
"description": "Generate agent-platform repository governance files from Spec Kit metadata.",
"author": "bigben",
"version": "1.2.0",
"download_url": "https://github.com/bigsmartben/spec-kit-agent-governance/archive/refs/tags/v1.2.0.zip",
"repository": "https://github.com/bigsmartben/spec-kit-agent-governance",
"homepage": "https://github.com/bigsmartben/spec-kit-agent-governance",
"documentation": "https://github.com/bigsmartben/spec-kit-agent-governance/blob/main/README.md",
"changelog": "https://github.com/bigsmartben/spec-kit-agent-governance/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.0",
"tools": [
{
"name": "uv",
"required": true
}
]
},
"provides": {
"commands": 1,
"hooks": 3
},
"tags": [
"governance",
"agents",
"memory",
"context"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-05-14T00:00:00Z",
"updated_at": "2026-05-21T00:00:00Z"
},
"agent-orchestrator": {
"name": "Intelligent Agent Orchestrator",
"id": "agent-orchestrator",
"description": "Cross-catalog agent discovery and intelligent prompt-to-command routing",
"author": "pragya247",
"version": "0.1.0",
"download_url": "https://github.com/pragya247/spec-kit-orchestrator/archive/refs/tags/v0.1.0.zip",
"repository": "https://github.com/pragya247/spec-kit-orchestrator",
"homepage": "https://github.com/pragya247/spec-kit-orchestrator",
"documentation": "https://github.com/pragya247/spec-kit-orchestrator/blob/main/README.md",
"changelog": "https://github.com/pragya247/spec-kit-orchestrator/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.6.1"
},
"provides": {
"commands": 3,
"hooks": 1
},
"tags": [
"orchestrator",
"routing",
"discovery",
"agent",
"ai"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-05-04T00:00:00Z",
"updated_at": "2026-05-04T00:00:00Z"
},
"api-evolve": {
"name": "API Evolve",
"id": "api-evolve",
"description": "Managed API contract evolution — breaking-change detection, semver enforcement, deprecation orchestration, and lifecycle gates across REST, GraphQL, and gRPC.",
"author": "Quratulain-bilal",
"version": "1.0.0",
"download_url": "https://github.com/Quratulain-bilal/spec-kit-api-evolve/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/Quratulain-bilal/spec-kit-api-evolve",
"homepage": "https://github.com/Quratulain-bilal/spec-kit-api-evolve",
"documentation": "https://github.com/Quratulain-bilal/spec-kit-api-evolve/blob/main/README.md",
"changelog": "https://github.com/Quratulain-bilal/spec-kit-api-evolve/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.4.0"
},
"provides": {
"commands": 12,
"hooks": 5
},
"tags": [
"api",
"contracts",
"versioning",
"openapi",
"graphql",
"grpc",
"deprecation",
"breaking-changes",
"semver",
"governance"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-05-07T00:00:00Z",
"updated_at": "2026-05-07T00:00:00Z"
},
"arch": {
"name": "Architecture Workflow",
"id": "arch",
"description": "Generate or reverse project-level 4+1 architecture view artifacts and synthesis",
"author": "bigsmartben",
"version": "1.1.0",
"download_url": "https://github.com/bigsmartben/spec-kit-arch/archive/refs/tags/v1.1.0.zip",
"repository": "https://github.com/bigsmartben/spec-kit-arch",
"homepage": "https://github.com/bigsmartben/spec-kit-arch",
"documentation": "https://github.com/bigsmartben/spec-kit-arch/blob/main/README.md",
"changelog": "https://github.com/bigsmartben/spec-kit-arch/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.10.dev0"
},
"provides": {
"commands": 2,
"hooks": 0
},
"tags": [
"architecture",
"4plus1",
"workflow",
"design"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-05-14T00:00:00Z",
"updated_at": "2026-05-15T00:00:00Z"
},
"architect-preview": {
"name": "Architect Impact Previewer",
"id": "architect-preview",
@@ -105,8 +242,8 @@
"id": "architecture-guard",
"description": "Continuous architecture governance for AI-assisted development. Reviews specs, plans, and code for architecture drift, producing structured refactor tasks and evolution proposals.",
"author": "DyanGalih",
"version": "1.4.0",
"download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.4.0.zip",
"version": "1.8.4",
"download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.8.4.zip",
"repository": "https://github.com/DyanGalih/spec-kit-architecture-guard",
"homepage": "https://github.com/DyanGalih/spec-kit-architecture-guard",
"documentation": "https://github.com/DyanGalih/spec-kit-architecture-guard/blob/main/README.md",
@@ -116,8 +253,8 @@
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 6,
"hooks": 0
"commands": 10,
"hooks": 3
},
"tags": [
"architecture",
@@ -131,7 +268,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-05-05T07:26:00Z",
"updated_at": "2026-05-05T07:26:00Z"
"updated_at": "2026-05-11T14:58:00Z"
},
"archive": {
"name": "Archive Extension",
@@ -299,6 +436,38 @@
"created_at": "2026-04-10T00:00:00Z",
"updated_at": "2026-04-10T00:00:00Z"
},
"brownkit": {
"name": "BrownKit \u2014 Brownfield Discovery for Spec-Kit",
"id": "brownkit",
"description": "Evidence-driven capability discovery, security and QA risk assessment for existing codebases.",
"author": "Maksim Shautsou",
"version": "1.0.1",
"download_url": "https://github.com/MaksimShevtsov/BrownKit/archive/refs/tags/v1.0.1.zip",
"repository": "https://github.com/MaksimShevtsov/BrownKit",
"homepage": "https://github.com/MaksimShevtsov/BrownKit",
"documentation": "https://github.com/MaksimShevtsov/BrownKit/blob/main/README.md",
"changelog": "https://github.com/MaksimShevtsov/BrownKit/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 10,
"hooks": 5
},
"tags": [
"brownfield",
"discovery",
"security",
"qa",
"capabilities"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-05-10T00:00:00Z",
"updated_at": "2026-05-10T00:00:00Z"
},
"bugfix": {
"name": "Bugfix Workflow",
"id": "bugfix",
@@ -398,6 +567,38 @@
"created_at": "2026-04-16T00:00:00Z",
"updated_at": "2026-04-16T00:00:00Z"
},
"changelog": {
"name": "Spec Changelog",
"id": "changelog",
"description": "Auto-generate changelogs and release notes from spec git history and requirement diffs.",
"author": "Quratulain-bilal",
"version": "1.0.0",
"download_url": "https://github.com/Quratulain-bilal/spec-kit-changelog/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/Quratulain-bilal/spec-kit-changelog",
"homepage": "https://github.com/Quratulain-bilal/spec-kit-changelog",
"documentation": "https://github.com/Quratulain-bilal/spec-kit-changelog/blob/main/README.md",
"changelog": "https://github.com/Quratulain-bilal/spec-kit-changelog/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.4.0"
},
"provides": {
"commands": 4,
"hooks": 1
},
"tags": [
"changelog",
"release-notes",
"documentation",
"git-history",
"notifications"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-11T18:00:00Z",
"updated_at": "2026-04-11T18:00:00Z"
},
"ci-guard": {
"name": "CI Guard",
"id": "ci-guard",
@@ -580,6 +781,38 @@
"created_at": "2026-03-29T00:00:00Z",
"updated_at": "2026-03-29T00:00:00Z"
},
"cost": {
"name": "Cost Tracker",
"id": "cost",
"description": "Track real LLM dollar cost across SDD workflows — per-feature budgets, per-integration comparison, and finance-ready exports.",
"author": "Quratulain-bilal",
"version": "1.0.0",
"download_url": "https://github.com/Quratulain-bilal/spec-kit-cost/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/Quratulain-bilal/spec-kit-cost",
"homepage": "https://github.com/Quratulain-bilal/spec-kit-cost",
"documentation": "https://github.com/Quratulain-bilal/spec-kit-cost/blob/main/README.md",
"changelog": "https://github.com/Quratulain-bilal/spec-kit-cost/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.0"
},
"provides": {
"commands": 5,
"hooks": 0
},
"tags": [
"cost",
"budget",
"tokens",
"visibility",
"finance"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-05-03T00:00:00Z",
"updated_at": "2026-05-05T00:00:00Z"
},
"diagram": {
"name": "Spec Diagram",
"id": "diagram",
@@ -810,6 +1043,44 @@
"created_at": "2026-03-06T00:00:00Z",
"updated_at": "2026-03-31T00:00:00Z"
},
"fx-to-dotnet": {
"name": ".NET Framework to Modern .NET Migration",
"id": "fx-to-dotnet",
"description": "Orchestrate end-to-end .NET Framework to modern .NET migration across 7 phases, with SDD lifecycle integration.",
"author": "RogerBestMsft",
"version": "0.8.0",
"download_url": "https://github.com/RogerBestMsft/spec-kit-FxToNet/releases/download/v0.8.0/fx-to-dotnet.zip",
"repository": "https://github.com/RogerBestMsft/spec-kit-FxToNet",
"homepage": "https://github.com/RogerBestMsft/spec-kit-FxToNet",
"documentation": "https://github.com/RogerBestMsft/spec-kit-FxToNet/blob/main/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0",
"tools": [
{
"name": "Microsoft.GitHubCopilot.Modernization.Mcp",
"required": true
}
]
},
"provides": {
"commands": 12,
"hooks": 5
},
"tags": [
"dotnet",
"migration",
"modernization",
"framework",
"aspnet",
"shared-artifact"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-05-06T00:00:00Z",
"updated_at": "2026-05-06T00:00:00Z"
},
"github-issues": {
"name": "GitHub Issues Integration 1",
"id": "github-issues",
@@ -1277,6 +1548,35 @@
"created_at": "2026-04-28T00:00:00Z",
"updated_at": "2026-04-28T00:00:00Z"
},
"mde": {
"name": "MDE",
"id": "mde",
"description": "A Spec Kit extension that exposes a minimal model-driven engineering workflow with setup, next, and status commands.",
"author": "AI-MDE",
"version": "0.5.1",
"download_url": "https://github.com/AI-MDE/spec-kit-mde/archive/refs/tags/v0.5.1.zip",
"repository": "https://github.com/AI-MDE/spec-kit-mde",
"homepage": "https://github.com/AI-MDE/spec-kit-mde",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 4,
"hooks": 1
},
"tags": [
"mde",
"model-driven-engineering",
"workflow",
"process"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-05-08T00:00:00Z",
"updated_at": "2026-05-08T00:00:00Z"
},
"memory-loader": {
"name": "Memory Loader",
"id": "memory-loader",
@@ -1313,8 +1613,8 @@
"id": "memory-md",
"description": "Spec Kit extension for repository-native Markdown memory that captures durable decisions, bugs, and project context",
"author": "DyanGalih",
"version": "0.7.5",
"download_url": "https://github.com/DyanGalih/spec-kit-memory-hub/archive/refs/tags/v0.7.5.zip",
"version": "0.8.5",
"download_url": "https://github.com/DyanGalih/spec-kit-memory-hub/archive/refs/tags/v0.8.5.zip",
"repository": "https://github.com/DyanGalih/spec-kit-memory-hub",
"homepage": "https://github.com/DyanGalih/spec-kit-memory-hub",
"documentation": "https://github.com/DyanGalih/spec-kit-memory-hub/blob/main/README.md",
@@ -1324,8 +1624,8 @@
"speckit_version": ">=0.2.0"
},
"provides": {
"commands": 6,
"hooks": 0
"commands": 7,
"hooks": 2
},
"tags": [
"memory",
@@ -1339,7 +1639,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-04-23T00:00:00Z",
"updated_at": "2026-05-03T00:00:00Z"
"updated_at": "2026-05-11T14:58:00Z"
},
"memorylint": {
"name": "MemoryLint",
@@ -1614,6 +1914,37 @@
"created_at": "2026-03-18T00:00:00Z",
"updated_at": "2026-03-18T00:00:00Z"
},
"preview": {
"name": "Interactive HTML Preview",
"id": "preview",
"description": "Generate self-contained interactive HTML prototypes from Spec Kit artifacts",
"author": "bigsmartben",
"version": "1.0.0",
"download_url": "https://github.com/bigsmartben/spec-kit-preview/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/bigsmartben/spec-kit-preview",
"homepage": "https://github.com/bigsmartben/spec-kit-preview",
"documentation": "https://github.com/bigsmartben/spec-kit-preview/blob/main/README.md",
"changelog": "https://github.com/bigsmartben/spec-kit-preview/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.10.dev0"
},
"provides": {
"commands": 1,
"hooks": 0
},
"tags": [
"preview",
"prototype",
"html",
"ux"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-05-15T00:00:00Z",
"updated_at": "2026-05-15T00:00:00Z"
},
"product-forge": {
"name": "Product Forge",
"id": "product-forge",
@@ -1849,6 +2180,44 @@
"created_at": "2026-03-23T13:30:00Z",
"updated_at": "2026-03-23T13:30:00Z"
},
"reqnroll-bdd": {
"name": "Reqnroll BDD",
"id": "reqnroll-bdd",
"description": "Adds Reqnroll BDD planning, Gherkin generation, traceability, safe task injection, handoff, and verification to Spec Kit.",
"author": "LoogaCY Studio",
"version": "1.0.0",
"download_url": "https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd",
"homepage": "https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd",
"documentation": "https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd#readme",
"changelog": "https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.0",
"tools": [
{
"name": "dotnet",
"required": false
}
]
},
"provides": {
"commands": 4,
"hooks": 1
},
"tags": [
"bdd",
"reqnroll",
"dotnet",
"gherkin",
"acceptance-testing"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-05-13T00:00:00Z",
"updated_at": "2026-05-13T00:00:00Z"
},
"retro": {
"name": "Retro Extension",
"id": "retro",
@@ -1977,6 +2346,38 @@
"created_at": "2026-04-20T00:00:00Z",
"updated_at": "2026-04-20T00:00:00Z"
},
"schedule": {
"name": "Spec Kit Schedule — CP-SAT Agent Orchestrator",
"id": "schedule",
"description": "Optimal multi-agent task scheduling via CP-SAT solver with DAG precedence, hallucination-aware caps, file-conflict avoidance, stochastic durations, replanning, and interactive HTML output",
"author": "Julio César Franco Ardila",
"version": "0.6.2",
"download_url": "https://github.com/jfranc38/spec-kit-schedule/archive/refs/tags/v0.6.2.zip",
"repository": "https://github.com/jfranc38/spec-kit-schedule",
"homepage": "https://github.com/jfranc38/spec-kit-schedule",
"documentation": "https://github.com/jfranc38/spec-kit-schedule/blob/main/README.md",
"changelog": "https://github.com/jfranc38/spec-kit-schedule/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.4.0"
},
"provides": {
"commands": 5,
"hooks": 1
},
"tags": [
"scheduling",
"optimization",
"multi-agent",
"cp-sat",
"operations-research"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-05-06T22:35:00Z",
"updated_at": "2026-05-07T17:25:00Z"
},
"scope": {
"name": "Spec Scope",
"id": "scope",
@@ -2015,8 +2416,8 @@
"id": "security-review",
"description": "Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews",
"author": "DyanGalih",
"version": "1.4.2",
"download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.4.2.zip",
"version": "1.5.0",
"download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.5.0.zip",
"repository": "https://github.com/DyanGalih/spec-kit-security-review",
"homepage": "https://github.com/DyanGalih/spec-kit-security-review",
"documentation": "https://github.com/DyanGalih/spec-kit-security-review/blob/main/README.md",
@@ -2026,8 +2427,8 @@
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 7,
"hooks": 0
"commands": 9,
"hooks": 3
},
"tags": [
"security",
@@ -2040,7 +2441,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-04-03T03:24:03Z",
"updated_at": "2026-05-03T00:00:00Z"
"updated_at": "2026-05-11T14:58:00Z"
},
"sf": {
"name": "SFSpeckit — Salesforce Spec-Driven Development",
@@ -2211,6 +2612,55 @@
"created_at": "2026-04-30T00:00:00Z",
"updated_at": "2026-04-30T00:00:00Z"
},
"speckit-superpowers-bridge": {
"name": "Superpowers Implementation Bridge",
"id": "speckit-superpowers-bridge",
"description": "Thin orchestrator between Spec Kit (design) and Superpowers (implementation). Cross-agent.",
"author": "lihan3238",
"version": "0.5.0",
"download_url": "https://github.com/lihan3238/speckit-superpowers-bridge/releases/download/v0.5.0/speckit-superpowers-bridge-v0.5.0.zip",
"repository": "https://github.com/lihan3238/speckit-superpowers-bridge",
"homepage": "https://github.com/lihan3238/speckit-superpowers-bridge",
"documentation": "https://github.com/lihan3238/speckit-superpowers-bridge#readme",
"changelog": "https://github.com/lihan3238/speckit-superpowers-bridge/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.10",
"tools": [
{
"name": "powershell",
"version": ">=5.1",
"required": false
},
{
"name": "bash",
"version": ">=4.0",
"required": false
},
{
"name": "jq",
"version": ">=1.6",
"required": false
}
]
},
"provides": {
"commands": 3,
"hooks": 5
},
"tags": [
"bridge",
"superpowers",
"cross-agent",
"tdd",
"workflow"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-05-15T00:00:00Z",
"updated_at": "2026-05-20T00:00:00Z"
},
"speckit-utils": {
"name": "SDD Utilities",
"id": "speckit-utils",
@@ -2279,21 +2729,21 @@
"squad": {
"name": "Squad Bridge",
"id": "squad",
"description": "Bootstrap and synchronize a Squad agent team from your Spec Kit spec and tasks.",
"description": "Bootstrap and synchronize a Squad agent team from your Speckit spec and tasks.",
"author": "jwill824",
"version": "1.1.0",
"download_url": "https://github.com/jwill824/spec-kit-squad/archive/refs/tags/v1.1.0.zip",
"version": "1.3.0",
"download_url": "https://github.com/jwill824/spec-kit-squad/archive/refs/tags/v1.3.0.zip",
"repository": "https://github.com/jwill824/spec-kit-squad",
"homepage": "https://github.com/jwill824/spec-kit-squad",
"documentation": "https://github.com/jwill824/spec-kit-squad/blob/main/README.md",
"changelog": "https://github.com/jwill824/spec-kit-squad/blob/main/docs/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0",
"speckit_version": ">=0.8.11",
"tools": [
{
"name": "@bradygaster/squad-cli",
"version": ">=0.1.0",
"version": ">=0.9.4",
"required": true
}
]
@@ -2313,7 +2763,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-04-29T00:00:00Z",
"updated_at": "2026-04-29T00:00:00Z"
"updated_at": "2026-05-20T00:00:00Z"
},
"staff-review": {
"name": "Staff Review Extension",
@@ -2515,6 +2965,74 @@
"created_at": "2026-03-02T00:00:00Z",
"updated_at": "2026-03-02T00:00:00Z"
},
"team-assign": {
"name": "Team Assign",
"id": "team-assign",
"description": "Assign tasks.md items to human engineers, split into subtasks, and generate a per-engineer workboard",
"author": "tarunkumarbhati",
"version": "1.0.0",
"download_url": "https://github.com/tarunkumarbhati/spec-kit-team-assign/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/tarunkumarbhati/spec-kit-team-assign",
"homepage": "https://github.com/tarunkumarbhati/spec-kit-team-assign",
"documentation": "https://github.com/tarunkumarbhati/spec-kit-team-assign/blob/main/README.md",
"changelog": "https://github.com/tarunkumarbhati/spec-kit-team-assign/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 3
},
"tags": [
"team",
"assignment",
"process",
"planning",
"subtasks"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-05-20T00:00:00Z",
"updated_at": "2026-05-20T00:00:00Z"
},
"time-machine": {
"name": "Time Machine",
"id": "time-machine",
"description": "Retroactively apply the full SDD workflow to existing codebases — analyse, spec, and ship feature-by-feature",
"author": "te3yo",
"version": "1.1.0",
"download_url": "https://github.com/teeyo/spec-kit-time-machine/archive/refs/tags/v1.1.0.zip",
"repository": "https://github.com/teeyo/spec-kit-time-machine",
"homepage": "https://github.com/teeyo/spec-kit-time-machine",
"documentation": "https://github.com/teeyo/spec-kit-time-machine",
"changelog": "https://github.com/teeyo/spec-kit-time-machine/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0",
"tools": [
{
"name": "git",
"required": true
}
]
},
"provides": {
"commands": 3,
"hooks": 1
},
"tags": [
"brownfield",
"automation",
"workflow",
"process"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-05-15T00:00:00Z",
"updated_at": "2026-05-15T00:00:00Z"
},
"tinyspec": {
"name": "TinySpec",
"id": "tinyspec",
@@ -2905,7 +3423,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-04-13T00:00:00Z",
"updated_at": "2026-04-13T00:00:00Z"
"updated_at": "2026-04-13T00:00:00Z"
}
}
}

View File

@@ -4,7 +4,7 @@ description: "Create a feature branch with sequential or timestamp numbering"
# Create Feature Branch
Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `/speckit.specify` workflow.
Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `__SPECKIT_COMMAND_SPECIFY__` workflow.
## User Input

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-04-28T00:00:00Z",
"updated_at": "2026-04-29T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json",
"integrations": {
"claude": {
@@ -210,6 +210,15 @@
"repository": "https://github.com/github/spec-kit",
"tags": ["cli", "skills"]
},
"lingma": {
"id": "lingma",
"name": "Lingma",
"version": "1.0.0",
"description": "Lingma IDE skills-based integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["ide", "skills"]
},
"pi": {
"id": "pi",
"name": "Pi Coding Agent",

View File

@@ -256,6 +256,43 @@
"created_at": "2026-04-09T08:00:00Z",
"updated_at": "2026-04-27T08:00:00Z"
},
"game-narrative-writing": {
"name": "Game Narrative Writing",
"id": "game-narrative-writing",
"version": "1.0.0",
"description": "Spec-Driven Development for interactive game-narrative pre-production in 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.",
"author": "Andreas Daumann",
"repository": "https://github.com/adaumann/speckit-preset-game-narrative-writing",
"download_url": "https://github.com/adaumann/speckit-preset-game-narrative-writing/archive/refs/tags/v1.0.0.zip",
"homepage": "https://github.com/adaumann/speckit-preset-game-narrative-writing",
"documentation": "https://github.com/adaumann/speckit-preset-game-narrative-writing/blob/main/game-narrative-writing/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.5.0"
},
"provides": {
"templates": 22,
"commands": 36,
"scripts": 2
},
"tags": [
"game-writing",
"interactive-fiction",
"twine",
"ink",
"renpy",
"point-and-click",
"branching-narrative",
"choice-if",
"visual-novel",
"mechanic-hooks",
"game-narrative",
"export",
"series"
],
"created_at": "2026-05-05T08:00:00Z",
"updated_at": "2026-05-05T08:00:00Z"
},
"isaqb-architecture-governance": {
"name": "iSAQB Architecture Governance",
"id": "isaqb-architecture-governance",
@@ -311,6 +348,37 @@
"created_at": "2026-04-15T00:00:00Z",
"updated_at": "2026-04-15T00:00:00Z"
},
"mde": {
"name": "Model Driven Engineering",
"id": "mde",
"version": "0.5.1",
"description": "Focuses on streamlined commands, app repository support, cross-spec support, and capability-aware project memory for model-driven engineering workflows.",
"author": "Ralph Hanna",
"repository": "https://github.com/AI-MDE/spec-kit-preset-mde",
"download_url": "https://github.com/AI-MDE/spec-kit-preset-mde/archive/refs/tags/v0.5.1.zip",
"homepage": "https://github.com/AI-MDE/spec-kit-preset-mde",
"documentation": "https://github.com/AI-MDE/spec-kit-preset-mde/blob/main/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0",
"extensions": [
"mde"
]
},
"provides": {
"templates": 6,
"commands": 11
},
"tags": [
"model-driven-engineering",
"software-lifecycle",
"business-analysis",
"business-application",
"multi-layered-architecture"
],
"created_at": "2026-05-08T00:00:00Z",
"updated_at": "2026-05-08T00:00:00Z"
},
"multi-repo-branching": {
"name": "Multi-Repo Branching",
"id": "multi-repo-branching",

View File

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

View File

@@ -350,7 +350,10 @@ if (-not $DryRun) {
if (-not (Test-Path -PathType Leaf $specFile)) {
$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
if ($template -and (Test-Path $template)) {
Copy-Item $template $specFile -Force
# Read the template content and write it to the spec file with UTF-8 encoding without BOM
$content = [System.IO.File]::ReadAllText($template)
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($specFile, $content, $utf8NoBom)
} else {
New-Item -ItemType File -Path $specFile -Force | Out-Null
}

View File

@@ -36,8 +36,10 @@ New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
# Copy plan template if it exists, otherwise note it or create empty file
$template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT
if ($template -and (Test-Path $template)) {
Copy-Item $template $paths.IMPL_PLAN -Force
Write-Output "Copied plan template to $($paths.IMPL_PLAN)"
# Read the template content and write it to the implementation plan file with UTF-8 encoding without BOM
$content = [System.IO.File]::ReadAllText($template)
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($paths.IMPL_PLAN, $content, $utf8NoBom)
} else {
Write-Warning "Plan template not found"
# Create a basic plan file if template doesn't exist

File diff suppressed because it is too large Load Diff

121
src/specify_cli/_assets.py Normal file
View File

@@ -0,0 +1,121 @@
"""Bundle path resolution and version lookup for specify_cli.
Stdlib-only; zero internal imports so it sits at the base of the dependency
graph without risk of circular imports.
"""
from __future__ import annotations
import importlib.metadata
import re
from pathlib import Path
def _locate_core_pack() -> Path | None:
"""Return the filesystem path to the bundled core_pack directory, or None.
Only present in wheel installs: hatchling's force-include copies
templates/, scripts/ etc. into specify_cli/core_pack/ at build time.
Source-checkout and editable installs do NOT have this directory.
Callers that need to work in both environments must check the repo-root
trees (templates/, scripts/) as a fallback when this returns None.
"""
# Wheel install: core_pack is a sibling directory of this file
candidate = Path(__file__).parent / "core_pack"
if candidate.is_dir():
return candidate
return None
def _repo_root() -> Path:
"""Return the source checkout root used for editable installs."""
return Path(__file__).parent.parent.parent
def _locate_bundled_extension(extension_id: str) -> Path | None:
"""Return the path to a bundled extension, or None.
Checks the wheel's core_pack first, then falls back to the
source-checkout ``extensions/<id>/`` directory.
"""
if not re.match(r'^[a-z0-9-]+$', extension_id):
return None
core = _locate_core_pack()
if core is not None:
candidate = core / "extensions" / extension_id
if (candidate / "extension.yml").is_file():
return candidate
# Source-checkout / editable install: look relative to repo root
candidate = _repo_root() / "extensions" / extension_id
if (candidate / "extension.yml").is_file():
return candidate
return None
def _locate_bundled_workflow(workflow_id: str) -> Path | None:
"""Return the path to a bundled workflow directory, or None.
Checks the wheel's core_pack first, then falls back to the
source-checkout ``workflows/<id>/`` directory.
"""
if not re.match(r'^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$', workflow_id):
return None
core = _locate_core_pack()
if core is not None:
candidate = core / "workflows" / workflow_id
if (candidate / "workflow.yml").is_file():
return candidate
# Source-checkout / editable install: look relative to repo root
candidate = _repo_root() / "workflows" / workflow_id
if (candidate / "workflow.yml").is_file():
return candidate
return None
def _locate_bundled_preset(preset_id: str) -> Path | None:
"""Return the path to a bundled preset, or None.
Checks the wheel's core_pack first, then falls back to the
source-checkout ``presets/<id>/`` directory.
"""
if not re.match(r'^[a-z0-9-]+$', preset_id):
return None
core = _locate_core_pack()
if core is not None:
candidate = core / "presets" / preset_id
if (candidate / "preset.yml").is_file():
return candidate
# Source-checkout / editable install: look relative to repo root
candidate = _repo_root() / "presets" / preset_id
if (candidate / "preset.yml").is_file():
return candidate
return None
def get_speckit_version() -> str:
"""Get current spec-kit version."""
try:
return importlib.metadata.version("specify-cli")
except Exception:
# Fallback: try reading from pyproject.toml
try:
import tomllib
pyproject_path = _repo_root() / "pyproject.toml"
if pyproject_path.exists():
with open(pyproject_path, "rb") as f:
data = tomllib.load(f)
return data.get("project", {}).get("version", "unknown")
except Exception:
# Intentionally ignore any errors while reading/parsing pyproject.toml.
# If this lookup fails for any reason, we fall back to returning "unknown" below.
pass
return "unknown"

245
src/specify_cli/_console.py Normal file
View File

@@ -0,0 +1,245 @@
"""Base Rich/Typer console layer for the specify CLI.
This module is the single source of Rich ``Console`` instances and Typer UI
helpers used throughout ``specify_cli``. Nothing in this file should import
from other ``specify_cli`` sub-modules; all dependencies must flow *into* this
layer, not out of it, to avoid circular imports.
"""
from __future__ import annotations
from collections.abc import Callable
import readchar
import typer
from rich.align import Align
from rich.console import Console
from rich.live import Live
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from rich.tree import Tree
from typer.core import TyperGroup
BANNER = """
███████╗██████╗ ███████╗ ██████╗██╗███████╗██╗ ██╗
██╔════╝██╔══██╗██╔════╝██╔════╝██║██╔════╝╚██╗ ██╔╝
███████╗██████╔╝█████╗ ██║ ██║█████╗ ╚████╔╝
╚════██║██╔═══╝ ██╔══╝ ██║ ██║██╔══╝ ╚██╔╝
███████║██║ ███████╗╚██████╗██║██║ ██║
╚══════╝╚═╝ ╚══════╝ ╚═════╝╚═╝╚═╝ ╚═╝
"""
TAGLINE = "GitHub Spec Kit - Spec-Driven Development Toolkit"
console = Console(highlight=False)
class StepTracker:
"""Track and render hierarchical steps without emojis, similar to Claude Code tree output.
Supports live auto-refresh via an attached refresh callback.
"""
def __init__(self, title: str):
self.title = title
self.steps = [] # list of dicts: {key, label, status, detail}
self.status_order = {"pending": 0, "running": 1, "done": 2, "error": 3, "skipped": 4}
self._refresh_cb: Callable[[], None] | None = None
def attach_refresh(self, cb: Callable[[], None]) -> None:
self._refresh_cb = cb
def add(self, key: str, label: str):
if key not in [s["key"] for s in self.steps]:
self.steps.append({"key": key, "label": label, "status": "pending", "detail": ""})
self._maybe_refresh()
def start(self, key: str, detail: str = ""):
self._update(key, status="running", detail=detail)
def complete(self, key: str, detail: str = ""):
self._update(key, status="done", detail=detail)
def error(self, key: str, detail: str = ""):
self._update(key, status="error", detail=detail)
def skip(self, key: str, detail: str = ""):
self._update(key, status="skipped", detail=detail)
def _update(self, key: str, status: str, detail: str):
for s in self.steps:
if s["key"] == key:
s["status"] = status
if detail:
s["detail"] = detail
self._maybe_refresh()
return
self.steps.append({"key": key, "label": key, "status": status, "detail": detail})
self._maybe_refresh()
def _maybe_refresh(self):
if self._refresh_cb:
try:
self._refresh_cb()
except Exception:
pass
def render(self):
tree = Tree(f"[cyan]{self.title}[/cyan]", guide_style="grey50")
for step in self.steps:
label = step["label"]
detail_text = step["detail"].strip() if step["detail"] else ""
status = step["status"]
if status == "done":
symbol = "[green]●[/green]"
elif status == "pending":
symbol = "[green dim]○[/green dim]"
elif status == "running":
symbol = "[cyan]○[/cyan]"
elif status == "error":
symbol = "[red]●[/red]"
elif status == "skipped":
symbol = "[yellow]○[/yellow]"
else:
symbol = " "
if status == "pending":
# Entire line light gray (pending)
if detail_text:
line = f"{symbol} [bright_black]{label} ({detail_text})[/bright_black]"
else:
line = f"{symbol} [bright_black]{label}[/bright_black]"
else:
# Label white, detail (if any) light gray in parentheses
if detail_text:
line = f"{symbol} [white]{label}[/white] [bright_black]({detail_text})[/bright_black]"
else:
line = f"{symbol} [white]{label}[/white]"
tree.add(line)
return tree
def get_key():
"""Get a single keypress in a cross-platform way using readchar."""
key = readchar.readkey()
if key == readchar.key.UP or key == readchar.key.CTRL_P:
return 'up'
if key == readchar.key.DOWN or key == readchar.key.CTRL_N:
return 'down'
if key == readchar.key.ENTER:
return 'enter'
if key == readchar.key.ESC:
return 'escape'
if key == readchar.key.CTRL_C:
raise KeyboardInterrupt
return key
def select_with_arrows(
options: dict[str, str],
prompt_text: str = "Select an option",
default_key: str | None = None,
) -> str:
"""
Interactive selection using arrow keys with Rich Live display.
Args:
options: Dict with keys as option keys and values as descriptions
prompt_text: Text to show above the options
default_key: Default option key to start with
Returns:
Selected option key
"""
if not options:
raise ValueError("select_with_arrows() requires at least one option.")
option_keys = list(options.keys())
if default_key and default_key in option_keys:
selected_index = option_keys.index(default_key)
else:
selected_index = 0
selected_key = None
def create_selection_panel():
"""Create the selection panel with current selection highlighted."""
table = Table.grid(padding=(0, 2))
table.add_column(style="cyan", justify="left", width=3)
table.add_column(style="white", justify="left")
for i, key in enumerate(option_keys):
if i == selected_index:
table.add_row("", f"[cyan]{key}[/cyan] [dim]({options[key]})[/dim]")
else:
table.add_row(" ", f"[cyan]{key}[/cyan] [dim]({options[key]})[/dim]")
table.add_row("", "")
table.add_row("", "[dim]Use ↑/↓ to navigate, Enter to select, Esc to cancel[/dim]")
return Panel(
table,
title=f"[bold]{prompt_text}[/bold]",
border_style="cyan",
padding=(1, 2)
)
console.print()
def run_selection_loop():
nonlocal selected_key, selected_index
with Live(create_selection_panel(), console=console, transient=True, auto_refresh=False) as live:
while True:
try:
key = get_key()
if key == 'up':
selected_index = (selected_index - 1) % len(option_keys)
elif key == 'down':
selected_index = (selected_index + 1) % len(option_keys)
elif key == 'enter':
selected_key = option_keys[selected_index]
break
elif key == 'escape':
console.print("\n[yellow]Selection cancelled[/yellow]")
raise typer.Exit(code=1)
live.update(create_selection_panel(), refresh=True)
except KeyboardInterrupt:
console.print("\n[yellow]Selection cancelled[/yellow]")
raise typer.Exit(code=1)
run_selection_loop()
if selected_key is None:
console.print("\n[red]Selection failed.[/red]")
raise typer.Exit(code=1)
return selected_key
class BannerGroup(TyperGroup):
"""Custom group that shows banner before help."""
def format_help(self, ctx, formatter):
# Show banner before help
show_banner()
super().format_help(ctx, formatter)
def show_banner():
"""Display the ASCII art banner."""
banner_lines = BANNER.strip().split('\n')
colors = ["bright_blue", "blue", "cyan", "bright_cyan", "white", "bright_white"]
styled_banner = Text()
for i, line in enumerate(banner_lines):
color = colors[i % len(colors)]
styled_banner.append(line + "\n", style=color)
console.print(Align.center(styled_banner))
console.print(Align.center(Text(TAGLINE, style="italic bright_yellow")))
console.print()

282
src/specify_cli/_utils.py Normal file
View File

@@ -0,0 +1,282 @@
"""System utilities: subprocess, tool detection, file operations."""
from __future__ import annotations
import json
import json5
import os
import shutil
import stat
import subprocess
import tempfile
from pathlib import Path
from typing import Any
from ._console import console
CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude"
CLAUDE_NPM_LOCAL_PATH = Path.home() / ".claude" / "local" / "node_modules" / ".bin" / "claude"
def run_command(cmd: list[str], check_return: bool = True, capture: bool = False, shell: bool = False) -> str | None:
"""Run a shell command and optionally capture output."""
try:
if capture:
result = subprocess.run(cmd, check=check_return, capture_output=True, text=True, shell=shell)
return result.stdout.strip()
else:
subprocess.run(cmd, check=check_return, shell=shell)
return None
except subprocess.CalledProcessError as e:
if check_return:
console.print(f"[red]Error running command:[/red] {' '.join(cmd)}")
console.print(f"[red]Exit code:[/red] {e.returncode}")
if hasattr(e, 'stderr') and e.stderr:
console.print(f"[red]Error output:[/red] {e.stderr}")
raise
return None
def check_tool(tool: str, tracker=None) -> bool:
"""Check if a tool is installed. Optionally update tracker.
Args:
tool: Name of the tool to check
tracker: StepTracker | None to update with results
Returns:
True if tool is found, False otherwise
"""
# Special handling for Claude CLI local installs
# See: https://github.com/github/spec-kit/issues/123
# See: https://github.com/github/spec-kit/issues/550
# Claude Code can be installed in two local paths:
# 1. ~/.claude/local/claude (after `claude migrate-installer`)
# 2. ~/.claude/local/node_modules/.bin/claude (npm-local install, e.g. via nvm)
# Neither path may be on the system PATH, so we check them explicitly.
if tool == "claude":
if CLAUDE_LOCAL_PATH.is_file() or CLAUDE_NPM_LOCAL_PATH.is_file():
if tracker:
tracker.complete(tool, "available")
return True
if tool == "kiro-cli":
# Kiro currently supports both executable names. Prefer kiro-cli and
# accept kiro as a compatibility fallback.
found = shutil.which("kiro-cli") is not None or shutil.which("kiro") is not None
else:
found = shutil.which(tool) is not None
if tracker:
if found:
tracker.complete(tool, "available")
else:
tracker.error(tool, "not 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:
"""Handle merging or copying of .vscode/settings.json files.
Note: when merge produces changes, rewritten output is normalized JSON and
existing JSONC comments/trailing commas are not preserved.
"""
def log(message, color="green"):
if verbose and not tracker:
console.print(f"[{color}]{message}[/] {rel_path}")
def atomic_write_json(target_file: Path, payload: dict[str, Any]) -> None:
"""Atomically write JSON while preserving existing mode bits when possible."""
temp_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
mode='w',
encoding='utf-8',
dir=target_file.parent,
prefix=f"{target_file.name}.",
suffix=".tmp",
delete=False,
) as f:
temp_path = Path(f.name)
json.dump(payload, f, indent=4)
f.write('\n')
if target_file.exists():
try:
existing_stat = target_file.stat()
os.chmod(temp_path, stat.S_IMODE(existing_stat.st_mode))
if hasattr(os, "chown"):
try:
os.chown(temp_path, existing_stat.st_uid, existing_stat.st_gid)
except PermissionError:
# Best-effort owner/group preservation without requiring elevated privileges.
pass
except OSError:
# Best-effort metadata preservation; data safety is prioritized.
pass
os.replace(temp_path, target_file)
except Exception:
if temp_path and temp_path.exists():
temp_path.unlink()
raise
try:
with open(sub_item, 'r', encoding='utf-8') as f:
# json5 natively supports comments and trailing commas (JSONC)
new_settings = json5.load(f)
if dest_file.exists():
merged = merge_json_files(dest_file, new_settings, verbose=verbose and not tracker)
if merged is not None:
atomic_write_json(dest_file, merged)
log("Merged:", "green")
log("Note: comments/trailing commas are normalized when rewritten", "yellow")
else:
log("Skipped merge (preserved existing settings)", "yellow")
else:
shutil.copy2(sub_item, dest_file)
log("Copied (no existing settings.json):", "blue")
except Exception as e:
log(f"Warning: Could not merge settings: {e}", "yellow")
if not dest_file.exists():
shutil.copy2(sub_item, dest_file)
def merge_json_files(existing_path: Path, new_content: Any, verbose: bool = False) -> dict[str, Any] | None:
"""Merge new JSON content into existing JSON file.
Performs a polite deep merge where:
- New keys are added
- Existing keys are preserved (not overwritten) unless both values are dictionaries
- Nested dictionaries are merged recursively only when both sides are dictionaries
- Lists and other values are preserved from base if they exist
Args:
existing_path: Path to existing JSON file
new_content: New JSON content to merge in
verbose: Whether to print merge details
Returns:
Merged JSON content as dict, or None if the existing file should be left untouched.
"""
# Load existing content first to have a safe fallback
existing_content = None
exists = existing_path.exists()
if exists:
try:
with open(existing_path, 'r', encoding='utf-8') as f:
# Handle comments (JSONC) natively with json5
# Note: json5 handles BOM automatically
existing_content = json5.load(f)
except FileNotFoundError:
# Handle race condition where file is deleted after exists() check
exists = False
except Exception as e:
if verbose:
console.print(f"[yellow]Warning: Could not read or parse existing JSON in {existing_path.name} ({e}).[/yellow]")
# Skip merge to preserve existing file if unparseable or inaccessible (e.g. PermissionError)
return None
# Validate template content
if not isinstance(new_content, dict):
if verbose:
console.print(f"[yellow]Warning: Template content for {existing_path.name} is not a dictionary. Preserving existing settings.[/yellow]")
return None
if not exists:
return new_content
# If existing content parsed but is not a dict, skip merge to avoid data loss
if not isinstance(existing_content, dict):
if verbose:
console.print(f"[yellow]Warning: Existing JSON in {existing_path.name} is not an object. Skipping merge to avoid data loss.[/yellow]")
return None
def deep_merge_polite(base: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]:
"""Recursively merge update dict into base dict, preserving base values."""
result = base.copy()
for key, value in update.items():
if key not in result:
# Add new key
result[key] = value
elif isinstance(result[key], dict) and isinstance(value, dict):
# Recursively merge nested dictionaries
result[key] = deep_merge_polite(result[key], value)
else:
# Key already exists and values are not both dicts; preserve existing value.
# This ensures user settings aren't overwritten by template defaults.
pass
return result
merged = deep_merge_polite(existing_content, new_content)
# Detect if anything actually changed. If not, return None so the caller
# can skip rewriting the file (preserving user's comments/formatting).
if merged == existing_content:
return None
if verbose:
console.print(f"[cyan]Merged JSON file:[/cyan] {existing_path.name}")
return merged
def _display_project_path(project_root: Path, path: str | Path) -> str:
"""Return a stable POSIX-style display path for paths under a project."""
path_obj = Path(path)
try:
rel_path = path_obj.relative_to(project_root) if path_obj.is_absolute() else path_obj
except ValueError:
try:
rel_path = path_obj.resolve().relative_to(project_root.resolve())
except (OSError, ValueError):
return path_obj.as_posix()
return rel_path.as_posix()

173
src/specify_cli/_version.py Normal file
View File

@@ -0,0 +1,173 @@
"""Version checking and self-update commands for specify_cli.
Pure helpers for comparing PEP 440 versions and fetching the latest GitHub
release tag. The ``self_app`` Typer sub-command group is co-located here so
all version-related logic lives in one place.
Dependencies: stdlib + packaging + ._console only (no other internal imports
at module level, keeping this layer thin and circular-import-safe).
"""
from __future__ import annotations
import json
import urllib.error
import typer
from packaging.version import InvalidVersion, Version
from ._console import console
GITHUB_API_LATEST = "https://api.github.com/repos/github/spec-kit/releases/latest"
def _get_installed_version() -> str:
"""Return the installed specify-cli distribution version or 'unknown'.
Uses importlib.metadata so the value reflects what was actually installed
by pip/uv/pipx — not a value read from pyproject.toml. This is
intentional for `specify self check`, which should reason about the
installed distribution rather than a source-tree fallback. Callers must
treat the sentinel string 'unknown' as an indeterminate value (see FR-020).
"""
import importlib.metadata
metadata_errors = [importlib.metadata.PackageNotFoundError]
invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None)
if invalid_metadata_error is not None:
metadata_errors.append(invalid_metadata_error)
try:
return importlib.metadata.version("specify-cli")
except tuple(metadata_errors):
return "unknown"
def _normalize_tag(tag: str) -> str:
"""Strip exactly one leading 'v' from a release tag.
Returns the rest of the string unchanged. This handles the common
'vX.Y.Z' tag convention in this repo; it MUST NOT strip more
aggressively (e.g., two leading 'v's keeps one).
"""
return tag[1:] if tag.startswith("v") else tag
def _is_newer(latest: str, current: str) -> bool:
"""Return True iff `latest` is strictly greater than `current` under PEP 440.
Returns False whenever either side is 'unknown' or fails to parse; this
keeps the comparison indeterminate (rather than crashing or falsely
recommending a downgrade) on edge inputs.
"""
if latest == "unknown" or current == "unknown":
return False
try:
return Version(latest) > Version(current)
except InvalidVersion:
return False
def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
"""Return (tag, failure_category). Exactly one outbound call, 5 s timeout.
On success: (tag_name, None).
On a documented network/HTTP failure (added in T029/T030): (None, category).
On anything else — including a malformed response body — the exception
propagates; there is no catch-all (research D-006).
"""
from .authentication.http import open_url
try:
with open_url(
GITHUB_API_LATEST,
timeout=5,
extra_headers={"Accept": "application/vnd.github+json"},
) as resp:
payload = json.loads(resp.read().decode("utf-8"))
tag = payload.get("tag_name")
if not isinstance(tag, str) or not tag:
raise ValueError("GitHub API response missing valid tag_name")
return tag, None
except urllib.error.HTTPError as e:
# Order matters: HTTPError is a subclass of URLError.
if e.code == 403:
return None, (
"rate limited (configure ~/.specify/auth.json with a GitHub token)"
)
return None, f"HTTP {e.code}"
except (urllib.error.URLError, OSError):
return None, "offline or timeout"
# ===== Self Commands =====
self_app = typer.Typer(
name="self",
help="Manage the specify CLI itself (read-only check and reserved upgrade command).",
add_completion=False,
)
@self_app.command("check")
def self_check() -> None:
"""Check whether a newer specify-cli release is available. Read-only.
This command only checks for updates; it does not modify your installation.
The reserved (and currently non-destructive) `specify self upgrade` command
is the name that a future release will use for actual self-upgrade — its
behavior is not implemented in this release and is intentionally out of
scope here. See `specify self upgrade --help` for its current status.
"""
installed = _get_installed_version()
tag, failure_reason = _fetch_latest_release_tag()
if tag is None:
# Graceful-failure path (FR-008). `failure_reason` is one of the
# enumerated strings produced by _fetch_latest_release_tag() — it
# never contains a URL, headers, response body, or traceback.
assert failure_reason is not None
console.print(f"Installed: {installed}")
console.print(f"[yellow]Could not check latest release:[/yellow] {failure_reason}")
return
latest_normalized = _normalize_tag(tag)
if installed == "unknown":
# FR-020: surface the latest release and the recovery action even
# when the local distribution metadata is unavailable.
console.print("Current version could not be determined.")
console.print(f"Latest release: {latest_normalized}")
console.print("\nTo reinstall:")
console.print(" uv tool install specify-cli --force \\")
console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}")
return
if _is_newer(latest_normalized, installed):
console.print(f"[green]Update available:[/green] {installed}{latest_normalized}")
console.print("\nTo upgrade:")
console.print(" uv tool install specify-cli --force \\")
console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}")
return
# Installed is parseable AND is >= latest → "up to date" (FR-006).
# Also reached when the tag is unparseable (InvalidVersion) → _is_newer
# returns False, and the up-to-date branch is the safer default per
# FR-004 / test T016.
console.print(f"[green]Up to date:[/green] {installed}")
@self_app.command("upgrade")
def self_upgrade() -> None:
"""Reserved command surface for self-upgrade; not implemented in this release.
This command is a documented non-destructive stub in this release: it
performs no outbound network request, no install-method detection, and
invokes no installer. It prints a three-line guidance message and exits 0.
Actual self-upgrade is planned as follow-up work.
Use `specify self check` today to see whether a newer release is available
and to get a copy-pasteable reinstall command.
"""
console.print("specify self upgrade is not implemented yet.")
console.print("Run 'specify self check' to see whether a newer release is available.")
console.print("Actual self-upgrade is planned as follow-up work.")

View File

@@ -7,12 +7,12 @@ command files into agent-specific directories in the correct format.
"""
import os
from pathlib import Path
from typing import Dict, List, Any, Optional
import platform
import re
from copy import deepcopy
from pathlib import Path
from typing import Any, Dict, List, Optional
import yaml
@@ -25,7 +25,16 @@ def _build_agent_configs() -> dict[str, Any]:
if key == "generic":
continue
if integration.registrar_config:
configs[key] = dict(integration.registrar_config)
config = dict(integration.registrar_config)
# Propagate invoke_separator from the integration class when the
# registrar_config dict doesn't already declare it explicitly.
# SkillsIntegration subclasses (claude, codex, …) set
# invoke_separator="-" as a class attribute but omit it from
# registrar_config, so without this they would fall back to "."
# when register_commands() resolves __SPECKIT_COMMAND_*__ tokens.
if "invoke_separator" not in config:
config["invoke_separator"] = integration.invoke_separator
configs[key] = config
return configs
@@ -419,9 +428,7 @@ class CommandRegistrar:
normalized = Path(os.path.normpath(candidate))
base_normalized = Path(os.path.normpath(base))
if not normalized.is_relative_to(base_normalized):
raise ValueError(
f"Output path {candidate!r} escapes directory {base!r}"
)
raise ValueError(f"Output path {candidate!r} escapes directory {base!r}")
def register_commands(
self,
@@ -431,6 +438,7 @@ class CommandRegistrar:
source_dir: Path,
project_root: Path,
context_note: str = None,
_resolved_dir: Path = None,
) -> List[str]:
"""Register commands for a specific agent.
@@ -441,6 +449,10 @@ class CommandRegistrar:
source_dir: Directory containing command source files
project_root: Path to project root
context_note: Custom context comment for markdown output
_resolved_dir: Pre-resolved command directory (internal use
only — avoids a second ``_resolve_agent_dir`` call and
duplicate deprecation warnings when invoked from
``register_commands_for_all_agents``).
Returns:
List of registered command names
@@ -453,7 +465,9 @@ class CommandRegistrar:
raise ValueError(f"Unsupported agent: {agent_name}")
agent_config = self.AGENT_CONFIGS[agent_name]
commands_dir = project_root / agent_config["dir"]
commands_dir = _resolved_dir or self._resolve_agent_dir(
agent_name, agent_config, project_root,
)
commands_dir.mkdir(parents=True, exist_ok=True)
registered = []
@@ -471,7 +485,10 @@ class CommandRegistrar:
if frontmatter.get("strategy") == "wrap":
from .presets import _substitute_core_template
body, core_frontmatter = _substitute_core_template(body, cmd_name, project_root, self)
body, core_frontmatter = _substitute_core_template(
body, cmd_name, project_root, self
)
frontmatter = dict(frontmatter)
for key in ("scripts", "agent_scripts"):
if key not in frontmatter and key in core_frontmatter:
@@ -492,6 +509,16 @@ class CommandRegistrar:
body, "$ARGUMENTS", agent_config["args"]
)
# Resolve __SPECKIT_COMMAND_*__ tokens using the agent's invoke separator.
# The separator is sourced from agent_config (populated by _build_agent_configs,
# which propagates each integration's invoke_separator class attribute).
# Deferred import of IntegrationBase avoids a circular import at module load
# (base.py itself imports CommandRegistrar lazily).
from specify_cli.integrations.base import IntegrationBase # noqa: PLC0415
_sep = agent_config.get("invoke_separator", ".")
body = IntegrationBase.resolve_command_refs(body, _sep)
output_name = self._compute_output_name(agent_name, cmd_name, agent_config)
if agent_config["extension"] == "/SKILL.md":
@@ -505,12 +532,22 @@ class CommandRegistrar:
project_root,
)
elif agent_config["format"] == "markdown":
body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root)
body = self._convert_argument_placeholder(body, "$ARGUMENTS", agent_config["args"])
output = self.render_markdown_command(frontmatter, body, source_id, context_note)
body = self.resolve_skill_placeholders(
agent_name, frontmatter, body, project_root
)
body = self._convert_argument_placeholder(
body, "$ARGUMENTS", agent_config["args"]
)
output = self.render_markdown_command(
frontmatter, body, source_id, context_note
)
elif agent_config["format"] == "toml":
body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root)
body = self._convert_argument_placeholder(body, "$ARGUMENTS", agent_config["args"])
body = self.resolve_skill_placeholders(
agent_name, frontmatter, body, project_root
)
body = self._convert_argument_placeholder(
body, "$ARGUMENTS", agent_config["args"]
)
output = self.render_toml_command(frontmatter, body, source_id)
elif agent_config["format"] == "yaml":
output = self.render_yaml_command(
@@ -609,6 +646,40 @@ class CommandRegistrar:
CommandRegistrar._ensure_inside(prompt_file, prompts_dir)
prompt_file.write_text(f"---\nagent: {cmd_name}\n---\n", encoding="utf-8")
@staticmethod
def _resolve_agent_dir(
agent_name: str,
agent_config: dict[str, Any],
project_root: Path,
) -> Path:
"""Return the agent command directory, falling back to legacy_dir.
When the canonical directory (``agent_config["dir"]``) does not
exist but a ``legacy_dir`` is configured and present on disk,
returns the legacy path and emits a deprecation warning advising
the user to upgrade.
Integrations that do not declare ``legacy_dir`` get the canonical
path unconditionally — no fallback, no warning.
"""
agent_dir = project_root / agent_config["dir"]
if not agent_dir.exists():
legacy = agent_config.get("legacy_dir")
if legacy:
legacy_dir = project_root / legacy
if legacy_dir.exists():
import warnings
warnings.warn(
f"Found legacy '{legacy}' directory for "
f"{agent_name}. Run 'specify integration "
f"upgrade {agent_name}' to migrate to "
f"'{agent_config['dir']}'.",
stacklevel=3,
)
return legacy_dir
return agent_dir
def register_commands_for_all_agents(
self,
commands: List[Dict[str, Any]],
@@ -633,7 +704,9 @@ class CommandRegistrar:
self._ensure_configs()
for agent_name, agent_config in self.AGENT_CONFIGS.items():
agent_dir = project_root / agent_config["dir"]
agent_dir = self._resolve_agent_dir(
agent_name, agent_config, project_root,
)
if agent_dir.exists():
try:
@@ -644,6 +717,7 @@ class CommandRegistrar:
source_dir,
project_root,
context_note=context_note,
_resolved_dir=agent_dir,
)
if registered:
results[agent_name] = registered
@@ -681,13 +755,19 @@ class CommandRegistrar:
for agent_name, agent_config in self.AGENT_CONFIGS.items():
if agent_config.get("extension") == "/SKILL.md":
continue
agent_dir = project_root / agent_config["dir"]
agent_dir = self._resolve_agent_dir(
agent_name, agent_config, project_root,
)
if agent_dir.exists():
try:
registered = self.register_commands(
agent_name, commands, source_id,
source_dir, project_root,
agent_name,
commands,
source_id,
source_dir,
project_root,
context_note=context_note,
_resolved_dir=agent_dir,
)
if registered:
results[agent_name] = registered
@@ -700,6 +780,11 @@ class CommandRegistrar:
) -> None:
"""Remove previously registered command files from agent directories.
When a ``legacy_dir`` is configured, files are removed from
*both* the canonical and the legacy directory so that orphaned
commands left behind after an ``integration upgrade`` are
cleaned up as well.
Args:
registered_commands: Dict mapping agent names to command name lists
project_root: Path to project root
@@ -710,24 +795,39 @@ class CommandRegistrar:
continue
agent_config = self.AGENT_CONFIGS[agent_name]
commands_dir = project_root / agent_config["dir"]
commands_dir = self._resolve_agent_dir(
agent_name, agent_config, project_root,
)
# Collect all directories to clean: canonical (or resolved
# legacy) plus the legacy dir if it exists separately.
dirs_to_clean = [commands_dir]
legacy = agent_config.get("legacy_dir")
if legacy:
legacy_dir = project_root / legacy
if legacy_dir.exists() and legacy_dir != commands_dir:
dirs_to_clean.append(legacy_dir)
for cmd_name in cmd_names:
output_name = self._compute_output_name(
agent_name, cmd_name, agent_config
)
cmd_file = commands_dir / f"{output_name}{agent_config['extension']}"
if cmd_file.exists():
cmd_file.unlink()
# For SKILL.md agents each command lives in its own subdirectory
# (e.g. .agents/skills/speckit-ext-cmd/SKILL.md). Remove the
# parent dir when it becomes empty to avoid orphaned directories.
parent = cmd_file.parent
if parent != commands_dir and parent.exists():
try:
parent.rmdir() # no-op if dir still has other files
except OSError:
pass
for target_dir in dirs_to_clean:
cmd_file = (
target_dir / f"{output_name}{agent_config['extension']}"
)
if cmd_file.exists():
cmd_file.unlink()
# For SKILL.md agents each command lives in its own
# subdirectory (e.g. .agents/skills/speckit-ext-cmd/
# SKILL.md). Remove the parent dir when it becomes
# empty to avoid orphaned directories.
parent = cmd_file.parent
if parent != target_dir and parent.exists():
try:
parent.rmdir()
except OSError:
pass
if agent_name == "copilot":
prompt_file = (

View File

@@ -0,0 +1,50 @@
"""Authentication provider registry for multi-platform support.
Credentials are **opt-in only**. No authentication headers are sent unless
the user creates ``~/.specify/auth.json`` mapping hosts to providers.
Provider classes define *how* to authenticate (Bearer, Basic-PAT, etc.)
while the config file defines *where* and *with what credentials*.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .base import AuthProvider
# Maps provider key → AuthProvider class instance.
AUTH_REGISTRY: dict[str, AuthProvider] = {}
def _register(provider: AuthProvider) -> None:
"""Register a provider instance in the global registry.
Raises ``ValueError`` for falsy keys and ``KeyError`` for duplicates.
"""
key = provider.key
if not key:
raise ValueError("Cannot register provider with an empty key.")
if key in AUTH_REGISTRY:
raise KeyError(f"Provider with key {key!r} is already registered.")
AUTH_REGISTRY[key] = provider
def get_provider(key: str) -> AuthProvider | None:
"""Return the provider for *key*, or ``None`` if not registered."""
return AUTH_REGISTRY.get(key)
# -- Register built-in providers -----------------------------------------
def _register_builtins() -> None:
"""Register all built-in authentication providers (alphabetical)."""
from .azure_devops import AzureDevOpsAuth
from .github import GitHubAuth
_register(AzureDevOpsAuth())
_register(GitHubAuth())
_register_builtins()

View File

@@ -0,0 +1,117 @@
"""Azure DevOps authentication provider."""
from __future__ import annotations
import base64
import json as _json
import os
import subprocess
from typing import TYPE_CHECKING
from .base import AuthProvider
if TYPE_CHECKING:
from .config import AuthConfigEntry
# Azure DevOps resource ID for OAuth / Azure AD token acquisition.
_ADO_RESOURCE_ID = "499b84ac-1321-427f-aa17-267ca6975798"
class AzureDevOpsAuth(AuthProvider):
"""Azure DevOps authentication provider.
Supports four auth schemes:
* ``basic-pat`` — PAT with empty username, Base64-encoded as ``:<PAT>``
* ``bearer`` — pre-acquired OAuth / Azure AD token
* ``azure-cli`` — acquires a token via ``az account get-access-token``
* ``azure-ad`` — acquires a token via OAuth2 client credentials flow
"""
key = "azure-devops"
supported_auth_schemes = ("basic-pat", "bearer", "azure-cli", "azure-ad")
def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]:
"""Build the ``Authorization`` header for the given scheme."""
if auth_scheme == "basic-pat":
encoded = base64.b64encode(f":{token}".encode("ascii")).decode("ascii")
return {"Authorization": f"Basic {encoded}"}
if auth_scheme in ("bearer", "azure-cli", "azure-ad"):
return {"Authorization": f"Bearer {token}"}
raise ValueError(
f"AzureDevOpsAuth does not support auth scheme {auth_scheme!r}"
)
def resolve_token(self, entry: AuthConfigEntry) -> str | None:
"""Resolve token, with special handling for azure-cli and azure-ad."""
if entry.auth == "azure-cli":
return self._acquire_via_az_cli()
if entry.auth == "azure-ad":
return self._acquire_via_client_credentials(entry)
return super().resolve_token(entry)
# -- Token acquisition ------------------------------------------------
@staticmethod
def _acquire_via_az_cli() -> str | None:
"""Run ``az account get-access-token`` and return the access token."""
try:
result = subprocess.run( # noqa: S603, S607
[
"az",
"account",
"get-access-token",
"--resource",
_ADO_RESOURCE_ID,
"--output",
"json",
],
capture_output=True,
text=True,
timeout=30,
check=False,
)
if result.returncode != 0:
return None
payload = _json.loads(result.stdout)
token = payload.get("accessToken", "").strip()
return token or None
except (OSError, subprocess.TimeoutExpired, _json.JSONDecodeError, KeyError):
return None
@staticmethod
def _acquire_via_client_credentials(entry: AuthConfigEntry) -> str | None:
"""Acquire a token via OAuth2 client credentials flow."""
import urllib.error
import urllib.request
if not entry.tenant_id or not entry.client_id or not entry.client_secret_env:
return None
client_secret = os.environ.get(entry.client_secret_env, "").strip()
if not client_secret:
return None
url = (
f"https://login.microsoftonline.com/{entry.tenant_id}"
"/oauth2/v2.0/token"
)
from urllib.parse import urlencode
body = urlencode({
"grant_type": "client_credentials",
"client_id": entry.client_id,
"client_secret": client_secret,
"scope": f"{_ADO_RESOURCE_ID}/.default",
}).encode("utf-8")
req = urllib.request.Request(
url,
data=body,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
try:
with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310
payload = _json.loads(resp.read().decode("utf-8"))
token = payload.get("access_token", "").strip()
return token or None
except (urllib.error.URLError, OSError, _json.JSONDecodeError, KeyError):
return None

View File

@@ -0,0 +1,57 @@
"""Abstract base class for authentication providers."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .config import AuthConfigEntry
class AuthProvider(ABC):
"""Abstract base class every authentication provider must implement.
Subclasses must set:
* ``key`` — unique provider identifier (e.g. ``"github"``, ``"azure-devops"``)
* ``supported_auth_schemes`` — tuple of auth scheme strings this provider handles
And implement:
* ``auth_headers(token, auth_scheme)`` — build headers from a resolved token
* ``resolve_token(entry)`` — obtain the token for a config entry
"""
key: str = ""
"""Unique provider identifier."""
supported_auth_schemes: tuple[str, ...] = ()
"""Auth schemes this provider supports (e.g. ``("bearer",)``)."""
@abstractmethod
def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]:
"""Build authentication headers for *token* using *auth_scheme*.
Must return a dict with at least an ``Authorization`` key.
"""
def resolve_token(self, entry: AuthConfigEntry) -> str | None:
"""Resolve the token for *entry*.
Default implementation reads from ``entry.token`` directly
or from the environment variable named by ``entry.token_env``.
Override for schemes that acquire tokens dynamically
(e.g. ``azure-cli``, ``azure-ad``).
"""
import os
if entry.token:
return entry.token.strip() or None
if entry.token_env:
val = os.environ.get(entry.token_env)
if val is not None:
val = val.strip()
if val:
return val
return None

View File

@@ -0,0 +1,209 @@
"""Authentication configuration loader.
Reads ``~/.specify/auth.json`` to determine which hosts receive credentials
and which provider/auth-scheme to use. No credentials are sent without
an explicit opt-in via this file.
"""
from __future__ import annotations
import json
import os
import stat
from dataclasses import dataclass
from fnmatch import fnmatch
from pathlib import Path
from urllib.parse import urlparse
@dataclass(frozen=True)
class AuthConfigEntry:
"""A single provider entry from ``auth.json``."""
hosts: tuple[str, ...]
provider: str
auth: str
token: str | None = None
token_env: str | None = None
# Azure AD service-principal fields
tenant_id: str | None = None
client_id: str | None = None
client_secret_env: str | None = None
def _default_config_path() -> Path:
"""Return ``~/.specify/auth.json``."""
return Path.home() / ".specify" / "auth.json"
def _is_valid_host_pattern(pattern: str) -> bool:
"""Return True for safe host patterns: exact hostnames or ``*.suffix`` only.
Rejects patterns like ``*github.com`` (which would match
``github.com.evil.com``) or multi-wildcard forms. Only these two
forms are accepted:
* ``example.com`` — exact hostname
* ``*.example.com`` — leading ``*.`` wildcard; matches subdomains
such as ``myorg.example.com`` but not ``example.com`` itself
"""
if "*" not in pattern:
return True # exact hostname — already validated as non-empty
# Only *.suffix is allowed; no other wildcard positions
return pattern.startswith("*.") and "*" not in pattern[2:]
def load_auth_config(
path: Path | None = None,
) -> list[AuthConfigEntry]:
"""Load and validate ``auth.json``, returning configured entries.
Returns an empty list when the file does not exist — this means
all HTTP requests will be unauthenticated (opt-in model).
Raises ``ValueError`` on schema violations. Callers that want
misconfigurations to fail fast can allow this exception to
propagate; higher-level HTTP helpers may instead catch it,
warn, and continue with unauthenticated requests.
"""
config_path = path or _default_config_path()
if not config_path.is_file():
return []
# Warn (but don't fail) if the file is world-readable (POSIX only).
if os.name != "nt":
try:
mode = config_path.stat().st_mode
if mode & (stat.S_IRGRP | stat.S_IROTH):
import warnings
warnings.warn(
f"{config_path} is readable by group/others. "
"Consider restricting with: chmod 600 "
f"{config_path}",
UserWarning,
stacklevel=2,
)
except OSError:
pass # stat failed — skip permission check
raw = json.loads(config_path.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
raise ValueError(f"auth.json must be a JSON object, got {type(raw).__name__}")
providers_raw = raw.get("providers")
if not isinstance(providers_raw, list):
raise ValueError("auth.json must contain a 'providers' array")
entries: list[AuthConfigEntry] = []
for i, entry_raw in enumerate(providers_raw):
if not isinstance(entry_raw, dict):
raise ValueError(f"providers[{i}]: must be a JSON object")
hosts = entry_raw.get("hosts")
if not isinstance(hosts, list) or not hosts:
raise ValueError(f"providers[{i}]: 'hosts' must be a non-empty array")
if not all(isinstance(h, str) and h.strip() for h in hosts):
raise ValueError(f"providers[{i}]: each host must be a non-empty string")
# Normalize hosts: strip whitespace and lowercase
hosts = [h.strip().lower() for h in hosts]
# Reject dangerous wildcard forms (e.g. *github.com matches github.com.evil.com)
for h in hosts:
if not _is_valid_host_pattern(h):
raise ValueError(
f"providers[{i}]: invalid host pattern {h!r}. "
"Only exact hostnames or '*.suffix' forms are allowed "
"(e.g. 'github.com' or '*.visualstudio.com')."
)
provider = entry_raw.get("provider", "")
if not isinstance(provider, str) or not provider:
raise ValueError(f"providers[{i}]: 'provider' must be a non-empty string")
auth = entry_raw.get("auth", "")
if not isinstance(auth, str) or not auth:
raise ValueError(f"providers[{i}]: 'auth' must be a non-empty string")
token = entry_raw.get("token")
token_env = entry_raw.get("token_env")
# Validate token/token_env types
if token is not None and (not isinstance(token, str) or not token.strip()):
raise ValueError(f"providers[{i}]: 'token' must be a non-empty string")
if token_env is not None and (not isinstance(token_env, str) or not token_env.strip()):
raise ValueError(f"providers[{i}]: 'token_env' must be a non-empty string")
# Validate provider+scheme compatibility
from . import get_provider as _get_provider
_prov = _get_provider(provider)
if _prov is None:
from . import AUTH_REGISTRY
raise ValueError(
f"providers[{i}]: unknown provider {provider!r}; "
f"registered: {sorted(AUTH_REGISTRY.keys())}"
)
if auth not in _prov.supported_auth_schemes:
raise ValueError(
f"providers[{i}]: provider {provider!r} does not support "
f"auth scheme {auth!r}; supported: {list(_prov.supported_auth_schemes)}"
)
# Validate token source based on auth scheme
if auth in ("bearer", "basic-pat"):
if not token and not token_env:
raise ValueError(
f"providers[{i}]: auth={auth!r} requires 'token' or 'token_env'"
)
elif auth == "azure-ad":
tenant_id = entry_raw.get("tenant_id")
client_id = entry_raw.get("client_id")
client_secret_env = entry_raw.get("client_secret_env")
if not all([tenant_id, client_id, client_secret_env]):
raise ValueError(
f"providers[{i}]: auth='azure-ad' requires "
"'tenant_id', 'client_id', and 'client_secret_env'"
)
for field_name, field_val in [
("tenant_id", tenant_id),
("client_id", client_id),
("client_secret_env", client_secret_env),
]:
if not isinstance(field_val, str) or not field_val.strip():
raise ValueError(
f"providers[{i}]: '{field_name}' must be a non-empty string"
)
# azure-cli needs no extra fields
entries.append(
AuthConfigEntry(
hosts=tuple(hosts),
provider=provider,
auth=auth,
token=token,
token_env=token_env,
tenant_id=entry_raw.get("tenant_id"),
client_id=entry_raw.get("client_id"),
client_secret_env=entry_raw.get("client_secret_env"),
)
)
return entries
def find_entries_for_url(
url: str, entries: list[AuthConfigEntry]
) -> list[AuthConfigEntry]:
"""Return entries whose ``hosts`` match the hostname of *url*."""
hostname = (urlparse(url).hostname or "").lower()
if not hostname:
return []
return [
e
for e in entries
if any(
pattern == hostname or fnmatch(hostname, pattern)
for pattern in e.hosts
)
]

View File

@@ -0,0 +1,24 @@
"""GitHub authentication provider."""
from __future__ import annotations
from .base import AuthProvider
class GitHubAuth(AuthProvider):
"""GitHub authentication provider.
Supports the ``bearer`` auth scheme, used for PATs, fine-grained PATs,
OAuth tokens, and GitHub App installation tokens.
"""
key = "github"
supported_auth_schemes = ("bearer",)
def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]:
"""Return ``Authorization: Bearer <token>``."""
if auth_scheme != "bearer":
raise ValueError(
f"GitHubAuth does not support auth scheme {auth_scheme!r}"
)
return {"Authorization": f"Bearer {token}"}

View File

@@ -0,0 +1,149 @@
"""Authenticated HTTP helpers driven by ``~/.specify/auth.json``.
No credentials are sent unless the user has created ``auth.json``.
For each outbound URL the helper matches the hostname against
configured entries, resolves the token via the appropriate provider
class, and attaches auth headers. Redirect safety is enforced:
the ``Authorization`` header is stripped when a redirect leaves the
entry's declared hosts. On 401/403 the next matching entry is tried,
then unauthenticated.
"""
from __future__ import annotations
import urllib.error
import urllib.request
from fnmatch import fnmatch
from urllib.parse import urlparse
from . import get_provider
from .config import AuthConfigEntry, _default_config_path, find_entries_for_url, load_auth_config
_config_override: list[AuthConfigEntry] | None = None
_config_cache: list[AuthConfigEntry] | None = None # None = not yet loaded
def _load_config() -> list[AuthConfigEntry]:
"""Load auth config, using override if set (for testing).
The result is cached per-process so ``auth.json`` is read at most once,
and any warning about a malformed file fires only once.
"""
global _config_cache
if _config_override is not None:
return _config_override
if _config_cache is not None:
return _config_cache
try:
_config_cache = load_auth_config()
except (ValueError, OSError) as exc:
import warnings
config_path = _default_config_path()
warnings.warn(
f"Failed to load {config_path}: {exc}. "
"All requests will be unauthenticated.",
UserWarning,
stacklevel=2,
)
_config_cache = []
return _config_cache
def _hostname_in_hosts(hostname: str, hosts: tuple[str, ...]) -> bool:
"""Return True if *hostname* matches any pattern in *hosts*."""
hostname = hostname.lower()
return any(p == hostname or fnmatch(hostname, p) for p in hosts)
class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
"""Drop ``Authorization`` when a redirect leaves the entry's declared hosts."""
def __init__(self, hosts: tuple[str, ...]) -> None:
super().__init__()
self._hosts = hosts
def redirect_request(self, req, fp, code, msg, headers, newurl):
original_auth = (
req.get_header("Authorization")
or req.unredirected_hdrs.get("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_hosts(hostname, self._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 build_request(url: str, extra_headers: dict[str, str] | None = None) -> urllib.request.Request:
"""Build a :class:`~urllib.request.Request`, attaching auth when config matches.
Uses the first matching entry from ``auth.json`` whose token resolves.
Returns a plain request when no entry matches or the file doesn't exist.
"""
headers: dict[str, str] = {}
if extra_headers:
# Strip Authorization from extra_headers to prevent bypass
headers.update({k: v for k, v in extra_headers.items() if k.lower() != "authorization"})
# Auth headers applied last — cannot be overridden by extra_headers
entries = find_entries_for_url(url, _load_config())
for entry in entries:
provider = get_provider(entry.provider)
if provider is None:
continue
token = provider.resolve_token(entry)
if token:
headers.update(provider.auth_headers(token, entry.auth))
break
return urllib.request.Request(url, headers=headers)
def open_url(url: str, timeout: int = 10, extra_headers: dict[str, str] | None = None):
"""Open *url* with config-driven auth, redirect stripping, and fallthrough.
1. Find ``auth.json`` entries whose hosts match the URL.
2. For each entry, resolve the token and try the request.
3. On 401/403 move to the next matching entry.
4. After all entries exhausted (or none matched), try unauthenticated.
5. Non-auth errors (404, 500, network) raise immediately.
*extra_headers* (e.g. ``Accept``) are merged into every attempt.
"""
entries = find_entries_for_url(url, _load_config())
def _make_req(auth_headers: dict[str, str]) -> urllib.request.Request:
merged = {}
if extra_headers:
# Strip Authorization from extra_headers to prevent bypass
merged.update({k: v for k, v in extra_headers.items() if k.lower() != "authorization"})
# Auth headers applied last — cannot be overridden by extra_headers
merged.update(auth_headers)
return urllib.request.Request(url, headers=merged)
# Try each matching entry
for entry in entries:
provider = get_provider(entry.provider)
if provider is None:
continue
token = provider.resolve_token(entry)
if not token:
continue
req = _make_req(provider.auth_headers(token, entry.auth))
opener = urllib.request.build_opener(_StripAuthOnRedirect(entry.hosts))
try:
return opener.open(req, timeout=timeout)
except urllib.error.HTTPError as exc:
if exc.code in (401, 403):
exc.close()
continue # try next entry
raise
# No entry worked (or none matched) — unauthenticated fallback
req = _make_req({})
return urllib.request.urlopen(req, timeout=timeout) # noqa: S310

180
src/specify_cli/catalogs.py Normal file
View File

@@ -0,0 +1,180 @@
"""Shared catalog stack config primitives.
Catalog-backed features use the same local config shape and URL validation
rules. This module keeps those narrow primitives in one place while individual
catalog types keep their active source resolution, fetch, cache, and
domain-specific validation behavior.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import ClassVar
import yaml
@dataclass
class CatalogEntry:
"""Represents a single catalog source in a catalog stack."""
url: str
name: str
priority: int
install_allowed: bool
description: str = ""
class CatalogStackBase:
"""Base class for ordered catalog-source resolution.
Subclasses provide catalog-specific metadata and exception classes. Fetching
and schema validation stay in each concrete catalog because those formats
differ across integrations, extensions, presets, and workflows.
"""
ENTRY_CLASS: ClassVar[type[CatalogEntry]] = CatalogEntry
ERROR_TYPE: ClassVar[type[Exception]] = ValueError
VALIDATION_ERROR_TYPE: ClassVar[type[Exception]] = ValueError
CONFIG_FILENAME: ClassVar[str]
@classmethod
def _error(cls, message: str) -> Exception:
return cls.ERROR_TYPE(message)
@classmethod
def _validation_error(cls, message: str) -> Exception:
return cls.VALIDATION_ERROR_TYPE(message)
@classmethod
def _entry(
cls,
*,
url: str,
name: str,
priority: int,
install_allowed: bool,
description: str = "",
) -> CatalogEntry:
return cls.ENTRY_CLASS(
url=url,
name=name,
priority=priority,
install_allowed=install_allowed,
description=description,
)
@classmethod
def _validate_catalog_url(cls, url: str) -> None:
"""Validate that a catalog URL uses HTTPS, except localhost HTTP."""
from urllib.parse import urlparse
parsed = urlparse(url)
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
raise cls._error(
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
"HTTP is only allowed for localhost."
)
if not parsed.netloc:
raise cls._error("Catalog URL must be a valid URL with a host.")
def _load_catalog_config(self, config_path: Path) -> list[CatalogEntry] | None:
"""Load catalog stack configuration from a YAML file.
Returns ``None`` when the file does not exist. Existing files fail
closed when they are malformed, empty, or contain no usable URLs.
"""
if not config_path.exists():
return None
try:
data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
except (yaml.YAMLError, OSError, UnicodeError) as exc:
raise self._validation_error(
f"Failed to read catalog config {config_path}: {exc}"
) from exc
if data is None:
data = {}
if not isinstance(data, dict):
raise self._validation_error(
f"Invalid catalog config {config_path}: expected a YAML mapping at the root"
)
catalogs_data = data.get("catalogs", [])
if not isinstance(catalogs_data, list):
raise self._validation_error(
f"Invalid catalog config {config_path}: 'catalogs' must be a list, "
f"got {type(catalogs_data).__name__}"
)
if not catalogs_data:
raise self._validation_error(
f"Catalog config {config_path} exists but contains no 'catalogs' entries. "
f"Remove the file to use built-in defaults, or add valid catalog entries."
)
entries: list[CatalogEntry] = []
skipped: list[int] = []
for idx, item in enumerate(catalogs_data):
if not isinstance(item, dict):
raise self._validation_error(
f"Invalid catalog config {config_path}: catalog entry at index {idx}: "
f"expected a mapping, got {type(item).__name__}"
)
url = str(item.get("url", "")).strip()
if not url:
skipped.append(idx)
continue
try:
self._validate_catalog_url(url)
except self.ERROR_TYPE as exc:
raise self._validation_error(
f"Invalid catalog URL in {config_path} at index {idx}: {exc}"
) from exc
raw_priority = item.get("priority", idx + 1)
if isinstance(raw_priority, bool):
raise self._validation_error(
f"Invalid catalog config {config_path}: "
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
f"expected integer, got {raw_priority!r}"
)
try:
priority = int(raw_priority)
except (TypeError, ValueError):
raise self._validation_error(
f"Invalid catalog config {config_path}: "
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
f"expected integer, got {raw_priority!r}"
)
raw_install = item.get("install_allowed", False)
if isinstance(raw_install, str):
install_allowed = raw_install.strip().lower() in ("true", "yes", "1")
else:
install_allowed = bool(raw_install)
raw_name = item.get("name")
name = str(raw_name).strip() if raw_name is not None else ""
if not name:
name = f"catalog-{len(entries) + 1}"
entries.append(
self._entry(
url=url,
name=name,
priority=priority,
install_allowed=install_allowed,
description=str(item.get("description", "")),
)
)
entries.sort(key=lambda e: e.priority)
if not entries:
raise self._validation_error(
f"Catalog config {config_path} contains {len(catalogs_data)} "
f"entries but none have valid URLs (entries at indices {skipped} "
f"were skipped). Each catalog entry must have a 'url' field."
)
return entries

View File

@@ -25,6 +25,8 @@ import yaml
from packaging import version as pkg_version
from packaging.specifiers import SpecifierSet, InvalidSpecifier
from .catalogs import CatalogEntry as BaseCatalogEntry, CatalogStackBase
_FALLBACK_CORE_COMMAND_NAMES = frozenset({
"analyze",
"checklist",
@@ -107,13 +109,8 @@ def normalize_priority(value: Any, default: int = 10) -> int:
@dataclass
class CatalogEntry:
class CatalogEntry(BaseCatalogEntry):
"""Represents a single catalog entry in the catalog stack."""
url: str
name: str
priority: int
install_allowed: bool
description: str = ""
class ExtensionManifest:
@@ -1190,7 +1187,7 @@ class ExtensionManager:
# was used during project initialisation (feature parity).
registered_skills = self._register_extension_skills(manifest, dest_dir)
# Register hooks
# Register hooks and update installed list in extensions.yml
hook_executor = HookExecutor(self.project_root)
hook_executor.register_hooks(manifest)
@@ -1666,12 +1663,16 @@ class CommandRegistrar:
return self.register_commands_for_agent("claude", manifest, extension_dir, project_root)
class ExtensionCatalog:
class ExtensionCatalog(CatalogStackBase):
"""Manages extension catalog fetching, caching, and searching."""
DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json"
COMMUNITY_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json"
CACHE_DURATION = 3600 # 1 hour in seconds
CONFIG_FILENAME = "extension-catalogs.yml"
ENTRY_CLASS = CatalogEntry
ERROR_TYPE = ValidationError
VALIDATION_ERROR_TYPE = ValidationError
def __init__(self, project_root: Path):
"""Initialize extension catalog manager.
@@ -1685,117 +1686,21 @@ class ExtensionCatalog:
self.cache_file = self.cache_dir / "catalog.json"
self.cache_metadata_file = self.cache_dir / "catalog-metadata.json"
def _validate_catalog_url(self, url: str) -> None:
"""Validate that a catalog URL uses HTTPS (localhost HTTP allowed).
Args:
url: URL to validate
Raises:
ValidationError: If URL is invalid or uses non-HTTPS scheme
"""
from urllib.parse import urlparse
parsed = urlparse(url)
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
raise ValidationError(
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
"HTTP is only allowed for localhost."
)
if not parsed.netloc:
raise ValidationError("Catalog URL must be a valid URL with a host.")
def _make_request(self, url: str):
"""Build a urllib Request, adding a GitHub auth header when available.
"""Build a urllib Request, adding auth headers when a provider matches.
Delegates to :func:`specify_cli._github_http.build_github_request`.
Delegates to :func:`specify_cli.authentication.http.build_request`.
"""
from specify_cli._github_http import build_github_request
return build_github_request(url)
from specify_cli.authentication.http import build_request
return build_request(url)
def _open_url(self, url: str, timeout: int = 10):
"""Open a URL with GitHub auth, stripping the header on cross-host redirects.
"""Open a URL with provider-based auth, trying each configured provider.
Delegates to :func:`specify_cli._github_http.open_github_url`.
Delegates to :func:`specify_cli.authentication.http.open_url`.
"""
from specify_cli._github_http import open_github_url
return open_github_url(url, timeout)
def _load_catalog_config(self, config_path: Path) -> Optional[List[CatalogEntry]]:
"""Load catalog stack configuration from a YAML file.
Args:
config_path: Path to extension-catalogs.yml
Returns:
Ordered list of CatalogEntry objects, or None if file doesn't exist.
Raises:
ValidationError: If any catalog entry has an invalid URL,
the file cannot be parsed, a priority value is invalid,
or the file exists but contains no valid catalog entries
(fail-closed for security).
"""
if not config_path.exists():
return None
try:
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
except (yaml.YAMLError, OSError, UnicodeError) as e:
raise ValidationError(
f"Failed to read catalog config {config_path}: {e}"
)
catalogs_data = data.get("catalogs", [])
if not catalogs_data:
# File exists but has no catalogs key or empty list - fail closed
raise ValidationError(
f"Catalog config {config_path} exists but contains no 'catalogs' entries. "
f"Remove the file to use built-in defaults, or add valid catalog entries."
)
if not isinstance(catalogs_data, list):
raise ValidationError(
f"Invalid catalog config: 'catalogs' must be a list, got {type(catalogs_data).__name__}"
)
entries: List[CatalogEntry] = []
skipped_entries: List[int] = []
for idx, item in enumerate(catalogs_data):
if not isinstance(item, dict):
raise ValidationError(
f"Invalid catalog entry at index {idx}: expected a mapping, got {type(item).__name__}"
)
url = str(item.get("url", "")).strip()
if not url:
skipped_entries.append(idx)
continue
self._validate_catalog_url(url)
try:
priority = int(item.get("priority", idx + 1))
except (TypeError, ValueError):
raise ValidationError(
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
f"expected integer, got {item.get('priority')!r}"
)
raw_install = item.get("install_allowed", False)
if isinstance(raw_install, str):
install_allowed = raw_install.strip().lower() in ("true", "yes", "1")
else:
install_allowed = bool(raw_install)
entries.append(CatalogEntry(
url=url,
name=str(item.get("name", f"catalog-{idx + 1}")),
priority=priority,
install_allowed=install_allowed,
description=str(item.get("description", "")),
))
entries.sort(key=lambda e: e.priority)
if not entries:
# All entries were invalid (missing URLs) - fail closed for security
raise ValidationError(
f"Catalog config {config_path} contains {len(catalogs_data)} entries but none have valid URLs "
f"(entries at indices {skipped_entries} were skipped). "
f"Each catalog entry must have a 'url' field."
)
return entries
from specify_cli.authentication.http import open_url
return open_url(url, timeout)
def get_active_catalogs(self) -> List[CatalogEntry]:
"""Get the ordered list of active catalogs.
@@ -1826,24 +1731,44 @@ class ExtensionCatalog:
file=sys.stderr,
)
self._non_default_catalog_warning_shown = True
return [CatalogEntry(url=catalog_url, name="custom", priority=1, install_allowed=True, description="Custom catalog via SPECKIT_CATALOG_URL")]
return [
self._entry(
url=catalog_url,
name="custom",
priority=1,
install_allowed=True,
description="Custom catalog via SPECKIT_CATALOG_URL",
)
]
# 2. Project-level config overrides all defaults
project_config_path = self.project_root / ".specify" / "extension-catalogs.yml"
project_config_path = self.project_root / ".specify" / self.CONFIG_FILENAME
catalogs = self._load_catalog_config(project_config_path)
if catalogs is not None:
return catalogs
# 3. User-level config
user_config_path = Path.home() / ".specify" / "extension-catalogs.yml"
user_config_path = Path.home() / ".specify" / self.CONFIG_FILENAME
catalogs = self._load_catalog_config(user_config_path)
if catalogs is not None:
return catalogs
# 4. Built-in default stack
return [
CatalogEntry(url=self.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True, description="Built-in catalog of installable extensions"),
CatalogEntry(url=self.COMMUNITY_CATALOG_URL, name="community", priority=2, install_allowed=False, description="Community-contributed extensions (discovery only)"),
self._entry(
url=self.DEFAULT_CATALOG_URL,
name="default",
priority=1,
install_allowed=True,
description="Built-in catalog of installable extensions",
),
self._entry(
url=self.COMMUNITY_CATALOG_URL,
name="community",
priority=2,
install_allowed=False,
description="Community-contributed extensions (discovery only)",
),
]
def get_catalog_url(self) -> str:
@@ -2481,7 +2406,32 @@ class HookExecutor:
}
try:
return yaml.safe_load(self.config_file.read_text(encoding="utf-8")) or {}
result = yaml.safe_load(self.config_file.read_text(encoding="utf-8"))
# Coerce non-dict root (including None for an empty file) to the
# fully-normalized default so callers always get guaranteed fields.
if not isinstance(result, dict):
return {
"installed": [],
"settings": {"auto_execute_hooks": True},
"hooks": {},
}
# Normalize nested fields so read-only callers like get_hooks_for_event()
# never see non-dict hooks or non-list installed (Feedback)
if not isinstance(result.get("hooks"), dict):
result["hooks"] = {}
if not isinstance(result.get("installed"), list):
result["installed"] = []
if not isinstance(result.get("settings"), dict):
result["settings"] = {"auto_execute_hooks": True}
# Sanitize hook event values: coerce non-list values to [] and filter
# non-dict items so get_hooks_for_event() can safely call .get() (Feedback)
for event_key in list(result["hooks"]):
event_val = result["hooks"][event_key]
if not isinstance(event_val, list):
result["hooks"][event_key] = []
else:
result["hooks"][event_key] = [h for h in event_val if isinstance(h, dict)]
return result
except (yaml.YAMLError, OSError, UnicodeError):
return {
"installed": [],
@@ -2501,25 +2451,141 @@ class HookExecutor:
encoding="utf-8",
)
def register_extension(self, extension_id: str):
"""Add extension to the installed list in project config.
Args:
extension_id: ID of extension to register
"""
config = self.get_project_config()
# Ensure config is a dict (defensive)
if not isinstance(config, dict):
config = {}
raw_installed = config.get("installed")
sanitized = self._sanitize_installed_list(raw_installed, add_id=extension_id)
if sanitized != raw_installed:
config["installed"] = sanitized
self.save_project_config(config)
def unregister_extension(self, extension_id: str):
"""Remove extension from the installed list in project config.
Args:
extension_id: ID of extension to unregister
"""
config = self.get_project_config()
if not isinstance(config, dict):
config = {}
raw_installed = config.get("installed")
sanitized = self._sanitize_installed_list(raw_installed, remove_id=extension_id)
# Always persist if sanitized state differs from raw config (ensures normalization)
if sanitized != raw_installed:
config["installed"] = sanitized
self.save_project_config(config)
@staticmethod
def _sanitize_installed_list(
raw: object,
*,
add_id: str = "",
remove_id: str = "",
) -> list:
"""Normalize, deduplicate, and optionally add/remove an extension id.
Shared by register_extension() and unregister_extension() to prevent
the two paths from drifting.
Args:
raw: The raw value from config["installed"] (may be non-list).
add_id: If non-empty, ensure this id is present (plain-string fallback).
remove_id: If non-empty, remove this id from the list.
Returns:
A sanitized, deduplicated, alphabetically-sorted list.
"""
_VALID_ID = re.compile(r'^[a-z0-9-]+$')
installed = raw if isinstance(raw, list) else []
# Keep only entries whose resolved id is a non-empty string matching
# the extension-id format (^[a-z0-9-]+$), same rule ExtensionManifest enforces.
def _valid_entry(x: object) -> bool:
if isinstance(x, str):
return bool(_VALID_ID.match(x.strip()))
if isinstance(x, dict):
eid = x.get("id")
return isinstance(eid, str) and bool(_VALID_ID.match(eid.strip()))
return False
valid = [x for x in installed if _valid_entry(x)]
# Deduplicate by id: prefer dict (richer metadata) over plain string
seen: dict = {} # id -> entry (dict preferred over str)
for x in valid:
eid = x.strip() if isinstance(x, str) else x.get("id", "").strip()
if eid not in seen or isinstance(x, dict):
seen[eid] = x
# Validate add_id against the same regex before inserting
if add_id and _VALID_ID.match(add_id.strip()) and add_id not in seen:
seen[add_id] = add_id
if remove_id:
seen.pop(remove_id, None)
def _sort_key(x: object) -> str:
return x if isinstance(x, str) else x.get("id", "") # type: ignore[return-value]
return sorted(seen.values(), key=_sort_key)
def register_hooks(self, manifest: ExtensionManifest):
"""Register extension hooks in project config.
Args:
manifest: Extension manifest with hooks to register
"""
# Always ensure the extension is in the installed list
self.register_extension(manifest.id)
if not hasattr(manifest, "hooks") or not manifest.hooks:
return
config = self.get_project_config()
# Ensure hooks dict exists
if "hooks" not in config:
# Ensure config is a dict (defensive)
changed = False
if not isinstance(config, dict):
config = {}
changed = True
# Ensure hooks dict exists and is a mapping
if "hooks" not in config or not isinstance(config["hooks"], dict):
config["hooks"] = {}
changed = True
else:
# Sanitize existing hook lists to prevent crashes in downstream code (Feedback)
for h_name in list(config["hooks"].keys()):
h_list = config["hooks"][h_name]
if not isinstance(h_list, list):
config["hooks"][h_name] = []
changed = True
else:
sanitized_h_list = [h for h in h_list if isinstance(h, dict)]
if len(sanitized_h_list) != len(h_list):
config["hooks"][h_name] = sanitized_h_list
changed = True
# Register each hook
for hook_name, hook_config in manifest.hooks.items():
if hook_name not in config["hooks"]:
if hook_name not in config["hooks"] or not isinstance(config["hooks"][hook_name], list):
config["hooks"][hook_name] = []
changed = True
# Add hook entry
hook_entry = {
@@ -2534,22 +2600,22 @@ class HookExecutor:
"condition": hook_config.get("condition"),
}
# Check if already registered
existing = [
h
for h in config["hooks"][hook_name]
if h.get("extension") == manifest.id
# Deduplicate: remove all existing entries for this extension on this
# hook event, then append the single canonical entry. This prevents
# multiple hooks firing when hand-edited or older versions leave
# duplicate entries behind. (Feedback from review)
original_list = config["hooks"][hook_name]
deduped = [
h for h in original_list
if not (isinstance(h, dict) and h.get("extension") == manifest.id)
]
deduped.append(hook_entry)
if deduped != original_list:
config["hooks"][hook_name] = deduped
changed = True
if not existing:
config["hooks"][hook_name].append(hook_entry)
else:
# Update existing
for i, h in enumerate(config["hooks"][hook_name]):
if h.get("extension") == manifest.id:
config["hooks"][hook_name][i] = hook_entry
self.save_project_config(config)
if changed:
self.save_project_config(config)
def unregister_hooks(self, extension_id: str):
"""Remove extension hooks from project config.
@@ -2557,17 +2623,30 @@ class HookExecutor:
Args:
extension_id: ID of extension to unregister
"""
# Always remove from installed list (Feedback from review)
self.unregister_extension(extension_id)
config = self.get_project_config()
if "hooks" not in config:
if not isinstance(config, dict):
config = {}
# We don't save yet, as there are no hooks to unregister,
# but unregister_extension above might have already saved a normalized config.
return
if "hooks" not in config or not isinstance(config["hooks"], dict):
return
# Remove hooks for this extension
for hook_name in config["hooks"]:
for hook_name in list(config["hooks"].keys()):
hook_list = config["hooks"][hook_name]
if not isinstance(hook_list, list):
config["hooks"][hook_name] = []
continue
config["hooks"][hook_name] = [
h
for h in config["hooks"][hook_name]
if h.get("extension") != extension_id
for h in hook_list
if isinstance(h, dict) and h.get("extension") != extension_id
]
# Clean up empty hook arrays

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -11,6 +12,67 @@ INTEGRATION_JSON = ".specify/integration.json"
INTEGRATION_STATE_SCHEMA = 1
@dataclass(frozen=True)
class IntegrationReadError:
"""Structured failure from :func:`try_read_integration_json`.
Callers map ``kind`` to whatever surface they need (loud CLI error,
silent fallback, etc.) without re-implementing the parse/validation logic.
"""
kind: str # "decode", "os", "not_object", "schema_too_new"
detail: str = ""
schema: int | None = 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 is the single low-level reader; both the CLI's loud
``_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
# Avoid Path.exists() / Path.is_file() as a pre-check: both return False
# on some OSErrors (e.g. permission errors during stat), which would
# silently treat an unreadable-but-present file as missing. Attempt the
# read directly and distinguish FileNotFoundError (genuinely absent) from
# other OSErrors (which become loud errors via the IntegrationReadError
# path).
try:
raw = path.read_text(encoding="utf-8")
except FileNotFoundError:
return None, None
except IsADirectoryError as exc:
return None, IntegrationReadError(
kind="os",
detail=f"{path} exists but is not a regular file: {exc}",
)
except UnicodeDecodeError as exc:
return None, IntegrationReadError(kind="decode", detail=str(exc))
except OSError as exc:
return None, IntegrationReadError(kind="os", detail=str(exc))
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
return None, IntegrationReadError(kind="decode", detail=str(exc))
if not isinstance(data, dict):
return None, IntegrationReadError(kind="not_object", detail=type(data).__name__)
schema = data.get("integration_state_schema")
if (
isinstance(schema, int)
and not isinstance(schema, bool)
and schema > INTEGRATION_STATE_SCHEMA
):
return None, IntegrationReadError(kind="schema_too_new", schema=schema)
return normalize_integration_state(data), None
def clean_integration_key(key: Any) -> str | None:
"""Return a stripped integration key, or None for empty/non-string values."""
if not isinstance(key, str) or not key.strip():

View File

@@ -66,6 +66,7 @@ def _register_builtins() -> None:
from .kilocode import KilocodeIntegration
from .kimi import KimiIntegration
from .kiro_cli import KiroCliIntegration
from .lingma import LingmaIntegration
from .opencode import OpencodeIntegration
from .pi import PiIntegration
from .qodercli import QodercliIntegration
@@ -97,6 +98,7 @@ def _register_builtins() -> None:
_register(KilocodeIntegration())
_register(KimiIntegration())
_register(KiroCliIntegration())
_register(LingmaIntegration())
_register(OpencodeIntegration())
_register(PiIntegration())
_register(QodercliIntegration())

View File

@@ -20,6 +20,8 @@ from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
import yaml
if TYPE_CHECKING:
from .manifest import IntegrationManifest
@@ -606,6 +608,7 @@ class IntegrationBase(ABC):
# For .mdc files, treat Speckit-generated frontmatter-only content as empty
if ctx_path.suffix == ".mdc":
import re
# Delete the file if only YAML frontmatter remains (no body content)
frontmatter_only = re.match(
r"^---\n.*?\n---\s*$", normalized, re.DOTALL
@@ -953,7 +956,6 @@ class TomlIntegration(IntegrationBase):
and ``>``) keep their YAML semantics instead of being treated as
raw text.
"""
import yaml
frontmatter_text, _ = TomlIntegration._split_frontmatter(content)
if not frontmatter_text:
@@ -1140,7 +1142,6 @@ class YamlIntegration(IntegrationBase):
@staticmethod
def _extract_frontmatter(content: str) -> dict[str, Any]:
"""Extract frontmatter as a dict from YAML frontmatter block."""
import yaml
if not content.startswith("---"):
return {}
@@ -1201,24 +1202,38 @@ class YamlIntegration(IntegrationBase):
text = text[len("speckit.") :]
return text.replace(".", " ").replace("-", " ").replace("_", " ").title()
@staticmethod
def _render_yaml(title: str, description: str, body: str, source_id: str) -> str:
@classmethod
def _build_yaml_header(cls, title: str, description: str) -> dict[str, Any]:
"""Build the base YAML header."""
header = {
"version": "1.0.0",
"title": title,
"description": description,
"author": {"contact": "spec-kit"},
"parameters": [
{
"key": "args",
"input_type": "string",
"requirement": "optional",
"default": "",
"description": "User input passed to the command.",
}
],
"extensions": [{"type": "builtin", "name": "developer"}],
"activities": ["Spec-Driven Development"],
}
return header
@classmethod
def _render_yaml(cls, title: str, description: str, body: str, source_id: str) -> str:
"""Render a YAML recipe file from title, description, and body.
Produces a Goose-compatible recipe with a literal block scalar
for the prompt content. Uses ``yaml.safe_dump()`` for the
header fields to ensure proper escaping.
"""
import yaml
header = {
"version": "1.0.0",
"title": title,
"description": description,
"author": {"contact": "spec-kit"},
"extensions": [{"type": "builtin", "name": "developer"}],
"activities": ["Spec-Driven Development"],
}
header = cls._build_yaml_header(title, description)
header_yaml = yaml.safe_dump(
header,
@@ -1227,12 +1242,20 @@ class YamlIntegration(IntegrationBase):
default_flow_style=False,
).strip()
# Indent each line for YAML block scalar
# Indent the body for YAML block scalar
indented = "\n".join(f" {line}" for line in body.split("\n"))
lines = [header_yaml, "prompt: |", indented, "", f"# Source: {source_id}"]
lines = [
header_yaml,
"prompt: |",
indented,
"",
f"# Source: {source_id}",
]
return "\n".join(lines) + "\n"
def setup(
self,
project_root: Path,
@@ -1391,7 +1414,6 @@ class SkillsIntegration(IntegrationBase):
template. Each SKILL.md has normalised frontmatter containing
``name``, ``description``, ``compatibility``, and ``metadata``.
"""
import yaml
templates = self.list_command_templates()
if not templates:

View File

@@ -21,6 +21,8 @@ from typing import Any, Dict, List, Optional, Tuple
import yaml
from packaging import version as pkg_version
from ..catalogs import CatalogEntry, CatalogStackBase
# ---------------------------------------------------------------------------
# Errors
@@ -43,21 +45,15 @@ class IntegrationDescriptorError(Exception):
# ---------------------------------------------------------------------------
@dataclass
class IntegrationCatalogEntry:
class IntegrationCatalogEntry(CatalogEntry):
"""Represents a single catalog source in the catalog stack."""
url: str
name: str
priority: int
install_allowed: bool
description: str = ""
# ---------------------------------------------------------------------------
# IntegrationCatalog
# ---------------------------------------------------------------------------
class IntegrationCatalog:
class IntegrationCatalog(CatalogStackBase):
"""Manages integration catalog fetching, caching, and searching."""
DEFAULT_CATALOG_URL = (
@@ -67,136 +63,15 @@ class IntegrationCatalog:
"https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.community.json"
)
CACHE_DURATION = 3600 # 1 hour
CONFIG_FILENAME = "integration-catalogs.yml"
ENTRY_CLASS = IntegrationCatalogEntry
ERROR_TYPE = IntegrationCatalogError
VALIDATION_ERROR_TYPE = IntegrationValidationError
def __init__(self, project_root: Path) -> None:
self.project_root = project_root
self.cache_dir = project_root / ".specify" / "integrations" / ".cache"
# -- URL validation ---------------------------------------------------
@staticmethod
def _validate_catalog_url(url: str) -> None:
from urllib.parse import urlparse
parsed = urlparse(url)
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
raise IntegrationCatalogError(
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
"HTTP is only allowed for localhost."
)
if not parsed.netloc:
raise IntegrationCatalogError(
"Catalog URL must be a valid URL with a host."
)
# -- Catalog stack ----------------------------------------------------
def _load_catalog_config(
self, config_path: Path
) -> Optional[List[IntegrationCatalogEntry]]:
"""Load catalog stack from a YAML file.
Returns None when the file does not exist.
Raises:
IntegrationValidationError: on any local-config / YAML problem
(parse failures, wrong shape, missing/invalid fields,
invalid catalog URLs, etc.). This is a subclass of
:class:`IntegrationCatalogError`, so any caller that already
catches ``IntegrationCatalogError`` keeps working — but
callers that want to distinguish *local config* problems
from *remote/network* problems can match the subclass.
"""
if not config_path.exists():
return None
try:
data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
except (yaml.YAMLError, OSError, UnicodeError) as exc:
raise IntegrationValidationError(
f"Failed to read catalog config {config_path}: {exc}"
) from exc
if data is None:
data = {}
if not isinstance(data, dict):
raise IntegrationValidationError(
f"Invalid catalog config {config_path}: expected a YAML mapping at the root"
)
catalogs_data = data.get("catalogs", [])
if not isinstance(catalogs_data, list):
raise IntegrationValidationError(
f"Invalid catalog config {config_path}: 'catalogs' must be a list, "
f"got {type(catalogs_data).__name__}"
)
if not catalogs_data:
raise IntegrationValidationError(
f"Catalog config {config_path} exists but contains no 'catalogs' entries. "
f"Remove the file to use built-in defaults, or add valid catalog entries."
)
entries: List[IntegrationCatalogEntry] = []
skipped: List[int] = []
for idx, item in enumerate(catalogs_data):
if not isinstance(item, dict):
raise IntegrationValidationError(
f"Invalid catalog config {config_path}: catalog entry at index {idx}: "
f"expected a mapping, got {type(item).__name__}"
)
url = str(item.get("url", "")).strip()
if not url:
skipped.append(idx)
continue
try:
self._validate_catalog_url(url)
except IntegrationCatalogError as exc:
# ``_validate_catalog_url`` raises the base class for direct
# callers (e.g. ``add_catalog`` validating user input); when
# the bad URL came from a local config file, surface it as a
# validation error so CLI handlers can route it accordingly.
raise IntegrationValidationError(
f"Invalid catalog URL in {config_path} at index {idx}: {exc}"
) from exc
raw_priority = item.get("priority", idx + 1)
if isinstance(raw_priority, bool):
raise IntegrationValidationError(
f"Invalid catalog config {config_path}: "
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
f"expected integer, got {raw_priority!r}"
)
try:
priority = int(raw_priority)
except (TypeError, ValueError):
raise IntegrationValidationError(
f"Invalid catalog config {config_path}: "
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
f"expected integer, got {raw_priority!r}"
)
raw_install = item.get("install_allowed", False)
if isinstance(raw_install, str):
install_allowed = raw_install.strip().lower() in ("true", "yes", "1")
else:
install_allowed = bool(raw_install)
raw_name = item.get("name")
name = str(raw_name).strip() if raw_name is not None else ""
if not name:
name = f"catalog-{len(entries) + 1}"
entries.append(
IntegrationCatalogEntry(
url=url,
name=name,
priority=priority,
install_allowed=install_allowed,
description=str(item.get("description", "")),
)
)
entries.sort(key=lambda e: e.priority)
if not entries:
raise IntegrationValidationError(
f"Catalog config {config_path} contains {len(catalogs_data)} "
f"entries but none have valid URLs (entries at indices {skipped} "
f"were skipped). Each catalog entry must have a 'url' field."
)
return entries
def get_active_catalogs(self) -> List[IntegrationCatalogEntry]:
"""Return the ordered list of active integration catalogs.
@@ -265,7 +140,6 @@ class IntegrationCatalog:
) -> Dict[str, Any]:
"""Fetch one catalog, with per-URL caching."""
import urllib.error
import urllib.request
url_hash = hashlib.sha256(entry.url.encode()).hexdigest()[:16]
cache_file = self.cache_dir / f"catalog-{url_hash}.json"
@@ -289,7 +163,9 @@ class IntegrationCatalog:
pass # Cache cleanup is best-effort; ignore deletion failures.
try:
with urllib.request.urlopen(entry.url, timeout=10) as resp:
from specify_cli.authentication.http import open_url
with open_url(entry.url, timeout=10) as resp:
# Validate final URL after redirects
final_url = resp.geturl()
if final_url != entry.url:
@@ -443,8 +319,6 @@ class IntegrationCatalog:
# -- Catalog-source management ----------------------------------------
CONFIG_FILENAME = "integration-catalogs.yml"
def get_catalog_configs(self) -> List[Dict[str, Any]]:
"""Return the active catalog stack as a list of dicts.

View File

@@ -6,7 +6,22 @@ Commands are deprecated; ``--skills`` defaults to ``True``.
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
from ..base import IntegrationOption, SkillsIntegration
from ..manifest import IntegrationManifest
# Note injected into hook sections so Codex maps dot-notation command
# names (from extensions.yml) to the hyphenated skill names it uses.
# Without this, Codex emits ``/speckit.git.commit`` (which does not
# resolve) instead of ``/speckit-git-commit``.
_HOOK_COMMAND_NOTE = (
"- When constructing slash commands from hook command names, "
"replace dots (`.`) with hyphens (`-`). "
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
)
class CodexIntegration(SkillsIntegration):
@@ -54,3 +69,68 @@ class CodexIntegration(SkillsIntegration):
help="Install as agent skills (default for Codex)",
),
]
@staticmethod
def _inject_hook_command_note(content: str) -> str:
"""Insert a dot-to-hyphen note before each hook output instruction.
Targets the line ``- For each executable hook, output the following``
and inserts the note on the line before it, matching its indentation.
Skips if the note is already present.
"""
if "replace dots" in content:
return content
def repl(m: re.Match[str]) -> str:
indent = m.group(1)
instruction = m.group(2)
# ``eol`` is empty when the regex matched via ``$`` because the
# instruction was the final line of a file with no trailing
# newline. Default to ``\n`` so the note never collapses onto
# the same line as the instruction.
eol = m.group(3) or "\n"
return (
indent
+ _HOOK_COMMAND_NOTE.rstrip("\n")
+ eol
+ indent
+ instruction
+ eol
)
return re.sub(
r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)",
repl,
content,
)
def post_process_skill_content(self, content: str) -> str:
"""Inject the dot-to-hyphen hook command note."""
return self._inject_hook_command_note(content)
def setup(
self,
project_root: Path,
manifest: IntegrationManifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Install Codex skills, then inject the hook command note."""
created = super().setup(project_root, manifest, parsed_options, **opts)
skills_dir = self.skills_dest(project_root).resolve()
for path in created:
try:
path.resolve().relative_to(skills_dir)
except ValueError:
continue
if path.name != "SKILL.md":
continue
content = path.read_bytes().decode("utf-8")
updated = self.post_process_skill_content(content)
if updated != content:
path.write_bytes(updated.encode("utf-8"))
self.record_file_in_manifest(path, project_root, manifest)
return created

View File

@@ -87,8 +87,10 @@ class ForgeIntegration(MarkdownIntegration):
"strip_frontmatter_keys": ["handoffs"],
"inject_name": True,
"format_name": format_forge_command_name, # Custom name formatter
"invoke_separator": "-",
}
context_file = "AGENTS.md"
invoke_separator = "-"
def setup(
self,
@@ -133,6 +135,7 @@ class ForgeIntegration(MarkdownIntegration):
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
context_file=self.context_file or "",
invoke_separator=self.invoke_separator,
)
# FORGE-SPECIFIC: Ensure any remaining $ARGUMENTS placeholders are

View File

@@ -3,6 +3,14 @@
from ..base import MarkdownIntegration
# Kiro CLI file-based prompts do NOT support any argument-substitution syntax,
# so a raw "$ARGUMENTS" token would reach the model verbatim and break the
# prompt (issue #1926, kirodotdev/Kiro#4141). Use a prose fallback so the
# rendered prompt instructs the model to take its argument from the user's
# next message.
_KIRO_ARG_FALLBACK = "(the user will provide the argument in this conversation)"
class KiroCliIntegration(MarkdownIntegration):
key = "kiro-cli"
config = {
@@ -15,7 +23,7 @@ class KiroCliIntegration(MarkdownIntegration):
registrar_config = {
"dir": ".kiro/prompts",
"format": "markdown",
"args": "$ARGUMENTS",
"args": _KIRO_ARG_FALLBACK,
"extension": ".md",
}
context_file = "AGENTS.md"

View File

@@ -0,0 +1,41 @@
"""Lingma IDE integration. — skills-based agent.
Lingma IDE uses ``.lingma/skills/speckit-<name>/SKILL.md`` layout.
In Specify CLI, the Lingma integration is skills-only, and ``--skills``
defaults to ``True``.
"""
from __future__ import annotations
from ..base import IntegrationOption, SkillsIntegration
class LingmaIntegration(SkillsIntegration):
"""Integration for Lingma IDE."""
key = "lingma"
config = {
"name": "Lingma",
"folder": ".lingma/",
"commands_subdir": "skills",
"install_url": None,
"requires_cli": False,
}
registrar_config = {
"dir": ".lingma/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = ".lingma/rules/specify-rules.md"
@classmethod
def options(cls) -> list[IntegrationOption]:
return [
IntegrationOption(
"--skills",
is_flag=True,
default=True,
help="Install as agent skills",
),
]

View File

@@ -8,12 +8,13 @@ class OpencodeIntegration(MarkdownIntegration):
config = {
"name": "opencode",
"folder": ".opencode/",
"commands_subdir": "command",
"commands_subdir": "commands",
"install_url": "https://opencode.ai",
"requires_cli": True,
}
registrar_config = {
"dir": ".opencode/command",
"dir": ".opencode/commands",
"legacy_dir": ".opencode/command",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",

View File

@@ -1048,9 +1048,9 @@ class PresetManager:
short_name = cmd_name
if short_name.startswith("speckit."):
short_name = short_name[len("speckit."):]
desc = SKILL_DESCRIPTIONS.get(
desc = fm.get("description", "") or SKILL_DESCRIPTIONS.get(
short_name.replace(".", "-"),
fm.get("description", f"Command: {short_name}"),
f"Command: {short_name}",
)
init_opts = load_init_options(self.project_root)
selected_ai = init_opts.get("ai") if isinstance(init_opts, dict) else ""
@@ -1314,9 +1314,9 @@ class PresetManager:
frontmatter[key] = core_frontmatter[key]
original_desc = frontmatter.get("description", "")
enhanced_desc = SKILL_DESCRIPTIONS.get(
enhanced_desc = original_desc or SKILL_DESCRIPTIONS.get(
short_name,
original_desc or f"Spec-kit workflow command: {short_name}",
f"Spec-kit workflow command: {short_name}",
)
frontmatter = dict(frontmatter)
frontmatter["description"] = enhanced_desc
@@ -1417,9 +1417,9 @@ class PresetManager:
)
original_desc = frontmatter.get("description", "")
enhanced_desc = SKILL_DESCRIPTIONS.get(
enhanced_desc = original_desc or SKILL_DESCRIPTIONS.get(
short_name,
original_desc or f"Spec-kit workflow command: {short_name}",
f"Spec-kit workflow command: {short_name}",
)
frontmatter_data = registrar.build_skill_frontmatter(
@@ -1845,20 +1845,20 @@ class PresetCatalog:
)
def _make_request(self, url: str):
"""Build a urllib Request, adding a GitHub auth header when available.
"""Build a urllib Request, adding auth headers when a provider matches.
Delegates to :func:`specify_cli._github_http.build_github_request`.
Delegates to :func:`specify_cli.authentication.http.build_request`.
"""
from specify_cli._github_http import build_github_request
return build_github_request(url)
from specify_cli.authentication.http import build_request
return build_request(url)
def _open_url(self, url: str, timeout: int = 10):
"""Open a URL with GitHub auth, stripping the header on cross-host redirects.
"""Open a URL with provider-based auth, trying each configured provider.
Delegates to :func:`specify_cli._github_http.open_github_url`.
Delegates to :func:`specify_cli.authentication.http.open_url`.
"""
from specify_cli._github_http import open_github_url
return open_github_url(url, timeout)
from specify_cli.authentication.http import open_url
return open_url(url, timeout)
def _load_catalog_config(self, config_path: Path) -> Optional[List[PresetCatalogEntry]]:
"""Load catalog stack configuration from a YAML file.
@@ -1903,12 +1903,24 @@ class PresetCatalog:
if not url:
continue
self._validate_catalog_url(url)
raw_priority = item.get("priority", idx + 1)
# Reject bools explicitly: ``bool`` is a subclass of ``int`` so
# ``int(True)`` silently returns 1, which would let a YAML
# ``priority: true`` slip through as a valid priority of 1. The
# sibling integration-catalog reader in ``catalogs.py`` already
# guards this; mirror the check here so the three catalog
# validators stay consistent.
if isinstance(raw_priority, bool):
raise PresetValidationError(
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
f"expected integer, got {raw_priority!r}"
)
try:
priority = int(item.get("priority", idx + 1))
priority = int(raw_priority)
except (TypeError, ValueError):
raise PresetValidationError(
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
f"expected integer, got {item.get('priority')!r}"
f"expected integer, got {raw_priority!r}"
)
raw_install = item.get("install_allowed", False)
if isinstance(raw_install, str):

View File

@@ -11,6 +11,15 @@ from .integrations.base import IntegrationBase
from .integrations.manifest import IntegrationManifest
class SymlinkedSharedPathError(ValueError):
"""Raised when a shared infrastructure path or ancestor is a symlink.
Distinct from other unsafe-path errors so callers can preserve symlinked
destinations as customizations while still letting genuine safety errors
(e.g. path escape, not-a-directory) propagate and abort the operation.
"""
def load_speckit_manifest(
project_path: Path,
*,
@@ -89,7 +98,7 @@ def _ensure_safe_shared_directory(project_path: Path, directory: Path, *, create
current = current / part
label = _shared_destination_label(project_path, current)
if current.is_symlink():
raise ValueError(f"Refusing to use symlinked shared infrastructure directory: {label}")
raise SymlinkedSharedPathError(f"Refusing to use symlinked shared infrastructure directory: {label}")
if current.exists():
if not current.is_dir():
raise ValueError(f"Shared infrastructure directory path is not a directory: {label}")
@@ -102,7 +111,7 @@ def _ensure_safe_shared_directory(project_path: Path, directory: Path, *, create
raise ValueError(f"Shared infrastructure directory does not exist: {label}")
current.mkdir()
if current.is_symlink():
raise ValueError(f"Refusing to use symlinked shared infrastructure directory: {label}")
raise SymlinkedSharedPathError(f"Refusing to use symlinked shared infrastructure directory: {label}")
try:
current.resolve().relative_to(root)
except (OSError, ValueError):
@@ -119,7 +128,7 @@ def _validate_safe_shared_directory(project_path: Path, directory: Path) -> None
current = current / part
label = _shared_destination_label(project_path, current)
if current.is_symlink():
raise ValueError(f"Refusing to use symlinked shared infrastructure directory: {label}")
raise SymlinkedSharedPathError(f"Refusing to use symlinked shared infrastructure directory: {label}")
if not current.exists():
continue
if not current.is_dir():
@@ -145,7 +154,7 @@ def _ensure_safe_shared_destination(
_validate_safe_shared_directory(project_path, dest.parent)
label = _shared_destination_label(project_path, dest)
if dest.is_symlink():
raise ValueError(f"Refusing to overwrite symlinked shared infrastructure path: {label}")
raise SymlinkedSharedPathError(f"Refusing to overwrite symlinked shared infrastructure path: {label}")
if dest.exists():
try:
@@ -242,58 +251,147 @@ def install_shared_infra(
console: Any,
force: bool = False,
invoke_separator: str = ".",
refresh_managed: bool = False,
refresh_hint: str | None = None,
) -> bool:
"""Install shared scripts and templates into *project_path*."""
"""Install shared scripts and templates into *project_path*.
When ``refresh_managed`` is True, files whose on-disk hash still matches
the previously recorded manifest hash are overwritten with the bundled
version. Files whose hash diverges are treated as user customizations and
preserved with a warning. ``force=True`` overwrites every regular file
(symlinks and symlinked-parent destinations are always preserved with a
warning — the safe-destination check refuses to follow them so writes
cannot escape the project root). ``refresh_hint`` is shown after the
customization warning to tell the user which flag would overwrite their
customizations.
"""
from .integrations.manifest import _sha256
manifest = load_speckit_manifest(project_path, version=version, console=console)
prior_hashes = dict(manifest.files)
def _is_managed(rel: str, dst: Path) -> bool:
expected = prior_hashes.get(rel)
if not expected or not dst.is_file() or dst.is_symlink():
return False
try:
return _sha256(dst) == expected
except OSError:
return False
skipped_files: list[str] = []
preserved_user_files: list[str] = []
symlinked_files: list[str] = []
planned_copies: list[tuple[Path, str, bytes, int]] = []
planned_templates: list[tuple[Path, str, str]] = []
def _decide_overwrite(rel: str, dst: Path) -> tuple[bool, str | None]:
"""Return (write, bucket) where bucket is 'skip', 'preserved', or None."""
if not dst.exists():
return True, None
if force:
return True, None
if refresh_managed:
if _is_managed(rel, dst):
return True, None
if rel in prior_hashes:
return False, "preserved"
return False, "skip"
return False, "skip"
def _safe_dest_or_bucket(dst: Path, rel: str, *, parent_must_exist: bool = True) -> bool:
"""Run the safe-destination check and bucket symlinked paths.
Returns True when the destination is safe to consider (write or skip).
Returns False (and records *rel* under ``symlinked_files``) when the
destination or any of its ancestors is a symlink — those paths can't
be written to safely, but they shouldn't abort the whole switch
either. They're surfaced as a separate "symlinked" warning bucket.
Other unsafe-path errors (e.g. path escape, parent-not-a-directory)
are NOT caught here: they re-raise so the operation aborts, since
treating them as "symlinked" would mask security-relevant failures.
"""
try:
_ensure_safe_shared_destination(project_path, dst, parent_must_exist=parent_must_exist)
except SymlinkedSharedPathError:
symlinked_files.append(rel)
return False
return True
def _ensure_or_bucket_dir(directory: Path) -> bool:
"""Create *directory* unless an ancestor is symlinked.
Returns True when the directory is safe to use. Returns False (and
records the path under ``symlinked_files``) when a symlink ancestor
forces us to skip the whole subtree. Other unsafe-path errors
(escape, not-a-directory) re-raise so the operation aborts.
"""
try:
_ensure_safe_shared_directory(project_path, directory)
except SymlinkedSharedPathError:
symlinked_files.append(directory.relative_to(project_path).as_posix())
return False
return True
scripts_src = shared_scripts_source(core_pack=core_pack, repo_root=repo_root)
if scripts_src.is_dir():
dest_scripts = project_path / ".specify" / "scripts"
_ensure_safe_shared_directory(project_path, dest_scripts)
variant_dir = "bash" if script_type == "sh" else "powershell"
variant_src = scripts_src / variant_dir
if variant_src.is_dir():
dest_variant = dest_scripts / variant_dir
_ensure_safe_shared_directory(project_path, dest_variant)
for src_path in variant_src.rglob("*"):
if not src_path.is_file():
continue
if _ensure_or_bucket_dir(dest_scripts):
variant_dir = "bash" if script_type == "sh" else "powershell"
variant_src = scripts_src / variant_dir
if variant_src.is_dir():
dest_variant = dest_scripts / variant_dir
if _ensure_or_bucket_dir(dest_variant):
for src_path in variant_src.rglob("*"):
if not src_path.is_file():
continue
rel_path = src_path.relative_to(variant_src)
dst_path = dest_variant / rel_path
_ensure_safe_shared_destination(project_path, dst_path, parent_must_exist=False)
if dst_path.exists() and not force:
skipped_files.append(dst_path.relative_to(project_path).as_posix())
continue
rel_path = src_path.relative_to(variant_src)
dst_path = dest_variant / rel_path
rel = dst_path.relative_to(project_path).as_posix()
if not _safe_dest_or_bucket(dst_path, rel, parent_must_exist=False):
continue
write, bucket = _decide_overwrite(rel, dst_path)
if not write:
if bucket == "preserved":
preserved_user_files.append(rel)
else:
skipped_files.append(rel)
continue
_ensure_safe_shared_directory(project_path, dst_path.parent)
rel = dst_path.relative_to(project_path).as_posix()
planned_copies.append((dst_path, rel, src_path.read_bytes(), src_path.stat().st_mode & 0o777))
if not _ensure_or_bucket_dir(dst_path.parent):
continue
planned_copies.append((dst_path, rel, src_path.read_bytes(), src_path.stat().st_mode & 0o777))
templates_src = shared_templates_source(core_pack=core_pack, repo_root=repo_root)
if templates_src.is_dir():
dest_templates = project_path / ".specify" / "templates"
_ensure_safe_shared_directory(project_path, dest_templates)
for src in templates_src.iterdir():
if not src.is_file() or src.name == "vscode-settings.json" or src.name.startswith("."):
continue
if _ensure_or_bucket_dir(dest_templates):
for src in templates_src.iterdir():
if not src.is_file() or src.name == "vscode-settings.json" or src.name.startswith("."):
continue
dst = dest_templates / src.name
_ensure_safe_shared_destination(project_path, dst)
if dst.exists() and not force:
skipped_files.append(dst.relative_to(project_path).as_posix())
continue
dst = dest_templates / src.name
rel = dst.relative_to(project_path).as_posix()
if not _safe_dest_or_bucket(dst, rel):
continue
write, bucket = _decide_overwrite(rel, dst)
if not write:
if bucket == "preserved":
preserved_user_files.append(rel)
else:
skipped_files.append(rel)
continue
content = src.read_text(encoding="utf-8")
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
rel = dst.relative_to(project_path).as_posix()
planned_templates.append((dst, rel, content))
content = src.read_text(encoding="utf-8")
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
planned_templates.append((dst, rel, content))
for dst_path, rel, content, mode in planned_copies:
_ensure_safe_shared_directory(project_path, dst_path.parent)
if not _ensure_or_bucket_dir(dst_path.parent):
continue
_write_shared_bytes(project_path, dst_path, content, mode=mode)
manifest.record_existing(rel)
@@ -307,11 +405,37 @@ def install_shared_infra(
)
for path in skipped_files:
console.print(f" {path}")
if refresh_managed and refresh_hint:
console.print(refresh_hint)
else:
console.print(
"To refresh shared infrastructure, run "
"[cyan]specify init --here --force[/cyan] or "
"[cyan]specify integration upgrade --force[/cyan]."
)
if symlinked_files:
console.print(
"To refresh shared infrastructure, run "
"[cyan]specify init --here --force[/cyan] or "
"[cyan]specify integration upgrade --force[/cyan]."
f"[yellow]⚠[/yellow] Skipped {len(symlinked_files)} symlinked shared "
"infrastructure path(s) — symlinks are never overwritten because they "
"may resolve outside the project root:"
)
for path in symlinked_files:
console.print(f" {path}")
console.print(
"To restore the bundled version, remove or replace the symlink manually, "
"then re-run the command."
)
if preserved_user_files:
console.print(
f"[yellow]⚠[/yellow] Preserved {len(preserved_user_files)} customized shared "
"infrastructure file(s) (hash differs from previous install):"
)
for path in preserved_user_files:
console.print(f" {path}")
if refresh_hint:
console.print(refresh_hint)
manifest.save()
return True

View File

@@ -322,7 +322,7 @@ class WorkflowCatalog:
# Fetch from URL — validate scheme before opening and after redirects
from urllib.parse import urlparse
from urllib.request import urlopen
from specify_cli.authentication.http import open_url as _open_url
def _validate_catalog_url(url: str) -> None:
parsed = urlparse(url)
@@ -337,7 +337,7 @@ class WorkflowCatalog:
_validate_catalog_url(entry.url)
try:
with urlopen(entry.url, timeout=30) as resp: # noqa: S310
with _open_url(entry.url, timeout=30) as resp:
_validate_catalog_url(resp.geturl())
data = json.loads(resp.read().decode("utf-8"))
except Exception as exc:

View File

@@ -19,6 +19,10 @@ from typing import Any
import yaml
from ..integration_state import (
default_integration_key,
try_read_integration_json,
)
from .base import RunStatus, StepContext, StepResult, StepStatus
@@ -143,6 +147,35 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
f"Must be 'string', 'number', or 'boolean'."
)
# Validate the default eagerly so authoring mistakes (e.g. a
# default not in the declared enum, or a non-numeric default for
# a number input) surface at install/validation time instead of
# at workflow-execution time. ``"auto"`` for the integration
# input is a runtime-resolved sentinel, so only the
# enum-membership check is exempted for that exact case — the
# declared type is still enforced (e.g. ``type: number`` paired
# with ``default: "auto"`` is still rejected).
if "default" in input_def:
default_value = input_def["default"]
is_auto_integration = (
input_name == "integration" and default_value == "auto"
)
validation_input_def: dict[str, Any] = input_def
if is_auto_integration and "enum" in input_def:
validation_input_def = {
key: value
for key, value in input_def.items()
if key != "enum"
}
try:
WorkflowEngine._coerce_input(
input_name, default_value, validation_input_def
)
except ValueError as exc:
errors.append(
f"Input {input_name!r} has invalid default: {exc}"
)
# -- Steps ------------------------------------------------------------
if not isinstance(definition.steps, list):
errors.append("'steps' must be a list.")
@@ -640,22 +673,29 @@ class WorkflowEngine:
if not evaluate_condition(condition, context):
break
# Namespace nested step IDs per iteration
iter_steps = []
for ns in result.next_steps:
# so logs and state keys are unique.
# Execute one step at a time and alias each
# result back to the unprefixed key so that
# later steps in the same body and the loop
# condition see the latest values.
for ns_idx, ns in enumerate(result.next_steps):
ns_copy = dict(ns)
if "id" in ns_copy:
ns_copy["id"] = f"{step_id}:{ns_copy['id']}:{_loop_iter + 1}"
iter_steps.append(ns_copy)
self._execute_steps(
iter_steps, context, state, registry,
step_offset=-1,
)
if state.status in (
RunStatus.PAUSED,
RunStatus.FAILED,
RunStatus.ABORTED,
):
return
orig = ns_copy.get("id")
base_id = orig or f"step-{ns_idx}"
ns_copy["id"] = f"{step_id}:{base_id}:{_loop_iter + 1}"
self._execute_steps(
[ns_copy], context, state, registry,
step_offset=-1,
)
if state.status in (
RunStatus.PAUSED,
RunStatus.FAILED,
RunStatus.ABORTED,
):
return
if orig and ns_copy["id"] in context.steps:
context.steps[orig] = context.steps[ns_copy["id"]]
state.step_results[orig] = context.steps[ns_copy["id"]]
# Fan-out: execute nested step template per item with unique IDs
if step_type == "fan-out":
@@ -711,16 +751,73 @@ class WorkflowEngine:
if not isinstance(input_def, dict):
continue
if name in provided:
resolved[name] = self._coerce_input(
name, provided[name], input_def
)
# Resolve sentinels for explicitly-provided values too: a
# caller passing ``{"integration": "auto"}`` (which the
# workflow prompt advertises as a valid value) must be
# treated identically to omitting the input and letting the
# default flow through, so dispatch never sees the literal
# sentinel.
value = self._resolve_default(name, provided[name])
elif "default" in input_def:
resolved[name] = input_def["default"]
value = self._resolve_default(name, input_def["default"])
elif input_def.get("required", False):
msg = f"Required input {name!r} not provided."
raise ValueError(msg)
else:
continue
# When the ``integration`` default could not be resolved against
# project state and falls back to the literal ``"auto"``
# sentinel, strip ``enum`` from the input definition before
# coercion so a workflow that lists specific integrations in
# ``enum`` does not crash at runtime on the sentinel value.
# NOTE: only enum-membership is skipped; ``_coerce_input``
# still enforces the declared ``type`` against the filtered
# definition (``string`` rejects non-strings, ``number`` rejects
# bools and uncoercible values, ``boolean`` rejects non-bools),
# so ill-typed values still fail fast here.
coerce_input_def = input_def
if (
name == "integration"
and value == "auto"
and "enum" in input_def
):
coerce_input_def = {
key: val
for key, val in input_def.items()
if key != "enum"
}
resolved[name] = self._coerce_input(name, value, coerce_input_def)
return resolved
def _resolve_default(self, name: str, default: Any) -> Any:
"""Resolve special default sentinels against project state.
For the ``integration`` input, ``"auto"`` resolves to the integration
recorded in ``.specify/integration.json`` so workflows dispatch to the
AI the project was actually initialized with, instead of a hardcoded
value baked into the workflow YAML.
"""
if name == "integration" and default == "auto":
resolved = self._load_project_integration()
if resolved is not None:
return resolved
return default
def _load_project_integration(self) -> str | None:
"""Read the default integration key from ``.specify/integration.json``.
Delegates parsing and schema validation to
:func:`try_read_integration_json` — the same low-level helper used by
the CLI — so the engine cannot drift from CLI behavior on the parse
path. Returns ``None`` when the file is missing, malformed, or
written by a newer CLI; callers fall back to the literal default.
"""
state, error = try_read_integration_json(self.project_root)
if state is None or error is not None:
return None
return default_integration_key(state)
@staticmethod
def _coerce_input(
name: str, value: Any, input_def: dict[str, Any]
@@ -730,6 +827,13 @@ class WorkflowEngine:
enum_values = input_def.get("enum")
if input_type == "number":
# Reject bools explicitly: ``bool`` is a subclass of ``int`` so
# ``float(True)`` succeeds and would silently coerce a YAML
# authoring mistake like ``type: number`` + ``default: true``
# into ``1``. Fail fast instead.
if isinstance(value, bool):
msg = f"Input {name!r} expected a number, got {value!r}."
raise ValueError(msg)
try:
value = float(value)
if value == int(value):
@@ -746,6 +850,17 @@ class WorkflowEngine:
else:
msg = f"Input {name!r} expected a boolean, got {value!r}."
raise ValueError(msg)
elif not isinstance(value, bool):
msg = f"Input {name!r} expected a boolean, got {value!r}."
raise ValueError(msg)
elif input_type == "string":
# Without this, ``type: string`` accepts any Python value
# (numbers, lists, dicts) because nothing else rejects it —
# YAML ``default: 5`` would slip through. Require an actual
# string so authoring mistakes fail at resolve time.
if not isinstance(value, str):
msg = f"Input {name!r} expected a string, got {value!r}."
raise ValueError(msg)
if enum_values is not None and value not in enum_values:
msg = (

View File

@@ -1,6 +1,7 @@
# Implementation Plan: [FEATURE]
**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link]
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
**Note**: This template is filled in by the `__SPECKIT_COMMAND_PLAN__` command. See `.specify/templates/plan-template.md` for the execution workflow.
@@ -17,14 +18,22 @@
the iteration process.
-->
**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION]
**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION]
**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION]
**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION]
**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION]
**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION]
**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION]
**Project Type**: [e.g., library/cli/web-service/mobile-app/compiler/desktop-app or NEEDS CLARIFICATION]
**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION]
**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION]
**Project Type**: [e.g., library/cli/web-service/mobile-app/compiler/desktop-app or NEEDS CLARIFICATION]
**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION]
**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION]
**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION]
## Constitution Check

View File

@@ -1,8 +1,11 @@
# Feature Specification: [FEATURE NAME]
**Feature Branch**: `[###-feature-name]`
**Created**: [DATE]
**Status**: Draft
**Feature Branch**: `[###-feature-name]`
**Created**: [DATE]
**Status**: Draft
**Input**: User description: "$ARGUMENTS"
## User Scenarios & Testing *(mandatory)*
@@ -11,7 +14,7 @@
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
you should still have a viable MVP (Minimum Viable Product) that delivers value.
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
Think of each story as a standalone slice of functionality that can be:
- Developed independently
@@ -85,7 +88,7 @@
### Functional Requirements
- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"]
- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"]
- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"]
- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"]
- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"]
- **FR-005**: System MUST [behavior, e.g., "log all security events"]

View File

@@ -6,6 +6,7 @@ description: "Task list template for feature implementation"
# Tasks: [FEATURE NAME]
**Input**: Design documents from `/specs/[###-feature-name]/`
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification.
@@ -25,21 +26,21 @@ description: "Task list template for feature implementation"
- **Mobile**: `api/src/`, `ios/src/` or `android/src/`
- Paths shown below assume single project - adjust based on plan.md structure
<!--
<!--
============================================================================
IMPORTANT: The tasks below are SAMPLE TASKS for illustration purposes only.
The __SPECKIT_COMMAND_TASKS__ command MUST replace these with actual tasks based on:
- User stories from spec.md (with their priorities P1, P2, P3...)
- Feature requirements from plan.md
- Entities from data-model.md
- Endpoints from contracts/
Tasks MUST be organized by user story so each story can be:
- Implemented independently
- Tested independently
- Delivered as an MVP increment
DO NOT keep these sample tasks in the generated tasks.md file.
============================================================================
-->

21
tests/auth_helpers.py Normal file
View File

@@ -0,0 +1,21 @@
"""Shared test helpers for authentication config injection."""
from __future__ import annotations
from specify_cli.authentication.config import AuthConfigEntry
def make_github_auth_entry(token_env: str = "GH_TOKEN") -> AuthConfigEntry:
"""Build a GitHub ``AuthConfigEntry`` for testing."""
return AuthConfigEntry(
hosts=("github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"),
provider="github",
auth="bearer",
token_env=token_env,
)
def inject_github_config(monkeypatch, token_env: str = "GH_TOKEN") -> None:
"""Inject a GitHub auth.json config entry into the auth HTTP module."""
from specify_cli.authentication import http as _auth_http
monkeypatch.setattr(_auth_http, "_config_override", [make_github_auth_entry(token_env)])

View File

@@ -66,3 +66,18 @@ requires_bash = pytest.mark.skipif(
def strip_ansi(text: str) -> str:
"""Remove ANSI escape codes from Rich-formatted CLI output."""
return _ANSI_ESCAPE_RE.sub("", text)
# ---------------------------------------------------------------------------
# Auth config isolation — prevents tests from reading ~/.specify/auth.json
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _isolate_auth_config(monkeypatch):
"""Ensure no test reads the real ~/.specify/auth.json."""
from specify_cli.authentication import http as _auth_http
monkeypatch.setattr(_auth_http, "_config_override", [])
# Also clear the per-process cache so tests that unset _config_override
# won't see a previously cached real-file result.
monkeypatch.setattr(_auth_http, "_config_cache", None)

View File

@@ -22,6 +22,26 @@ def _normalize_cli_output(output: str) -> str:
return output.strip()
class TestCliDiagnosticFormatting:
def test_cli_error_detail_flattens_newlines(self):
import specify_cli
assert specify_cli._cli_error_detail(RuntimeError("line one\nline two")) == "line one line two"
def test_cli_error_detail_handles_empty_message(self):
import specify_cli
assert specify_cli._cli_error_detail(RuntimeError()) == "RuntimeError"
def test_cli_phase_label_includes_target(self):
import specify_cli
assert (
specify_cli._cli_phase_label("rollback", "integration", "codex")
== "rollback integration 'codex'"
)
class TestInitIntegrationFlag:
def test_integration_and_ai_mutually_exclusive(self, tmp_path):
from typer.testing import CliRunner
@@ -81,6 +101,29 @@ class TestInitIntegrationFlag:
shared_manifest = project / ".specify" / "integrations" / "speckit.manifest.json"
assert shared_manifest.exists()
def test_noninteractive_init_defaults_to_copilot(self, tmp_path, monkeypatch):
from typer.testing import CliRunner
from specify_cli import app
import specify_cli
def fail_select(*_args, **_kwargs):
raise AssertionError("non-interactive init should not open the integration picker")
monkeypatch.setattr(specify_cli, "select_with_arrows", fail_select)
runner = CliRunner()
project = tmp_path / "noninteractive"
result = runner.invoke(app, [
"init", str(project), "--script", "sh", "--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
assert result.exit_code == 0, result.output
assert f"defaulting to '{specify_cli.DEFAULT_INIT_INTEGRATION}'" in result.output
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == specify_cli.DEFAULT_INIT_INTEGRATION
def test_ai_copilot_auto_promotes(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
@@ -151,6 +194,42 @@ class TestInitIntegrationFlag:
assert normalized_output.index("Deprecation Warning") < normalized_output.index("Next Steps")
assert (project / ".myagent" / "commands" / "speckit.plan.md").exists()
def test_init_optional_preset_failure_reports_target_and_continues(
self, tmp_path, monkeypatch
):
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.presets import PresetManager
def fail_install(self, path, version):
raise OSError("preset install exploded\nwith context")
monkeypatch.setattr(PresetManager, "install_from_directory", fail_install)
project = tmp_path / "init-preset-warning"
result = CliRunner().invoke(
app,
[
"init",
str(project),
"--integration",
"copilot",
"--script",
"sh",
"--no-git",
"--preset",
"lean",
],
catch_exceptions=False,
)
normalized = _normalize_cli_output(result.output)
assert result.exit_code == 0, result.output
assert "Failed to install preset 'lean'" in normalized
assert "preset install exploded with context" in normalized
assert "Continuing without the optional preset" in normalized
assert "Project ready" in normalized
def test_ai_claude_here_preserves_preexisting_commands(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
@@ -297,8 +376,8 @@ class TestInitIntegrationFlag:
assert "A new shared manifest will be created" in captured.out
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
def test_shared_infra_refuses_symlinked_script_destination(self, tmp_path):
"""Shared script refreshes must not follow destination symlinks."""
def test_shared_infra_buckets_symlinked_script_destination(self, tmp_path, capsys):
"""Symlinked script destinations are bucketed with a warning; the symlink target is preserved."""
from specify_cli import _install_shared_infra
project = tmp_path / "symlink-script-test"
@@ -311,14 +390,15 @@ class TestInitIntegrationFlag:
scripts_dir.mkdir(parents=True)
os.symlink(outside, scripts_dir / "common.sh")
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
_install_shared_infra(project, "sh", force=True)
_install_shared_infra(project, "sh", force=True)
captured = capsys.readouterr()
assert "symlinked shared infrastructure" in captured.out
assert outside.read_text(encoding="utf-8") == "# outside\n"
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
def test_shared_infra_refuses_symlinked_template_destination(self, tmp_path):
"""Shared template installs must not follow destination symlinks."""
def test_shared_infra_buckets_symlinked_template_destination(self, tmp_path, capsys):
"""Symlinked template destinations are bucketed with a warning; the symlink target is preserved."""
from specify_cli import _install_shared_infra
project = tmp_path / "symlink-template-test"
@@ -331,9 +411,10 @@ class TestInitIntegrationFlag:
templates_dir.mkdir(parents=True)
os.symlink(outside, templates_dir / "plan-template.md")
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
_install_shared_infra(project, "sh", force=True)
_install_shared_infra(project, "sh", force=True)
captured = capsys.readouterr()
assert "symlinked shared infrastructure" in captured.out
assert outside.read_text(encoding="utf-8") == "# outside\n"
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
@@ -358,7 +439,7 @@ class TestInitIntegrationFlag:
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
def test_shared_infra_refuses_symlinked_specify_directory_before_mkdir(self, tmp_path):
"""Shared infra directory creation must not follow a symlinked .specify."""
"""Shared infra installs must not follow a symlinked .specify directory."""
from specify_cli import _install_shared_infra
project = tmp_path / "symlink-dir-test"
@@ -367,8 +448,10 @@ class TestInitIntegrationFlag:
outside.mkdir()
os.symlink(outside, project / ".specify")
with pytest.raises(ValueError, match="symlinked shared infrastructure directory"):
with pytest.raises(ValueError, match="symlinked"):
_install_shared_infra(project, "sh", force=True)
# Nothing should have been written under the symlinked .specify target.
assert list(outside.iterdir()) == []
assert not (outside / "scripts").exists()
assert not (outside / "templates").exists()
@@ -442,8 +525,8 @@ class TestInitIntegrationFlag:
assert outside.read_text(encoding="utf-8") == "# outside\n"
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
def test_shared_infra_install_preflights_before_writing(self, tmp_path):
"""Full shared infra installs validate destinations before writing any file."""
def test_shared_infra_install_buckets_unsafe_destinations_and_continues(self, tmp_path):
"""Symlinked destinations are bucketed with a warning; safe destinations in the same install still complete."""
from specify_cli.shared_infra import install_shared_infra
project = tmp_path / "preflight-install-test"
@@ -463,19 +546,19 @@ class TestInitIntegrationFlag:
outside.write_text("# outside\n", encoding="utf-8")
os.symlink(outside, scripts_dir / "z.sh")
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
install_shared_infra(
project,
"sh",
version="test",
core_pack=core_pack,
repo_root=tmp_path / "unused",
console=_NoopConsole(),
force=True,
)
install_shared_infra(
project,
"sh",
version="test",
core_pack=core_pack,
repo_root=tmp_path / "unused",
console=_NoopConsole(),
force=True,
)
assert existing.read_text(encoding="utf-8") == "# old a\n"
# Symlinked z.sh is preserved (bucketed); regular a.sh is overwritten.
assert outside.read_text(encoding="utf-8") == "# outside\n"
assert existing.read_text(encoding="utf-8") == "# new a\n"
def test_shared_infra_install_supports_nested_script_sources(self, tmp_path):
"""Nested script source files create safe destination parents at write time."""
@@ -1028,6 +1111,143 @@ class TestIntegrationCatalogDiscoveryCLI:
finally:
os.chdir(old)
def test_integration_install_failure_reports_phase_target_and_rollback(
self, tmp_path, monkeypatch
):
from specify_cli.integrations import INTEGRATION_REGISTRY
from specify_cli.integrations.base import IntegrationBase
class BrokenIntegration(IntegrationBase):
key = "broken-test"
config = {
"name": "Broken Test",
"folder": ".broken/",
"commands_subdir": "commands",
"install_url": None,
"requires_cli": False,
}
registrar_config = {
"dir": ".broken/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "BROKEN.md"
def setup(self, project_root, manifest, **kwargs):
raise OSError("setup exploded\nwith context")
def teardown(self, project_root, manifest, force=False):
raise OSError("rollback exploded")
project = self._make_project(tmp_path)
monkeypatch.setitem(INTEGRATION_REGISTRY, "broken-test", BrokenIntegration())
result = self._invoke(["integration", "install", "broken-test"], project)
normalized = _normalize_cli_output(result.output)
assert result.exit_code == 1, result.output
assert "Failed to rollback integration 'broken-test'" in normalized
assert "rollback exploded" in normalized
assert "Failed to install integration 'broken-test'" in normalized
assert "setup exploded with context" in normalized
def test_integration_upgrade_failure_reports_phase_and_target(
self, tmp_path, monkeypatch
):
from specify_cli.integrations import INTEGRATION_REGISTRY
from specify_cli.integrations.copilot import CopilotIntegration
class UpgradeBrokenIntegration(CopilotIntegration):
key = "upgrade-broken"
config = dict(CopilotIntegration.config)
config["name"] = "Upgrade Broken"
def setup(self, project_root, manifest, **kwargs):
raise OSError("upgrade exploded\nwith context")
project = self._make_project(tmp_path)
monkeypatch.setitem(
INTEGRATION_REGISTRY, "upgrade-broken", UpgradeBrokenIntegration()
)
(project / ".specify" / "integrations").mkdir(parents=True, exist_ok=True)
(project / ".specify" / "integration.json").write_text(
json.dumps(
{
"version": 1,
"integration": "upgrade-broken",
"integrations": ["upgrade-broken"],
"integration_settings": {"upgrade-broken": {"script": "sh"}},
}
),
encoding="utf-8",
)
(
project / ".specify" / "integrations" / "upgrade-broken.manifest.json"
).write_text(
json.dumps(
{
"integration": "upgrade-broken",
"version": "0.0.0",
"installed_at": "2026-05-16T00:00:00+00:00",
"files": {},
}
),
encoding="utf-8",
)
result = self._invoke(["integration", "upgrade", "upgrade-broken"], project)
normalized = _normalize_cli_output(result.output)
assert result.exit_code == 1, result.output
assert "Failed to upgrade integration 'upgrade-broken'" in normalized
assert "upgrade exploded with context" in normalized
assert "previous integration files may still be in place" in normalized
def test_integration_switch_cleanup_warning_reports_phase_and_targets(
self, tmp_path, monkeypatch
):
from specify_cli.extensions import ExtensionManager
project = self._make_project(tmp_path)
(project / ".specify" / "integrations").mkdir(parents=True, exist_ok=True)
(project / ".specify" / "integration.json").write_text(
json.dumps(
{
"version": 1,
"integration": "copilot",
"integrations": ["copilot"],
"integration_settings": {"copilot": {"script": "sh"}},
}
),
encoding="utf-8",
)
(project / ".specify" / "integrations" / "copilot.manifest.json").write_text(
json.dumps(
{
"integration": "copilot",
"version": "0.0.0",
"installed_at": "2026-05-16T00:00:00+00:00",
"files": {},
}
),
encoding="utf-8",
)
def fail_cleanup(self, integration_key):
raise OSError("cleanup exploded")
monkeypatch.setattr(ExtensionManager, "unregister_agent_artifacts", fail_cleanup)
result = self._invoke(["integration", "switch", "claude"], project)
normalized = _normalize_cli_output(result.output)
assert result.exit_code == 0, result.output
assert "Failed to clean up extension artifacts for integration 'copilot'" in normalized
assert "cleanup exploded" in normalized
assert "Switched to integration" in normalized
# -- Project guard -----------------------------------------------------
def test_search_requires_specify_project(self, tmp_path):
@@ -1192,6 +1412,30 @@ class TestIntegrationCatalogDiscoveryCLI:
assert "contains invalid JSON" in normalized_output
assert "integration.json" in normalized_output
def test_search_rejects_non_utf8_integration_json_before_catalog_lookup(
self, tmp_path, monkeypatch
):
"""A non-UTF8 ``integration.json`` must surface a clear error and
avoid falling through to the catalog lookup, mirroring the malformed-JSON
case but for the ``UnicodeDecodeError`` branch in ``_read_integration_json``."""
project = self._make_project(tmp_path)
# 0xFF is invalid as the leading byte of any UTF-8 sequence, so
# ``Path.read_text(encoding="utf-8")`` raises ``UnicodeDecodeError``.
(project / ".specify" / "integration.json").write_bytes(b"\xff\xfe\x00\x00")
from specify_cli.integrations.catalog import IntegrationCatalog
def fail_search(self, **kwargs):
raise AssertionError("catalog search should not be called")
monkeypatch.setattr(IntegrationCatalog, "search", fail_search)
result = self._invoke(["integration", "search"], project)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 1
assert "not valid UTF-8" in normalized_output
assert "integration.json" in normalized_output
def test_search_filters_by_tag(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
self._patch_catalog(monkeypatch)

View File

@@ -166,12 +166,12 @@ class TestCatalogFetch:
"""Tests that use a local HTTP server stub via monkeypatch."""
def _patch_urlopen(self, monkeypatch, catalog_data):
"""Patch urllib.request.urlopen to return *catalog_data*."""
"""Patch authentication.http.urllib.request.urlopen to return *catalog_data*."""
class FakeResponse:
def __init__(self, data, url=""):
self._data = json.dumps(data).encode()
self._url = url
self._url = url if isinstance(url, str) else url.full_url
def read(self):
return self._data
@@ -185,11 +185,12 @@ class TestCatalogFetch:
def __exit__(self, *a):
pass
def fake_urlopen(url, timeout=10):
def fake_urlopen(req, timeout=10):
url = req if isinstance(req, str) else req.full_url
return FakeResponse(catalog_data, url)
import urllib.request
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
import specify_cli.authentication.http as _auth_http
monkeypatch.setattr(_auth_http.urllib.request, "urlopen", fake_urlopen)
def test_fetch_and_search_all(self, tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
@@ -486,12 +487,12 @@ class TestIntegrationListCatalog:
},
}
import urllib.request
import specify_cli.authentication.http as _auth_http
class FakeResponse:
def __init__(self, data, url=""):
self._data = json.dumps(data).encode()
self._url = url
self._url = url if isinstance(url, str) else url.full_url
def read(self):
return self._data
def geturl(self):
@@ -501,7 +502,8 @@ class TestIntegrationListCatalog:
def __exit__(self, *a):
pass
monkeypatch.setattr(urllib.request, "urlopen", lambda url, timeout=10: FakeResponse(catalog, url))
monkeypatch.setattr(_auth_http.urllib.request, "urlopen",
lambda req, timeout=10: FakeResponse(catalog, req if isinstance(req, str) else req.full_url))
old = os.getcwd()
try:

View File

@@ -196,7 +196,10 @@ class TestClaudeIntegration:
try:
os.chdir(project)
runner = CliRunner()
with patch("specify_cli.select_with_arrows", return_value="claude"):
with (
patch("specify_cli._stdin_is_interactive", return_value=True),
patch("specify_cli.select_with_arrows", return_value="claude"),
):
result = runner.invoke(
app,
[
@@ -484,13 +487,15 @@ class TestClaudeDisableModelInvocation:
assert "disable-model-invocation" not in fm
assert "user-invocable" not in fm
def test_non_claude_post_process_is_identity(self, tmp_path):
"""Non-Claude integrations should not modify skill content."""
codex = get_integration("codex")
if codex is None:
return # codex not registered in this build
def test_skills_default_post_process_is_identity(self, tmp_path):
"""SkillsIntegration agents without an override leave content unchanged."""
# ``agy`` is a plain SkillsIntegration with no post-process override,
# so it stands in for the base-class default behavior.
agy = get_integration("agy")
if agy is None:
return # agy not registered in this build
content = "---\nname: test\n---\nBody"
assert codex.post_process_skill_content(content) == content
assert agy.post_process_skill_content(content) == content
class TestClaudeHookCommandNote:

View File

@@ -1,5 +1,8 @@
"""Tests for CodexIntegration."""
from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest
from .test_integration_base_skills import SkillsIntegrationTests
@@ -25,3 +28,89 @@ class TestCodexAutoPromote:
assert result.exit_code == 0, f"init --ai codex failed: {result.output}"
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
class TestCodexHookCommandNote:
"""Verify dot-to-hyphen normalization note is injected in hook sections.
Hook commands in ``extensions.yml`` use dotted ids like
``speckit.git.commit`` but Codex skills are named with hyphens
(``speckit-git-commit``). Without this note, Codex emits
``/speckit.git.commit``, which does not resolve.
"""
def test_hook_note_injected_in_skills_with_hooks(self, tmp_path):
"""Skills that have hook sections should get the normalization note."""
i = get_integration("codex")
m = IntegrationManifest("codex", tmp_path)
i.setup(tmp_path, m, script_type="sh")
specify_skill = tmp_path / ".agents/skills/speckit-specify/SKILL.md"
assert specify_skill.exists()
content = specify_skill.read_text(encoding="utf-8")
assert "replace dots" in content, (
"speckit-specify should have dot-to-hyphen hook note"
)
def test_hook_note_not_in_skills_without_hooks(self):
"""Skills without hook sections should not get the note."""
from specify_cli.integrations.codex import CodexIntegration
content = "---\nname: test\ndescription: test\n---\n\nNo hooks here.\n"
result = CodexIntegration._inject_hook_command_note(content)
assert "replace dots" not in result
def test_hook_note_idempotent(self):
"""Injecting the note twice should not duplicate it."""
from specify_cli.integrations.codex import CodexIntegration
content = (
"---\nname: test\n---\n\n"
"- For each executable hook, output the following based on its flag:\n"
)
once = CodexIntegration._inject_hook_command_note(content)
twice = CodexIntegration._inject_hook_command_note(once)
assert once == twice, "Hook note injection should be idempotent"
def test_hook_note_preserves_indentation(self):
"""The injected note should match the indentation of the target line."""
from specify_cli.integrations.codex import CodexIntegration
content = (
"---\nname: test\n---\n\n"
" - For each executable hook, output the following\n"
)
result = CodexIntegration._inject_hook_command_note(content)
lines = result.splitlines()
note_line = [l for l in lines if "replace dots" in l][0]
assert note_line.startswith(" "), "Note should preserve indentation"
def test_hook_note_when_instruction_is_final_line_without_newline(self):
"""Note must not collapse onto the instruction line when the file
ends without a trailing newline and the preceding line is not blank.
"""
from specify_cli.integrations.codex import CodexIntegration
# No blank line before the instruction and no trailing newline:
# this is the case where the captured ``eol`` is empty and the
# captured indent is also empty, so a missing line separator would
# cause the note and instruction to collapse onto one line.
content = (
"---\nname: test\n---\n"
"Body line\n"
"- For each executable hook, output the following"
)
result = CodexIntegration._inject_hook_command_note(content)
lines = result.splitlines()
note_line_idx = next(
i for i, l in enumerate(lines) if "replace dots" in l
)
instruction_line_idx = next(
i for i, l in enumerate(lines)
if l.lstrip().startswith("- For each executable hook")
)
assert note_line_idx < instruction_line_idx, (
"Note must appear before the instruction"
)
assert "For each executable hook" not in lines[note_line_idx], (
"Note and instruction must not be on the same line"
)

View File

@@ -141,6 +141,7 @@ class TestForgeIntegration:
assert actual_commands == expected_commands
def test_templates_are_processed(self, tmp_path):
import re
from specify_cli.integrations.forge import ForgeIntegration
forge = ForgeIntegration()
m = IntegrationManifest("forge", tmp_path)
@@ -157,6 +158,11 @@ class TestForgeIntegration:
assert "$ARGUMENTS" not in content, f"{cmd_file.name} has unprocessed $ARGUMENTS"
# Frontmatter sections should be stripped
assert "\nscripts:\n" not in content
# Check Forge-specific: command references use hyphen notation, not dot notation
assert not re.search(r"/speckit\.[a-z]", content), (
f"{cmd_file.name} contains dot-notation command reference (/speckit.<cmd>); "
"Forge requires hyphen notation (/speckit-<cmd>) for ZSH compatibility"
)
def test_plan_references_correct_context_file(self, tmp_path):
"""The generated plan command must reference forge's context file."""
@@ -224,6 +230,33 @@ class TestForgeIntegration:
"checklist should contain {{parameters}} in User Input section"
)
def test_command_refs_use_hyphen_notation(self, tmp_path):
"""Verify all generated Forge command files use /speckit-foo, not /speckit.foo."""
import re
from specify_cli.integrations.forge import ForgeIntegration
forge = ForgeIntegration()
m = IntegrationManifest("forge", tmp_path)
forge.setup(tmp_path, m)
commands_dir = tmp_path / ".forge" / "commands"
files_with_refs = []
files_with_dot_refs = []
for cmd_file in commands_dir.glob("speckit.*.md"):
content = cmd_file.read_text(encoding="utf-8")
if re.search(r"/speckit-[a-z]", content):
files_with_refs.append(cmd_file.name)
if re.search(r"/speckit\.[a-z]", content):
files_with_dot_refs.append(cmd_file.name)
assert files_with_dot_refs == [], (
f"Files contain dot-notation command references: {files_with_dot_refs}. "
"Forge requires hyphen notation (/speckit-<cmd>) for ZSH compatibility."
)
assert len(files_with_refs) > 0, (
"Expected at least one generated Forge command to contain /speckit-<cmd> reference, "
"but none were found. Check that __SPECKIT_COMMAND_*__ tokens are being resolved."
)
def test_name_field_uses_hyphenated_format(self, tmp_path):
"""Verify that injected name fields use hyphenated format (speckit-plan, not speckit.plan)."""
from specify_cli.integrations.forge import ForgeIntegration
@@ -401,3 +434,48 @@ class TestForgeCommandRegistrar:
assert "name:" not in content, (
"Windsurf should not inject name field - format_name callback should be Forge-only"
)
def test_git_extension_command_uses_hyphen_notation(self, tmp_path):
"""Verify the git extension's feature command uses /speckit-specify (not /speckit.specify) for Forge."""
from pathlib import Path
from specify_cli.agents import CommandRegistrar
# Locate the real git extension command source file
repo_root = Path(__file__).resolve().parent.parent.parent
ext_dir = repo_root / "extensions" / "git"
cmd_source = ext_dir / "commands" / "speckit.git.feature.md"
assert cmd_source.exists(), (
f"Git extension command source not found at {cmd_source}. "
"Ensure extensions/git/commands/speckit.git.feature.md exists."
)
registrar = CommandRegistrar()
commands = [
{
"name": "speckit.git.feature",
"file": "commands/speckit.git.feature.md",
}
]
registered = registrar.register_commands(
"forge",
commands,
"git",
ext_dir,
tmp_path,
)
assert "speckit.git.feature" in registered
forge_cmd = tmp_path / ".forge" / "commands" / "speckit.git.feature.md"
assert forge_cmd.exists(), "Expected Forge command file was not created"
content = forge_cmd.read_text(encoding="utf-8")
assert "/speckit-specify" in content, (
"Expected '/speckit-specify' (hyphen) in generated Forge git.feature command body, "
"but it was not found. Check that __SPECKIT_COMMAND_SPECIFY__ is resolved correctly."
)
assert "/speckit.specify" not in content, (
"Found '/speckit.specify' (dot notation) in generated Forge git.feature command body. "
"Forge requires hyphen notation for ZSH compatibility."
)

View File

@@ -1,5 +1,9 @@
"""Tests for GooseIntegration."""
import yaml
from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest
from .test_integration_base_yaml import YamlIntegrationTests
@@ -9,3 +13,27 @@ class TestGooseIntegration(YamlIntegrationTests):
COMMANDS_SUBDIR = "recipes"
REGISTRAR_DIR = ".goose/recipes"
CONTEXT_FILE = "AGENTS.md"
def test_setup_declares_args_parameter_for_args_prompt(self, tmp_path):
# “If a generated Goose recipe uses {{args}} in its prompt, it
# must declare a corresponding args parameter.”
integration = get_integration("goose")
assert integration is not None
manifest = IntegrationManifest("goose", tmp_path)
created = integration.setup(tmp_path, manifest, script_type="sh")
recipe_files = [path for path in created if path.suffix == ".yaml"]
assert recipe_files
for recipe_file in recipe_files:
data = yaml.safe_load(recipe_file.read_text(encoding="utf-8"))
if "{{args}}" not in data["prompt"]:
continue
assert any(
param.get("key") == "args"
for param in data.get("parameters", [])
), f"{recipe_file} uses {{{{args}}}} but does not declare args"

View File

@@ -1,10 +1,41 @@
"""Tests for KiroCliIntegration."""
import os
import re
from specify_cli.integrations import get_integration
from specify_cli.integrations.kiro_cli import _KIRO_ARG_FALLBACK
from specify_cli.integrations.manifest import IntegrationManifest
from .test_integration_base_markdown import MarkdownIntegrationTests
# Regex shapes that indicate a value is a placeholder token, not prose.
# Covers Bash ($VAR, ${VAR}, ${VAR:-default}), Mustache/Handlebars/Jinja
# ({{var}}, {{{var}}}), Liquid/Jinja control ({% ... %}), Python str.format /
# .NET ({var}, {0}), angle-bracket (<var>), and Windows-style (%VAR%).
# Anchored to the FULL STRING so legitimate prose mentioning a placeholder
# (e.g. "the {{magic}} of placeholders") is not flagged. The Liquid pattern
# is anchored to the START so multi-tag templates fire while mid-sentence
# {%-quotation does not.
_PLACEHOLDER_TOKEN_PATTERNS = (
re.compile(r"^\$\w+$"), # $ARGUMENTS, $args
re.compile(r"^\$\{\w+(?:[:\-+?][^}]*)?\}$"), # ${ARGS}, ${ARGS:-default}
re.compile(r"^\{\{\{?\s*\w+(\s*[|.][^}]*)?\s*\}?\}\}$"), # {{var}} {{{var}}} {{x|y}}
re.compile(r"^\{%"), # {% if x %}{{ x }}{% endif %}
re.compile(r"^<\w+>$"), # <args>
re.compile(r"^%\w+%$"), # %USERNAME%
re.compile(r"^\{(?:\d+|[a-zA-Z_]\w*)(?:[.\[][^}]*)?(?:![rsa])?(?::[^}]*)?\}$"), # {0}, {var}, {0:>5}
)
def _looks_like_placeholder_token(value: str) -> bool:
"""Return True if *value* matches a known placeholder-token shape."""
if not value:
return False
return any(p.search(value) for p in _PLACEHOLDER_TOKEN_PATTERNS)
class TestKiroCliIntegration(MarkdownIntegrationTests):
KEY = "kiro-cli"
FOLDER = ".kiro/"
@@ -12,6 +43,85 @@ class TestKiroCliIntegration(MarkdownIntegrationTests):
REGISTRAR_DIR = ".kiro/prompts"
CONTEXT_FILE = "AGENTS.md"
def test_registrar_config(self):
"""Override base assertion: kiro-cli uses a prose fallback for args
because Kiro CLI file-based prompts do not natively substitute
``$ARGUMENTS`` (see issue #1926 / kirodotdev/Kiro#4141). The
regression-guard load is carried by the two layer tests below
(exact-fallback + placeholder-shape rejection)."""
i = get_integration(self.KEY)
assert i.registrar_config["dir"] == self.REGISTRAR_DIR
assert i.registrar_config["format"] == "markdown"
assert i.registrar_config["extension"] == ".md"
def test_registrar_config_args_is_exact_prose_fallback(self):
"""Layer 1 — pin the exact fallback so wording drift requires a
deliberate paired commit (production constant + test update)."""
i = get_integration(self.KEY)
assert i.registrar_config["args"] == _KIRO_ARG_FALLBACK, (
f"args drifted from the pinned fallback constant. "
f"Got: {i.registrar_config['args']!r}; expected: {_KIRO_ARG_FALLBACK!r}. "
f"If the wording change is intentional, update _KIRO_ARG_FALLBACK and "
f"this test together."
)
def test_registrar_config_args_does_not_look_like_a_placeholder_token(self):
"""Layer 2 — independent regression guard: even if someone bypasses
layer-1 by changing both constant and test, the value still must not
look like ANY placeholder token shape ($X, ${X}, {{X}}, <X>, %X%, {0},
{% %}). Catches the class of regression Copilot called out: a swap
from $ARGUMENTS to $INPUT or {{userMessage}} would fail this test
even if it accidentally passed layer 1."""
i = get_integration(self.KEY)
args = i.registrar_config["args"]
assert not _looks_like_placeholder_token(args), (
f"registrar_config['args'] = {args!r} matches a known placeholder-"
f"token shape — Kiro CLI does not substitute placeholders so this "
f"would reach the model verbatim and break the prompt (issue #1926). "
f"Use a prose fallback instead."
)
def test_rendered_prompts_do_not_contain_raw_arguments(self, tmp_path):
"""Rendered Kiro prompt files must NOT contain the raw ``$ARGUMENTS``
token — Kiro CLI does not substitute it, so the literal would reach
the model and break the prompt (issue #1926)."""
integration = get_integration(self.KEY)
manifest = IntegrationManifest(self.KEY, tmp_path)
integration.setup(tmp_path, manifest, script_type="sh")
prompts_dir = tmp_path / self.REGISTRAR_DIR
rendered = list(prompts_dir.glob("*.md"))
assert rendered, "expected at least one rendered prompt file"
offenders = [
p.name for p in rendered if "$ARGUMENTS" in p.read_text(encoding="utf-8")
]
assert offenders == [], (
f"these rendered prompts still contain the raw $ARGUMENTS token: {offenders}"
)
def test_rendered_prompts_contain_kiro_arg_placeholder(self, tmp_path):
"""The chosen kiro-cli args fallback string must end up in at least
one rendered prompt (proves substitution actually fired, not just
that $ARGUMENTS was removed). Imports the fallback constant directly
instead of reading the field back so the test stays independent of
the integration's own config — even if the registrar_config['args']
regresses, this test still verifies the FALLBACK STRING is in the
rendered output."""
integration = get_integration(self.KEY)
manifest = IntegrationManifest(self.KEY, tmp_path)
integration.setup(tmp_path, manifest, script_type="sh")
expected = _KIRO_ARG_FALLBACK
prompts_dir = tmp_path / self.REGISTRAR_DIR
contents = "\n".join(
p.read_text(encoding="utf-8") for p in prompts_dir.glob("*.md")
)
assert expected in contents, (
f"none of the rendered prompts contain the configured args fallback "
f"({expected!r})"
)
class TestKiroAlias:
"""--ai kiro alias normalizes to kiro-cli and auto-promotes."""

View File

@@ -0,0 +1,11 @@
"""Tests for LingmaIntegration."""
from .test_integration_base_skills import SkillsIntegrationTests
class TestLingmaIntegration(SkillsIntegrationTests):
KEY = "lingma"
FOLDER = ".lingma/"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".lingma/skills"
CONTEXT_FILE = ".lingma/rules/specify-rules.md"

View File

@@ -1,6 +1,10 @@
"""Tests for OpencodeIntegration."""
import warnings
from specify_cli.agents import CommandRegistrar
from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest
from .test_integration_base_markdown import MarkdownIntegrationTests
@@ -8,8 +12,8 @@ from .test_integration_base_markdown import MarkdownIntegrationTests
class TestOpencodeIntegration(MarkdownIntegrationTests):
KEY = "opencode"
FOLDER = ".opencode/"
COMMANDS_SUBDIR = "command"
REGISTRAR_DIR = ".opencode/command"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".opencode/commands"
CONTEXT_FILE = "AGENTS.md"
def test_build_exec_args_uses_run_command_dispatch(self):
@@ -57,3 +61,140 @@ class TestOpencodeIntegration(MarkdownIntegrationTests):
args = integration.build_exec_args("explain this repository", output_json=False)
assert args == ["opencode", "run", "explain this repository"]
def test_registrar_config_has_legacy_dir(self):
integration = get_integration(self.KEY)
assert integration.registrar_config["legacy_dir"] == ".opencode/command"
def test_legacy_dir_extension_registration(self, tmp_path):
"""Extensions register in legacy .opencode/command/ with a warning."""
# Seed a legacy project with only .opencode/command/
legacy_dir = tmp_path / ".opencode" / "command"
legacy_dir.mkdir(parents=True)
(legacy_dir / "speckit.specify.md").write_text("# existing", encoding="utf-8")
# Create a source command file for the registrar
src_dir = tmp_path / "_ext_src"
src_dir.mkdir()
(src_dir / "myext.md").write_text(
"---\ndescription: test\n---\n# ext command", encoding="utf-8",
)
registrar = CommandRegistrar()
commands = [{"name": "speckit.myext", "file": "myext.md"}]
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
results = registrar.register_commands_for_all_agents(
commands, "test-ext", src_dir, tmp_path,
)
# Should have registered in the legacy directory
assert "opencode" in results
assert (legacy_dir / "speckit.myext.md").exists()
# Canonical directory should NOT have been created
assert not (tmp_path / ".opencode" / "commands").exists()
# Should have emitted a deprecation warning
opencode_warnings = [
w for w in caught
if "legacy" in str(w.message) and "opencode" in str(w.message)
]
assert len(opencode_warnings) == 1, (
f"Expected exactly 1 legacy-dir warning, got {len(opencode_warnings)}"
)
assert "specify integration upgrade" in str(opencode_warnings[0].message)
def test_legacy_dir_unregister(self, tmp_path):
"""Unregister finds commands in legacy .opencode/command/ dir."""
legacy_dir = tmp_path / ".opencode" / "command"
legacy_dir.mkdir(parents=True)
cmd_file = legacy_dir / "speckit.myext.md"
cmd_file.write_text("# ext command", encoding="utf-8")
registrar = CommandRegistrar()
with warnings.catch_warnings(record=True):
warnings.simplefilter("always")
registrar.unregister_commands(
{"opencode": ["speckit.myext"]}, tmp_path,
)
assert not cmd_file.exists()
def test_unregister_cleans_legacy_when_both_dirs_exist(self, tmp_path):
"""Unregister removes files from legacy dir even when canonical exists."""
# Set up both canonical and legacy dirs
canonical_dir = tmp_path / ".opencode" / "commands"
canonical_dir.mkdir(parents=True)
legacy_dir = tmp_path / ".opencode" / "command"
legacy_dir.mkdir(parents=True)
# Place a command file in the legacy dir (orphaned after upgrade)
legacy_cmd = legacy_dir / "speckit.myext.md"
legacy_cmd.write_text("# orphaned ext command", encoding="utf-8")
# Place the same command in the canonical dir (current)
canonical_cmd = canonical_dir / "speckit.myext.md"
canonical_cmd.write_text("# ext command", encoding="utf-8")
registrar = CommandRegistrar()
with warnings.catch_warnings(record=True):
warnings.simplefilter("always")
registrar.unregister_commands(
{"opencode": ["speckit.myext"]}, tmp_path,
)
# Both files should be removed
assert not canonical_cmd.exists(), (
"Command file in canonical dir should be removed"
)
assert not legacy_cmd.exists(), (
"Orphaned command file in legacy dir should also be removed"
)
def test_canonical_dir_preferred_over_legacy(self, tmp_path):
"""When both dirs exist, canonical .opencode/commands/ is used."""
legacy_dir = tmp_path / ".opencode" / "command"
legacy_dir.mkdir(parents=True)
canonical_dir = tmp_path / ".opencode" / "commands"
canonical_dir.mkdir(parents=True)
(canonical_dir / "speckit.specify.md").write_text("# cmd", encoding="utf-8")
# Create a source command file for the registrar
src_dir = tmp_path / "_ext_src"
src_dir.mkdir()
(src_dir / "myext.md").write_text(
"---\ndescription: test\n---\n# ext command", encoding="utf-8",
)
registrar = CommandRegistrar()
commands = [{"name": "speckit.myext", "file": "myext.md"}]
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
results = registrar.register_commands_for_all_agents(
commands, "test-ext", src_dir, tmp_path,
)
# Should register in canonical dir, not legacy
assert "opencode" in results
assert (canonical_dir / "speckit.myext.md").exists()
assert not (legacy_dir / "speckit.myext.md").exists()
# No legacy warning when canonical dir exists
opencode_warnings = [
w for w in caught
if "legacy" in str(w.message) and "opencode" in str(w.message)
]
assert len(opencode_warnings) == 0
def test_setup_writes_to_canonical_dir(self, tmp_path):
"""New installs always write to .opencode/commands/ (plural)."""
integration = get_integration(self.KEY)
manifest = IntegrationManifest(self.KEY, tmp_path)
integration.setup(tmp_path, manifest)
canonical = tmp_path / ".opencode" / "commands"
legacy = tmp_path / ".opencode" / "command"
assert canonical.is_dir()
assert not legacy.exists()
assert any(canonical.glob("speckit.*.md"))

View File

@@ -163,7 +163,30 @@ class TestIntegrationInstall:
assert "already installed" in result.output
normalized = " ".join(result.output.split())
assert "specify integration upgrade copilot" in normalized
assert "specify integration uninstall copilot" in normalized
assert "already the default integration" in normalized
assert "No files were changed" in normalized
assert "specify integration uninstall copilot" not in normalized
def test_install_already_installed_non_default_guides_use(self, tmp_path):
project = _init_project(tmp_path, "claude")
old_cwd = os.getcwd()
try:
os.chdir(project)
install = runner.invoke(app, [
"integration", "install", "codex",
"--script", "sh",
], catch_exceptions=False)
assert install.exit_code == 0, install.output
result = runner.invoke(app, ["integration", "install", "codex"])
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
normalized = " ".join(result.output.split())
assert "already installed" in normalized
assert "specify integration use codex" in normalized
assert "specify integration upgrade codex" in normalized
assert "specify integration uninstall codex" not in normalized
def test_install_different_when_one_exists(self, tmp_path):
project = _init_project(tmp_path, "copilot")
@@ -176,7 +199,11 @@ class TestIntegrationInstall:
assert result.exit_code != 0
assert "Installed integrations: copilot" in result.output
assert "Default integration: copilot" in result.output
assert "--force" in result.output
normalized = " ".join(result.output.split())
assert "To replace the default integration" in normalized
assert "specify integration switch claude" in normalized
assert "To install 'claude' alongside" in normalized
assert "retry the same install command with --force" in normalized
def test_install_multi_safe_integration(self, tmp_path):
project = _init_project(tmp_path, "claude")
@@ -261,7 +288,11 @@ class TestIntegrationInstall:
assert result.exit_code != 0
assert "Installed integrations: copilot" in result.output
assert "multi-install safe" in result.output
assert "--force" in result.output
normalized = " ".join(result.output.split())
assert "To replace the default integration" in normalized
assert "specify integration switch claude" in normalized
assert "To install 'claude' alongside" in normalized
assert "retry the same install command with --force" in normalized
def test_install_multi_unsafe_allowed_with_force(self, tmp_path):
project = _init_project(tmp_path, "copilot")
@@ -762,7 +793,7 @@ class TestIntegrationSwitch:
assert result.exit_code == 0, result.output
# Git extension commands should exist for opencode
opencode_git_feature = project / ".opencode" / "command" / "speckit.git.feature.md"
opencode_git_feature = project / ".opencode" / "commands" / "speckit.git.feature.md"
assert opencode_git_feature.exists(), "Git extension command should exist for opencode"
# Old kimi extension skills should be removed
@@ -837,7 +868,7 @@ class TestIntegrationSwitch:
])
assert result.exit_code == 0, result.output
opencode_git_feature = project / ".opencode" / "command" / "speckit.git.feature.md"
opencode_git_feature = project / ".opencode" / "commands" / "speckit.git.feature.md"
assert opencode_git_feature.exists(), "Git extension command should exist for opencode"
assert not copilot_git_feature.exists(), "Old Copilot extension skill should be removed"
@@ -858,7 +889,7 @@ class TestIntegrationSwitch:
result = _run_in_project(project, ["extension", "disable", "git"])
assert result.exit_code == 0, result.output
opencode_git_feature = project / ".opencode" / "command" / "speckit.git.feature.md"
opencode_git_feature = project / ".opencode" / "commands" / "speckit.git.feature.md"
assert opencode_git_feature.exists(), "Disabled extension command remains until integration switch"
result = _run_in_project(project, [
@@ -901,6 +932,152 @@ class TestIntegrationSwitch:
assert shared_script.exists()
assert shared_script.read_text(encoding="utf-8") == shared_content
def test_switch_refreshes_stale_managed_shared_infra(self, tmp_path):
"""Regression for #2293: stale managed shared scripts get refreshed on switch."""
import hashlib
project = _init_project(tmp_path, "claude")
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
bundled_bytes = shared_script.read_bytes()
# Simulate a stale vendored script: write truncated content as bytes
# (write_text would translate \n→\r\n on Windows and break the hash)
# and update the speckit manifest hash so the stale copy is treated
# as "managed" (installed by spec-kit, not a user customization).
stale_bytes = b"#!/usr/bin/env bash\n# stale vendored copy\n"
shared_script.write_bytes(stale_bytes)
manifest_path = project / ".specify" / "integrations" / "speckit.manifest.json"
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest_data["files"][".specify/scripts/bash/common.sh"] = (
hashlib.sha256(stale_bytes).hexdigest()
)
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
# Stale managed file should be replaced by the bundled version
assert shared_script.read_bytes() == bundled_bytes
def test_switch_preserves_user_customized_shared_infra(self, tmp_path):
"""User customizations (hash divergence from manifest) survive switch without --refresh-shared-infra."""
project = _init_project(tmp_path, "claude")
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
# User customization: append bytes but do NOT update manifest hash,
# so on-disk hash diverges from the recorded one.
original = shared_script.read_bytes()
custom_bytes = original + b"\n# user customization\n"
shared_script.write_bytes(custom_bytes)
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
assert shared_script.read_bytes() == custom_bytes
assert "Preserved" in result.output
def test_switch_refresh_shared_infra_overwrites_customizations(self, tmp_path):
"""--refresh-shared-infra explicitly overwrites user customizations on switch."""
project = _init_project(tmp_path, "claude")
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
bundled_bytes = shared_script.read_bytes()
# User customization (hash diverges from manifest)
custom_bytes = bundled_bytes + b"\n# user customization\n"
shared_script.write_bytes(custom_bytes)
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--script", "sh",
"--refresh-shared-infra",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
# Customization is overwritten with the bundled version
assert shared_script.read_bytes() == bundled_bytes
def test_switch_skips_symlinked_parent_directory(self, tmp_path):
"""Regression: if .specify/scripts/bash is a symlink, switch must not write through it.
Copilot follow-up on #2375: leaf-only symlink check let writes escape
when an *ancestor* directory was symlinked outside the project root.
"""
import sys
if sys.platform.startswith("win"):
import pytest as _pytest
_pytest.skip("Symlink creation typically requires admin on Windows")
project = _init_project(tmp_path, "claude")
bash_dir = project / ".specify" / "scripts" / "bash"
outside = tmp_path / "outside"
outside.mkdir()
for child in bash_dir.iterdir():
child.rename(outside / child.name)
bash_dir.rmdir()
bash_dir.symlink_to(outside, target_is_directory=True)
sentinel = (outside / "common.sh").read_bytes()
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
# Symlinked tree reported, not written through.
assert "symlink" in result.output.lower()
# Outside dir contents unchanged.
assert (outside / "common.sh").read_bytes() == sentinel
def test_switch_force_alone_does_not_overwrite_shared_customizations(self, tmp_path):
"""--force (uninstall semantics) must NOT overwrite shared-infra customizations.
Regression: ensures the decoupling of --force and --refresh-shared-infra.
"""
project = _init_project(tmp_path, "claude")
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
bundled_bytes = shared_script.read_bytes()
custom_bytes = bundled_bytes + b"\n# user customization\n"
shared_script.write_bytes(custom_bytes)
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--script", "sh",
"--force",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
# --force alone preserves the customization
assert shared_script.read_bytes() == custom_bytes
def test_switch_from_nothing(self, tmp_path):
"""Switch when no integration is installed should just install the target."""
project = tmp_path / "bare"
@@ -1022,6 +1199,49 @@ class TestIntegrationUpgrade:
assert data["integration"] == "gemini"
assert "/speckit.plan" in template.read_text(encoding="utf-8")
def test_upgrade_migrates_opencode_legacy_dir(self, tmp_path):
"""Upgrade moves OpenCode commands from .opencode/command/ to .opencode/commands/."""
project = _init_project(tmp_path, "opencode")
# Simulate a legacy project: rename commands/ back to command/
canonical = project / ".opencode" / "commands"
legacy = project / ".opencode" / "command"
assert canonical.is_dir(), "init should have created .opencode/commands/"
canonical.rename(legacy)
assert legacy.is_dir()
assert not canonical.exists()
# Patch the manifest to reflect old paths (command/ not commands/)
manifest_path = project / ".specify" / "integrations" / "opencode.manifest.json"
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
patched_files = {}
for path, info in manifest_data.get("files", {}).items():
patched_files[path.replace(".opencode/commands/", ".opencode/command/")] = info
manifest_data["files"] = patched_files
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
old_commands = sorted(legacy.glob("speckit.*.md"))
assert len(old_commands) > 0, "Legacy dir should have speckit command files"
result = _run_in_project(project, [
"integration", "upgrade", "opencode",
"--script", "sh",
"--force",
])
assert result.exit_code == 0, f"upgrade failed: {result.output}"
# New commands in canonical dir
assert canonical.is_dir(), ".opencode/commands/ should exist after upgrade"
new_commands = sorted(canonical.glob("speckit.*.md"))
assert len(new_commands) > 0, "Commands should exist in .opencode/commands/"
# Stale files removed from legacy dir
remaining = list(legacy.glob("speckit.*.md"))
assert len(remaining) == 0, (
f"Legacy .opencode/command/ should have no speckit files after upgrade, "
f"found: {[f.name for f in remaining]}"
)
# ── Full lifecycle ───────────────────────────────────────────────────

View File

@@ -5,7 +5,6 @@ from pathlib import Path
from specify_cli import AGENT_CONFIG, AI_ASSISTANT_ALIASES, AI_ASSISTANT_HELP
from specify_cli.extensions import CommandRegistrar
REPO_ROOT = Path(__file__).resolve().parent.parent
@@ -199,3 +198,88 @@ class TestAgentConfigConsistency:
def test_ai_help_includes_goose(self):
"""CLI help text for --ai should include goose."""
assert "goose" in AI_ASSISTANT_HELP
# --- invoke_separator propagation checks ---
def test_skills_agents_have_hyphen_invoke_separator_in_agent_configs(self):
"""Skills-based agents must expose invoke_separator='-' in AGENT_CONFIGS.
SkillsIntegration sets ``invoke_separator = "-"`` as a class attribute,
but individual skills integrations (claude, codex, …) do not repeat it in
their ``registrar_config`` dicts. ``_build_agent_configs()`` must
propagate the class attribute so that ``register_commands()`` resolves
``__SPECKIT_COMMAND_*__`` tokens with the correct hyphen separator.
"""
cfg = CommandRegistrar.AGENT_CONFIGS
skills_agents = [
key for key, c in cfg.items() if c.get("extension") == "/SKILL.md"
]
assert skills_agents, (
"Expected at least one skills-based agent in AGENT_CONFIGS"
)
for agent in skills_agents:
assert cfg[agent].get("invoke_separator") == "-", (
f"Skills agent '{agent}' has invoke_separator="
f"{cfg[agent].get('invoke_separator')!r} in AGENT_CONFIGS; "
"expected '-' (propagated from SkillsIntegration.invoke_separator)"
)
def test_skills_agent_command_token_resolves_with_hyphen(self, tmp_path):
"""__SPECKIT_COMMAND_*__ tokens in extension commands resolve to /speckit-<cmd>
when registered for a skills-based agent (e.g. claude).
Regression guard: before the fix, _build_agent_configs() did not
propagate invoke_separator from the integration class, so
register_commands() fell back to '.' and emitted /speckit.specify instead
of /speckit-specify for skills agents.
"""
import re
from pathlib import Path
from specify_cli.agents import CommandRegistrar
repo_root = Path(__file__).resolve().parent.parent
ext_dir = repo_root / "extensions" / "git"
cmd_source = ext_dir / "commands" / "speckit.git.feature.md"
assert cmd_source.exists(), (
f"Git extension command source not found at {cmd_source}"
)
assert "__SPECKIT_COMMAND_SPECIFY__" in cmd_source.read_text(
encoding="utf-8"
), (
"Expected __SPECKIT_COMMAND_SPECIFY__ token in speckit.git.feature.md; "
"check that the file uses the token rather than a hard-coded ref."
)
registrar = CommandRegistrar()
commands = [
{"name": "speckit.git.feature", "file": "commands/speckit.git.feature.md"}
]
registered = registrar.register_commands(
"claude",
commands,
"git",
ext_dir,
tmp_path,
)
assert "speckit.git.feature" in registered
skill_file = (
tmp_path / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md"
)
assert skill_file.exists(), (
f"Expected Claude skill file not found at {skill_file}"
)
content = skill_file.read_text(encoding="utf-8")
assert "/speckit-specify" in content, (
"Expected '/speckit-specify' (hyphen) in generated Claude skill for git.feature; "
"__SPECKIT_COMMAND_SPECIFY__ was not resolved with the correct separator."
)
# Negative lookbehind (?<![a-zA-Z0-9_]) excludes file-path occurrences
# such as 'source: git:commands/speckit.git.feature.md' in frontmatter,
# where the '/' is a path separator preceded by a word character.
assert not re.search(r"(?<![a-zA-Z0-9_])/speckit\.[a-z]", content), (
"Found dot-notation command ref (/speckit.<cmd>) in generated Claude skill. "
"Skills agents must use hyphen notation."
)

View File

@@ -0,0 +1,860 @@
"""Tests for the authentication provider registry and config-driven HTTP helpers.
Covers:
- Config loading (auth.json parsing, validation, permission warning)
- Registry mechanics (_register, get_provider, duplicate/empty-key guards)
- GitHubAuth — bearer headers
- AzureDevOpsAuth — basic-pat, bearer, azure-cli, azure-ad headers
- Host matching (find_entries_for_url)
- open_url — config-driven auth with fallthrough and redirect stripping
- build_request — single-shot request construction
- _fetch_latest_release_tag() delegation
"""
from __future__ import annotations
import base64
import json
import os
import pytest
from specify_cli.authentication import AUTH_REGISTRY, _register, get_provider
from specify_cli.authentication.azure_devops import AzureDevOpsAuth
from specify_cli.authentication.base import AuthProvider
from specify_cli.authentication.config import (
AuthConfigEntry,
find_entries_for_url,
load_auth_config,
)
from specify_cli.authentication.github import GitHubAuth
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _github_entry(token_env: str = "GH_TOKEN", token: str | None = None) -> AuthConfigEntry:
"""Build a standard GitHub config entry."""
return AuthConfigEntry(
hosts=("github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"),
provider="github",
auth="bearer",
token=token,
token_env=token_env if token is None else None,
)
def _ado_basic_entry(token_env: str = "AZURE_DEVOPS_PAT") -> AuthConfigEntry:
"""Build an ADO basic-pat config entry."""
return AuthConfigEntry(
hosts=("dev.azure.com",),
provider="azure-devops",
auth="basic-pat",
token_env=token_env,
)
class _StubProvider(AuthProvider):
"""Minimal concrete provider for registry mechanics tests."""
key = "stub-provider"
supported_auth_schemes = ("bearer",)
def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
# ---------------------------------------------------------------------------
# Config loading
# ---------------------------------------------------------------------------
class TestLoadAuthConfig:
def test_missing_file_returns_empty(self, tmp_path):
assert load_auth_config(tmp_path / "nonexistent.json") == []
def test_valid_github_config(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{
"hosts": ["github.com"],
"provider": "github",
"auth": "bearer",
"token_env": "GH_TOKEN",
}]
}))
entries = load_auth_config(cfg)
assert len(entries) == 1
assert entries[0].provider == "github"
assert entries[0].auth == "bearer"
assert entries[0].token_env == "GH_TOKEN"
def test_valid_ado_config(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{
"hosts": ["dev.azure.com"],
"provider": "azure-devops",
"auth": "basic-pat",
"token_env": "AZURE_DEVOPS_PAT",
}]
}))
entries = load_auth_config(cfg)
assert len(entries) == 1
assert entries[0].provider == "azure-devops"
assert entries[0].auth == "basic-pat"
def test_inline_token(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{
"hosts": ["github.com"],
"provider": "github",
"auth": "bearer",
"token": "ghp_inline_token",
}]
}))
entries = load_auth_config(cfg)
assert entries[0].token == "ghp_inline_token"
def test_azure_ad_config(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{
"hosts": ["dev.azure.com"],
"provider": "azure-devops",
"auth": "azure-ad",
"tenant_id": "tid",
"client_id": "cid",
"client_secret_env": "SECRET",
}]
}))
entries = load_auth_config(cfg)
assert entries[0].auth == "azure-ad"
assert entries[0].tenant_id == "tid"
def test_azure_cli_config(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{
"hosts": ["dev.azure.com"],
"provider": "azure-devops",
"auth": "azure-cli",
}]
}))
entries = load_auth_config(cfg)
assert entries[0].auth == "azure-cli"
def test_multiple_entries(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [
{"hosts": ["github.com"], "provider": "github", "auth": "bearer", "token_env": "GH_TOKEN"},
{"hosts": ["dev.azure.com"], "provider": "azure-devops", "auth": "basic-pat", "token_env": "ADO_PAT"},
]
}))
entries = load_auth_config(cfg)
assert len(entries) == 2
# -- Negative: validation errors --
def test_invalid_json_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text("not json")
with pytest.raises(json.JSONDecodeError):
load_auth_config(cfg)
def test_not_object_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text("[]")
with pytest.raises(ValueError, match="JSON object"):
load_auth_config(cfg)
def test_missing_providers_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({"foo": "bar"}))
with pytest.raises(ValueError, match="providers"):
load_auth_config(cfg)
def test_empty_hosts_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{"hosts": [], "provider": "github", "auth": "bearer", "token_env": "X"}]
}))
with pytest.raises(ValueError, match="non-empty"):
load_auth_config(cfg)
def test_missing_provider_key_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{"hosts": ["github.com"], "auth": "bearer", "token_env": "X"}]
}))
with pytest.raises(ValueError, match="provider"):
load_auth_config(cfg)
def test_unsupported_auth_scheme_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{"hosts": ["github.com"], "provider": "github", "auth": "ntlm", "token_env": "X"}]
}))
with pytest.raises(ValueError, match="does not support"):
load_auth_config(cfg)
def test_bearer_without_token_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{"hosts": ["github.com"], "provider": "github", "auth": "bearer"}]
}))
with pytest.raises(ValueError, match="token"):
load_auth_config(cfg)
def test_azure_ad_missing_fields_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{
"hosts": ["dev.azure.com"],
"provider": "azure-devops",
"auth": "azure-ad",
"tenant_id": "tid",
}]
}))
with pytest.raises(ValueError, match="azure-ad"):
load_auth_config(cfg)
def test_unknown_provider_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{"hosts": ["example.com"], "provider": "gitlab", "auth": "bearer", "token_env": "X"}]
}))
with pytest.raises(ValueError, match="unknown provider"):
load_auth_config(cfg)
def test_incompatible_provider_scheme_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{
"hosts": ["github.com"],
"provider": "github",
"auth": "basic-pat",
"token_env": "X",
}]
}))
with pytest.raises(ValueError, match="does not support"):
load_auth_config(cfg)
def test_dangerous_wildcard_host_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{"hosts": ["*github.com"], "provider": "github", "auth": "bearer", "token_env": "X"}]
}))
with pytest.raises(ValueError, match="invalid host pattern"):
load_auth_config(cfg)
def test_multi_wildcard_host_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{"hosts": ["*.*.example.com"], "provider": "github", "auth": "bearer", "token_env": "X"}]
}))
with pytest.raises(ValueError, match="invalid host pattern"):
load_auth_config(cfg)
def test_valid_star_dot_host_accepted(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{"hosts": ["*.visualstudio.com"], "provider": "azure-devops", "auth": "basic-pat", "token_env": "X"}]
}))
entries = load_auth_config(cfg)
assert entries[0].hosts == ("*.visualstudio.com",)
@pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits not supported on Windows")
def test_world_readable_warns(self, tmp_path):
import stat
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{"hosts": ["github.com"], "provider": "github", "auth": "bearer", "token_env": "GH_TOKEN"}]
}))
cfg.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH)
with pytest.warns(UserWarning, match="readable by group"):
load_auth_config(cfg)
# ---------------------------------------------------------------------------
# Host matching
# ---------------------------------------------------------------------------
class TestFindEntriesForUrl:
def test_exact_match(self):
entry = _github_entry()
result = find_entries_for_url("https://github.com/org/repo", [entry])
assert result == [entry]
def test_wildcard_match(self):
entry = AuthConfigEntry(
hosts=("*.visualstudio.com",),
provider="azure-devops",
auth="basic-pat",
token_env="ADO_PAT",
)
result = find_entries_for_url("https://myorg.visualstudio.com/project", [entry])
assert result == [entry]
def test_no_match_returns_empty(self):
entry = _github_entry()
result = find_entries_for_url("https://evil.example.com/file", [entry])
assert result == []
def test_no_match_for_lookalike_host(self):
entry = _github_entry()
result = find_entries_for_url("https://github.com.evil.com/file", [entry])
assert result == []
def test_empty_url_returns_empty(self):
assert find_entries_for_url("", [_github_entry()]) == []
def test_empty_entries_returns_empty(self):
assert find_entries_for_url("https://github.com/org/repo", []) == []
def test_multiple_matches_returned(self):
e1 = _github_entry(token_env="GH_TOKEN")
e2 = _github_entry(token_env="GITHUB_TOKEN")
result = find_entries_for_url("https://github.com/org/repo", [e1, e2])
assert len(result) == 2
# ---------------------------------------------------------------------------
# Registry mechanics
# ---------------------------------------------------------------------------
class TestAuthRegistry:
def test_github_registered(self):
assert "github" in AUTH_REGISTRY
def test_azure_devops_registered(self):
assert "azure-devops" in AUTH_REGISTRY
def test_get_provider_returns_github(self):
assert isinstance(get_provider("github"), GitHubAuth)
def test_get_provider_returns_azure_devops(self):
assert isinstance(get_provider("azure-devops"), AzureDevOpsAuth)
def test_get_provider_unknown_returns_none(self):
assert get_provider("does-not-exist") is None
def test_register_duplicate_raises_key_error(self):
class _UniqueStub(_StubProvider):
key = "__test_duplicate__"
try:
_register(_UniqueStub())
with pytest.raises(KeyError, match="already registered"):
_register(_UniqueStub())
finally:
AUTH_REGISTRY.pop("__test_duplicate__", None)
def test_register_empty_key_raises_value_error(self):
class _EmptyKey(_StubProvider):
key = ""
with pytest.raises(ValueError, match="empty key"):
_register(_EmptyKey())
# ---------------------------------------------------------------------------
# GitHubAuth
# ---------------------------------------------------------------------------
class TestGitHubAuth:
def test_bearer_headers(self):
assert GitHubAuth().auth_headers("my-token", "bearer") == {"Authorization": "Bearer my-token"}
def test_unsupported_scheme_raises(self):
with pytest.raises(ValueError, match="basic-pat"):
GitHubAuth().auth_headers("tok", "basic-pat")
def test_resolve_token_from_env(self, monkeypatch):
monkeypatch.setenv("GH_TOKEN", "env-token")
assert GitHubAuth().resolve_token(_github_entry()) == "env-token"
def test_resolve_token_inline(self):
assert GitHubAuth().resolve_token(_github_entry(token="inline-tok")) == "inline-tok"
def test_resolve_token_strips_whitespace(self, monkeypatch):
monkeypatch.setenv("GH_TOKEN", " my-token ")
assert GitHubAuth().resolve_token(_github_entry()) == "my-token"
def test_resolve_token_empty_env_returns_none(self, monkeypatch):
monkeypatch.setenv("GH_TOKEN", " ")
assert GitHubAuth().resolve_token(_github_entry()) is None
def test_resolve_token_missing_env_returns_none(self, monkeypatch):
monkeypatch.delenv("GH_TOKEN", raising=False)
assert GitHubAuth().resolve_token(_github_entry()) is None
def test_key(self):
assert GitHubAuth.key == "github"
def test_supported_schemes(self):
assert GitHubAuth.supported_auth_schemes == ("bearer",)
# ---------------------------------------------------------------------------
# AzureDevOpsAuth
# ---------------------------------------------------------------------------
class TestAzureDevOpsAuth:
def test_basic_pat_headers(self):
headers = AzureDevOpsAuth().auth_headers("my-pat", "basic-pat")
encoded = base64.b64encode(b":my-pat").decode("ascii")
assert headers == {"Authorization": f"Basic {encoded}"}
def test_basic_pat_format(self):
header = AzureDevOpsAuth().auth_headers("test-pat", "basic-pat")["Authorization"]
raw = base64.b64decode(header[len("Basic "):]).decode("ascii")
assert raw == ":test-pat"
def test_bearer_headers(self):
assert AzureDevOpsAuth().auth_headers("tok", "bearer") == {"Authorization": "Bearer tok"}
def test_azure_cli_headers(self):
assert AzureDevOpsAuth().auth_headers("tok", "azure-cli") == {"Authorization": "Bearer tok"}
def test_azure_ad_headers(self):
assert AzureDevOpsAuth().auth_headers("tok", "azure-ad") == {"Authorization": "Bearer tok"}
def test_unsupported_scheme_raises(self):
with pytest.raises(ValueError):
AzureDevOpsAuth().auth_headers("tok", "ntlm")
def test_resolve_token_basic_pat(self, monkeypatch):
monkeypatch.setenv("AZURE_DEVOPS_PAT", "my-pat")
assert AzureDevOpsAuth().resolve_token(_ado_basic_entry()) == "my-pat"
def test_resolve_token_strips_whitespace(self, monkeypatch):
monkeypatch.setenv("AZURE_DEVOPS_PAT", " my-pat ")
assert AzureDevOpsAuth().resolve_token(_ado_basic_entry()) == "my-pat"
def test_resolve_token_missing_returns_none(self, monkeypatch):
monkeypatch.delenv("AZURE_DEVOPS_PAT", raising=False)
assert AzureDevOpsAuth().resolve_token(_ado_basic_entry()) is None
def test_key(self):
assert AzureDevOpsAuth.key == "azure-devops"
def test_supported_schemes(self):
schemes = AzureDevOpsAuth.supported_auth_schemes
assert "basic-pat" in schemes
assert "bearer" in schemes
assert "azure-cli" in schemes
assert "azure-ad" in schemes
def test_resolve_token_azure_cli_success(self):
"""azure-cli acquires token via az CLI."""
from unittest.mock import patch, MagicMock
entry = AuthConfigEntry(
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli",
)
result = MagicMock()
result.returncode = 0
result.stdout = '{"accessToken": "cli-acquired-token"}'
with patch("specify_cli.authentication.azure_devops.subprocess.run", return_value=result):
assert AzureDevOpsAuth().resolve_token(entry) == "cli-acquired-token"
def test_resolve_token_azure_cli_failure_returns_none(self):
"""azure-cli returns None when az CLI fails."""
from unittest.mock import patch, MagicMock
entry = AuthConfigEntry(
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli",
)
result = MagicMock()
result.returncode = 1
result.stdout = ""
with patch("specify_cli.authentication.azure_devops.subprocess.run", return_value=result):
assert AzureDevOpsAuth().resolve_token(entry) is None
def test_resolve_token_azure_cli_not_installed_returns_none(self):
"""azure-cli returns None when az is not installed."""
from unittest.mock import patch
entry = AuthConfigEntry(
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli",
)
with patch("specify_cli.authentication.azure_devops.subprocess.run", side_effect=OSError("not found")):
assert AzureDevOpsAuth().resolve_token(entry) is None
def test_resolve_token_azure_ad_success(self, monkeypatch):
"""azure-ad acquires token via OAuth2 client credentials."""
from unittest.mock import patch, MagicMock
monkeypatch.setenv("MY_SECRET", "secret-value")
entry = AuthConfigEntry(
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad",
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
)
mock_resp = MagicMock()
mock_resp.read.return_value = b'{"access_token": "ad-acquired-token"}'
mock_resp.__enter__ = lambda s: s
mock_resp.__exit__ = MagicMock(return_value=False)
with patch("urllib.request.urlopen", return_value=mock_resp):
assert AzureDevOpsAuth().resolve_token(entry) == "ad-acquired-token"
def test_resolve_token_azure_ad_missing_secret_returns_none(self, monkeypatch):
"""azure-ad returns None when client secret env var is missing."""
monkeypatch.delenv("MY_SECRET", raising=False)
entry = AuthConfigEntry(
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad",
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
)
assert AzureDevOpsAuth().resolve_token(entry) is None
def test_resolve_token_azure_ad_network_error_returns_none(self, monkeypatch):
"""azure-ad returns None on network errors."""
import urllib.error
from unittest.mock import patch
monkeypatch.setenv("MY_SECRET", "secret-value")
entry = AuthConfigEntry(
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad",
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
)
with patch("urllib.request.urlopen",
side_effect=urllib.error.URLError("connection refused")):
assert AzureDevOpsAuth().resolve_token(entry) is None
# ---------------------------------------------------------------------------
# open_url / build_request — positive tests
# ---------------------------------------------------------------------------
class TestAuthenticatedHttp:
def _set_config(self, monkeypatch, entries):
from specify_cli.authentication import http as _mod
monkeypatch.setattr(_mod, "_config_override", entries)
def test_build_request_attaches_auth_for_matching_host(self, monkeypatch):
from specify_cli.authentication.http import build_request
monkeypatch.setenv("GH_TOKEN", "my-token")
self._set_config(monkeypatch, [_github_entry()])
req = build_request("https://github.com/org/repo")
assert req.get_header("Authorization") == "Bearer my-token"
def test_build_request_no_auth_for_non_matching_host(self, monkeypatch):
from specify_cli.authentication.http import build_request
monkeypatch.setenv("GH_TOKEN", "my-token")
self._set_config(monkeypatch, [_github_entry()])
req = build_request("https://evil.example.com/file")
assert "Authorization" not in req.headers
def test_build_request_no_auth_when_no_config(self, monkeypatch):
from specify_cli.authentication.http import build_request
self._set_config(monkeypatch, [])
req = build_request("https://github.com/org/repo")
assert "Authorization" not in req.headers
def test_build_request_extra_headers(self, monkeypatch):
from specify_cli.authentication.http import build_request
monkeypatch.setenv("GH_TOKEN", "my-token")
self._set_config(monkeypatch, [_github_entry()])
req = build_request("https://github.com/api", extra_headers={"Accept": "application/json"})
assert req.get_header("Accept") == "application/json"
assert req.get_header("Authorization") == "Bearer my-token"
def test_open_url_attaches_auth_for_matching_host(self, monkeypatch):
from unittest.mock import MagicMock, patch
from specify_cli.authentication.http import open_url
monkeypatch.setenv("GH_TOKEN", "my-token")
self._set_config(monkeypatch, [_github_entry()])
captured = {}
mock_opener = MagicMock()
def fake_open(req, timeout=None):
captured["req"] = req
resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False)
return resp
mock_opener.open.side_effect = fake_open
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
open_url("https://github.com/org/repo/catalog.json")
assert captured["req"].get_header("Authorization") == "Bearer my-token"
def test_open_url_no_auth_for_non_matching_host(self, monkeypatch):
from unittest.mock import MagicMock, patch
from specify_cli.authentication.http import open_url
monkeypatch.setenv("GH_TOKEN", "my-token")
self._set_config(monkeypatch, [_github_entry()])
captured = {}
def fake_urlopen(req, timeout=None):
captured["req"] = req
resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False)
return resp
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_urlopen):
open_url("https://example.com/file.json")
assert captured["req"].get_header("Authorization") is None
def test_open_url_no_auth_when_no_config(self, monkeypatch):
from unittest.mock import MagicMock, patch
from specify_cli.authentication.http import open_url
self._set_config(monkeypatch, [])
captured = {}
def fake_urlopen(req, timeout=None):
captured["req"] = req
resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False)
return resp
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_urlopen):
open_url("https://github.com/org/repo")
assert captured["req"].get_header("Authorization") is None
def test_open_url_falls_through_on_401(self, monkeypatch):
import urllib.error
from unittest.mock import MagicMock, patch
from specify_cli.authentication.http import open_url
monkeypatch.setenv("GH_TOKEN", "bad-token")
self._set_config(monkeypatch, [_github_entry()])
call_count = 0
def fake_side_effect(req, timeout=None):
nonlocal call_count; call_count += 1
if call_count == 1:
raise urllib.error.HTTPError("url", 401, "Unauthorized", {}, None)
resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False)
return resp
mock_opener = MagicMock(); mock_opener.open.side_effect = fake_side_effect
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener), \
patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_side_effect):
open_url("https://github.com/org/repo")
assert call_count == 2
# ---------------------------------------------------------------------------
# open_url — negative tests
# ---------------------------------------------------------------------------
class TestAuthenticatedHttpNegative:
def _set_config(self, monkeypatch, entries):
from specify_cli.authentication import http as _mod
monkeypatch.setattr(_mod, "_config_override", entries)
def test_500_raises_immediately(self, monkeypatch):
import urllib.error
from unittest.mock import MagicMock, patch
from specify_cli.authentication.http import open_url
monkeypatch.setenv("GH_TOKEN", "tok")
self._set_config(monkeypatch, [_github_entry()])
mock_opener = MagicMock()
mock_opener.open.side_effect = urllib.error.HTTPError("url", 500, "ISE", {}, None)
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
with pytest.raises(urllib.error.HTTPError, match="500"):
open_url("https://github.com/org/repo")
def test_404_raises_immediately(self, monkeypatch):
import urllib.error
from unittest.mock import MagicMock, patch
from specify_cli.authentication.http import open_url
monkeypatch.setenv("GH_TOKEN", "tok")
self._set_config(monkeypatch, [_github_entry()])
mock_opener = MagicMock()
mock_opener.open.side_effect = urllib.error.HTTPError("url", 404, "Not Found", {}, None)
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
with pytest.raises(urllib.error.HTTPError, match="404"):
open_url("https://github.com/org/repo")
def test_urlerror_propagates(self, monkeypatch):
import urllib.error
from unittest.mock import patch
from specify_cli.authentication.http import open_url
self._set_config(monkeypatch, [])
with patch("specify_cli.authentication.http.urllib.request.urlopen",
side_effect=urllib.error.URLError("refused")):
with pytest.raises(urllib.error.URLError):
open_url("https://example.com/file")
def test_timeout_propagates(self, monkeypatch):
import socket
from unittest.mock import patch
from specify_cli.authentication.http import open_url
self._set_config(monkeypatch, [])
with patch("specify_cli.authentication.http.urllib.request.urlopen",
side_effect=socket.timeout("timed out")):
with pytest.raises(socket.timeout):
open_url("https://example.com/file")
# ---------------------------------------------------------------------------
# _load_config caching
# ---------------------------------------------------------------------------
class TestLoadConfigCaching:
def test_config_cached_after_first_load(self, monkeypatch):
"""_load_config() should call load_auth_config only once per process."""
from unittest.mock import patch
from specify_cli.authentication import http as _mod
from specify_cli.authentication.config import AuthConfigEntry
# Allow the real load path (no override)
monkeypatch.setattr(_mod, "_config_override", None)
monkeypatch.setattr(_mod, "_config_cache", None)
entry = _github_entry()
call_count = 0
def fake_load(path=None):
nonlocal call_count
call_count += 1
return [entry]
with patch.object(_mod, "load_auth_config", side_effect=fake_load):
_mod._load_config()
_mod._load_config()
_mod._load_config()
assert call_count == 1
def test_cache_bypassed_by_override(self, monkeypatch):
"""When _config_override is set, the cache is ignored entirely."""
from specify_cli.authentication import http as _mod
sentinel = [_github_entry()]
monkeypatch.setattr(_mod, "_config_override", sentinel)
monkeypatch.setattr(_mod, "_config_cache", None)
result = _mod._load_config()
assert result is sentinel
# Cache must not have been populated when override is active
assert _mod._config_cache is None
def test_failed_load_warns_once_and_caches_empty(self, monkeypatch):
"""A bad auth.json emits exactly one warning and subsequent calls use cache."""
from unittest.mock import patch
from specify_cli.authentication import http as _mod
import warnings as _warnings
monkeypatch.setattr(_mod, "_config_override", None)
monkeypatch.setattr(_mod, "_config_cache", None)
call_count = 0
def fail_load(path=None):
nonlocal call_count
call_count += 1
raise ValueError("bad config")
with patch.object(_mod, "load_auth_config", side_effect=fail_load):
with _warnings.catch_warnings(record=True) as w:
_warnings.simplefilter("always")
result1 = _mod._load_config()
result2 = _mod._load_config()
result3 = _mod._load_config()
user_warnings = [x for x in w if issubclass(x.category, UserWarning)]
assert len(user_warnings) == 1, "Expected exactly one warning"
# Loader called only once — subsequent calls used cache
assert call_count == 1
# All calls returned the cached empty list
assert result1 == result2 == result3 == []
# ---------------------------------------------------------------------------
# Redirect stripping
# ---------------------------------------------------------------------------
class TestRedirectStripping:
def test_redirect_within_hosts_preserves_auth(self):
from specify_cli.authentication.http import _StripAuthOnRedirect
from urllib.request import Request
import io
handler = _StripAuthOnRedirect(("github.com", "codeload.github.com"))
req = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
new_req = handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
"https://codeload.github.com/org/repo/zip")
assert new_req is not None
auth = new_req.get_header("Authorization") or new_req.unredirected_hdrs.get("Authorization")
assert auth == "Bearer tok"
def test_redirect_outside_hosts_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", {},
"https://objects.githubusercontent.com/asset")
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_multi_hop_redirect_within_hosts_preserves_auth(self):
"""Auth survives a multi-hop redirect chain within allowed hosts."""
from specify_cli.authentication.http import _StripAuthOnRedirect
from urllib.request import Request
import io
hosts = ("github.com", "codeload.github.com", "objects-origin.githubusercontent.com")
handler = _StripAuthOnRedirect(hosts)
# First hop: github.com → codeload.github.com
req1 = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
req2 = handler.redirect_request(req1, io.BytesIO(b""), 302, "Found", {},
"https://codeload.github.com/org/repo/zip")
assert req2 is not None
auth2 = req2.get_header("Authorization") or req2.unredirected_hdrs.get("Authorization")
assert auth2 == "Bearer tok"
# Second hop: codeload.github.com → objects-origin.githubusercontent.com
req3 = handler.redirect_request(req2, io.BytesIO(b""), 302, "Found", {},
"https://objects-origin.githubusercontent.com/asset")
assert req3 is not None
auth3 = req3.get_header("Authorization") or req3.unredirected_hdrs.get("Authorization")
assert auth3 == "Bearer tok"
# ---------------------------------------------------------------------------
# _fetch_latest_release_tag delegation
# ---------------------------------------------------------------------------
class TestFetchLatestReleaseTagDelegation:
def _set_config(self, monkeypatch, entries):
from specify_cli.authentication import http as _mod
monkeypatch.setattr(_mod, "_config_override", entries)
def _capture_request(self):
import json as _json
from unittest.mock import MagicMock
captured: dict = {}
def side_effect(req, timeout=None):
captured["request"] = req
body = _json.dumps({"tag_name": "v9.9.9"}).encode()
resp = MagicMock(); resp.read.return_value = body
cm = MagicMock(); cm.__enter__.return_value = resp; cm.__exit__.return_value = False
return cm
return captured, side_effect
def test_gh_token_forwarded_when_configured(self, monkeypatch):
from unittest.mock import MagicMock, patch
from specify_cli._version import _fetch_latest_release_tag
monkeypatch.setenv("GH_TOKEN", "forwarded-sentinel")
self._set_config(monkeypatch, [_github_entry()])
captured, side_effect = self._capture_request()
mock_opener = MagicMock(); mock_opener.open.side_effect = side_effect
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
_fetch_latest_release_tag()
assert captured["request"].get_header("Authorization") == "Bearer forwarded-sentinel"
def test_no_config_means_no_auth(self, monkeypatch):
from unittest.mock import patch
from specify_cli._version import _fetch_latest_release_tag
self._set_config(monkeypatch, [])
captured, side_effect = self._capture_request()
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
_fetch_latest_release_tag()
assert captured["request"].get_header("Authorization") is None
def test_accept_header_present(self, monkeypatch):
from unittest.mock import patch
from specify_cli._version import _fetch_latest_release_tag
self._set_config(monkeypatch, [])
captured, side_effect = self._capture_request()
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
_fetch_latest_release_tag()
assert captured["request"].get_header("Accept") == "application/vnd.github+json"

View File

@@ -7,7 +7,13 @@ Covers issue https://github.com/github/spec-kit/issues/550:
from unittest.mock import patch, MagicMock
from specify_cli import check_tool
from typer.testing import CliRunner
from specify_cli import app, check_tool
from tests.conftest import strip_ansi
runner = CliRunner()
class TestCheckToolClaude:
@@ -22,7 +28,9 @@ class TestCheckToolClaude:
fake_missing = tmp_path / "nonexistent" / "claude"
with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_claude), \
patch("specify_cli._utils.CLAUDE_LOCAL_PATH", fake_claude), \
patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
patch("specify_cli._utils.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
patch("shutil.which", return_value=None):
assert check_tool("claude") is True
@@ -36,7 +44,9 @@ class TestCheckToolClaude:
fake_migrate = tmp_path / "nonexistent" / "claude"
with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_migrate), \
patch("specify_cli._utils.CLAUDE_LOCAL_PATH", fake_migrate), \
patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_npm_claude), \
patch("specify_cli._utils.CLAUDE_NPM_LOCAL_PATH", fake_npm_claude), \
patch("shutil.which", return_value=None):
assert check_tool("claude") is True
@@ -45,7 +55,9 @@ class TestCheckToolClaude:
fake_missing = tmp_path / "nonexistent" / "claude"
with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_missing), \
patch("specify_cli._utils.CLAUDE_LOCAL_PATH", fake_missing), \
patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
patch("specify_cli._utils.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
patch("shutil.which", return_value="/usr/local/bin/claude"):
assert check_tool("claude") is True
@@ -54,7 +66,9 @@ class TestCheckToolClaude:
fake_missing = tmp_path / "nonexistent" / "claude"
with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_missing), \
patch("specify_cli._utils.CLAUDE_LOCAL_PATH", fake_missing), \
patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
patch("specify_cli._utils.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
patch("shutil.which", return_value=None):
assert check_tool("claude") is False
@@ -68,7 +82,9 @@ class TestCheckToolClaude:
tracker = MagicMock()
with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_missing), \
patch("specify_cli._utils.CLAUDE_LOCAL_PATH", fake_missing), \
patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_npm_claude), \
patch("specify_cli._utils.CLAUDE_NPM_LOCAL_PATH", fake_npm_claude), \
patch("shutil.which", return_value=None):
result = check_tool("claude", tracker=tracker)
@@ -93,4 +109,32 @@ class TestCheckToolOther:
return "/usr/bin/kiro" if name == "kiro" else None
with patch("shutil.which", side_effect=fake_which):
assert check_tool("kiro-cli") is True
assert check_tool("kiro-cli") is True
class TestCheckTip:
"""`specify check` should point users to the existing version check."""
def test_check_shows_self_check_tip(self):
with patch("specify_cli.check_tool", return_value=True):
result = runner.invoke(app, ["check"])
output = strip_ansi(result.output)
assert result.exit_code == 0
assert (
"Tip: Run 'specify self check' to verify you have the latest CLI version"
in output
)
def test_check_tip_does_not_fetch_latest_release(self):
with (
patch("specify_cli.check_tool", return_value=True),
patch(
"specify_cli._version._fetch_latest_release_tag",
side_effect=AssertionError("latest release lookup should not run"),
) as fetch_latest,
):
result = runner.invoke(app, ["check"])
assert result.exit_code == 0
fetch_latest.assert_not_called()

View File

@@ -1,5 +1,6 @@
"""Tests for the --version CLI flag."""
"""Tests for CLI version reporting."""
import json
from unittest.mock import patch
from typer.testing import CliRunner
@@ -33,3 +34,46 @@ class TestVersionFlag:
result = runner.invoke(app, ["--version", "init"])
assert result.exit_code == 0
assert "specify 0.7.2" in result.output
class TestVersionCommand:
"""Test the `specify version` subcommand."""
def test_version_features_text(self):
"""specify version --features prints local capability flags."""
with patch("specify_cli.get_speckit_version", return_value="1.2.3"):
result = runner.invoke(app, ["version", "--features"])
assert result.exit_code == 0
assert "Spec Kit CLI: 1.2.3" in result.output
assert "Features:" in result.output
assert "- controlled multi install integrations: yes" in result.output
assert "- integration use command: yes" in result.output
assert "- self check command: yes" in result.output
def test_version_features_json(self):
"""specify version --features --json prints machine-readable capabilities."""
with patch("specify_cli.get_speckit_version", return_value="1.2.3"):
result = runner.invoke(app, ["version", "--features", "--json"])
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload == {
"version": "1.2.3",
"features": {
"controlled_multi_install_integrations": True,
"integration_use_command": True,
"multi_install_safe_registry_metadata": True,
"integration_upgrade_command": True,
"self_check_command": True,
"workflow_catalog": True,
"bundled_templates": True,
},
}
def test_version_json_requires_features(self):
"""specify version --json is rejected until a JSON surface exists."""
result = runner.invoke(app, ["version", "--json"])
assert result.exit_code != 0
assert "--json requires --features" in result.output

View File

@@ -0,0 +1,46 @@
"""Regression guard: console symbols must remain importable from specify_cli."""
from specify_cli import (
console,
StepTracker,
get_key,
select_with_arrows,
BannerGroup,
show_banner,
BANNER,
TAGLINE,
)
def test_console_symbols_importable():
from rich.console import Console
assert isinstance(console, Console)
def test_console_symbols_available_from_star_import():
namespace = {}
exec("from specify_cli import *", namespace)
for symbol in (
"console",
"StepTracker",
"get_key",
"select_with_arrows",
"BannerGroup",
"show_banner",
"BANNER",
"TAGLINE",
):
assert symbol in namespace
def test_step_tracker_instantiable():
tracker = StepTracker("test")
tracker.add("step1", "Step One")
tracker.complete("step1", "done")
assert tracker.steps[0]["status"] == "done"
def test_select_with_arrows_raises_on_empty_options():
import pytest
with pytest.raises(ValueError, match="at least one option"):
select_with_arrows({})

View File

@@ -0,0 +1,497 @@
import pytest
import yaml
from specify_cli.extensions import HookExecutor, ExtensionManifest
@pytest.fixture
def project_dir(tmp_path):
"""Create a mock spec-kit project directory."""
proj_dir = tmp_path / "project"
proj_dir.mkdir()
(proj_dir / ".specify").mkdir()
return proj_dir
class TestExtensionRegistration:
"""Tests for the 'installed' list management in HookExecutor."""
def test_register_extension_new(self, project_dir):
"""Standard registration: Adding an extension should add it to the list."""
executor = HookExecutor(project_dir)
executor.register_extension("test-ext")
config = executor.get_project_config()
assert "installed" in config
assert config["installed"] == ["test-ext"]
def test_register_extension_sorting(self, project_dir):
"""Order Stability: Extensions should be stored in alphabetical order."""
executor = HookExecutor(project_dir)
executor.register_extension("zebra-ext")
executor.register_extension("apple-ext")
executor.register_extension("middle-ext")
config = executor.get_project_config()
assert config["installed"] == ["apple-ext", "middle-ext", "zebra-ext"]
def test_register_extension_idempotency(self, project_dir):
"""Idempotency: Adding the same extension twice should not result in duplicates."""
executor = HookExecutor(project_dir)
executor.register_extension("test-ext")
executor.register_extension("test-ext")
config = executor.get_project_config()
assert config["installed"] == ["test-ext"]
assert len(config["installed"]) == 1
def test_unregister_extension(self, project_dir):
"""Standard unregistration: Removing an extension should prune it from the list."""
executor = HookExecutor(project_dir)
executor.register_extension("ext-1")
executor.register_extension("ext-2")
executor.unregister_extension("ext-1")
config = executor.get_project_config()
assert config["installed"] == ["ext-2"]
def test_unregister_extension_not_present(self, project_dir):
"""Safe Removal: Unregistering a non-existent extension should do nothing."""
executor = HookExecutor(project_dir)
executor.register_extension("ext-1")
# Should not raise or change the list
executor.unregister_extension("ext-nonexistent")
config = executor.get_project_config()
assert config["installed"] == ["ext-1"]
def test_register_hooks_triggers_registration(self, project_dir, tmp_path):
"""Full Workflow: register_hooks should automatically register the extension."""
# Create a mock manifest
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "hook-ext",
"name": "Hook Ext",
"version": "1.0.0",
"description": "Test",
},
"requires": {
"speckit_version": ">=0.1.0",
"commands": []
},
"provides": {"commands": []},
"hooks": {
"after_tasks": {"command": "speckit.hook-ext.run"}
}
}
manifest_path = tmp_path / "extension.yml"
with open(manifest_path, "w") as f:
yaml.dump(manifest_data, f)
manifest = ExtensionManifest(manifest_path)
executor = HookExecutor(project_dir)
# This should call register_extension internally
executor.register_hooks(manifest)
config = executor.get_project_config()
assert "hook-ext" in config["installed"]
def test_missing_installed_key_initialization(self, project_dir):
"""Graceful Initialization: If 'installed' key is missing, it should be created."""
executor = HookExecutor(project_dir)
# Manually create a config without 'installed'
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump({"settings": {"auto_execute_hooks": True}}))
# This should detect the missing key and initialize it
executor.register_extension("new-ext")
config = executor.get_project_config()
assert "installed" in config
assert config["installed"] == ["new-ext"]
def test_unregister_hooks_full_workflow(self, project_dir, tmp_path):
"""Full Workflow: unregister_hooks should remove hooks and prune installed list."""
# Create a manifest with hooks
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "hook-ext",
"name": "Hook Ext",
"version": "1.0.0",
"description": "Test",
},
"requires": {
"speckit_version": ">=0.1.0",
"commands": []
},
"provides": {"commands": []},
"hooks": {
"after_tasks": {"command": "speckit.hook-ext.run"}
}
}
manifest_path = tmp_path / "extension.yml"
with open(manifest_path, "w") as f:
yaml.dump(manifest_data, f)
manifest = ExtensionManifest(manifest_path)
executor = HookExecutor(project_dir)
# Register hooks first
executor.register_hooks(manifest)
config = executor.get_project_config()
assert "hook-ext" in config["installed"]
assert "after_tasks" in config["hooks"]
# Now unregister hooks
executor.unregister_hooks("hook-ext")
config = executor.get_project_config()
assert "hook-ext" not in config["installed"]
# unregister_hooks() removes the empty hook array entirely, so the key is absent
assert "after_tasks" not in config["hooks"]
def test_unregister_hooks_no_hooks_key(self, project_dir):
"""Resilience: unregister_hooks should work even if config has no 'hooks' key."""
executor = HookExecutor(project_dir)
# Register extension without hooks
executor.register_extension("ext-no-hooks")
config = executor.get_project_config()
assert "ext-no-hooks" in config["installed"]
# Unregister should not crash even if no hooks key exists
executor.unregister_hooks("ext-no-hooks")
config = executor.get_project_config()
assert "ext-no-hooks" not in config["installed"]
def test_unregister_hooks_corrupted_config(self, project_dir):
"""Resilience: unregister_hooks should gracefully handle corrupted config."""
# Create a corrupted config (root is a list)
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump(["corrupted", "list"]))
executor = HookExecutor(project_dir)
# Should not raise even with corrupted config
executor.unregister_hooks("non-existent")
# Config should remain as-is or be handled gracefully
config = executor.get_project_config()
# If it's corrupted, it's returned as-is or handled by defensive logic
assert config is not None
def test_unregister_hooks_with_multiple_extensions(self, project_dir, tmp_path):
"""Multiple Extensions: unregister_hooks should only remove target extension's hooks."""
# Create two manifests
manifest_data_1 = {
"schema_version": "1.0",
"extension": {
"id": "ext-1",
"name": "Ext 1",
"version": "1.0.0",
"description": "Test 1",
},
"requires": {
"speckit_version": ">=0.1.0",
"commands": []
},
"provides": {"commands": []},
"hooks": {
"after_tasks": {"command": "speckit.ext-1.run"}
}
}
manifest_data_2 = {
"schema_version": "1.0",
"extension": {
"id": "ext-2",
"name": "Ext 2",
"version": "1.0.0",
"description": "Test 2",
},
"requires": {
"speckit_version": ">=0.1.0",
"commands": []
},
"provides": {"commands": []},
"hooks": {
"after_tasks": {"command": "speckit.ext-2.run"}
}
}
manifest_path_1 = tmp_path / "extension1.yml"
manifest_path_2 = tmp_path / "extension2.yml"
with open(manifest_path_1, "w") as f:
yaml.dump(manifest_data_1, f)
with open(manifest_path_2, "w") as f:
yaml.dump(manifest_data_2, f)
manifest1 = ExtensionManifest(manifest_path_1)
manifest2 = ExtensionManifest(manifest_path_2)
executor = HookExecutor(project_dir)
# Register both extensions
executor.register_hooks(manifest1)
executor.register_hooks(manifest2)
config = executor.get_project_config()
assert "ext-1" in config["installed"]
assert "ext-2" in config["installed"]
assert len(config["hooks"]["after_tasks"]) == 2
# Unregister first extension
executor.unregister_hooks("ext-1")
config = executor.get_project_config()
assert "ext-1" not in config["installed"]
assert "ext-2" in config["installed"]
# ext-2's hook should still be there
assert len(config["hooks"]["after_tasks"]) == 1
assert config["hooks"]["after_tasks"][0].get("extension") == "ext-2"
def test_register_hooks_no_hooks_still_registers(self, project_dir, tmp_path):
"""Commands-only manifest: register_hooks() must still update installed even with no hooks."""
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "commands-only-ext",
"name": "Commands Only",
"version": "1.0.0",
"description": "No hooks, only commands",
},
"requires": {
"speckit_version": ">=0.1.0",
"commands": []
},
"provides": {"commands": [{"name": "speckit.commands-only-ext.run", "file": "commands/run.md"}]},
}
manifest_path = tmp_path / "extension.yml"
with open(manifest_path, "w") as f:
yaml.dump(manifest_data, f)
manifest = ExtensionManifest(manifest_path)
executor = HookExecutor(project_dir)
executor.register_hooks(manifest)
config = executor.get_project_config()
assert "commands-only-ext" in config["installed"]
def test_register_extension_mixed_type_installed(self, project_dir):
"""Regression: installed list with non-string entries must not crash on sort."""
executor = HookExecutor(project_dir)
# Manually write a corrupted installed list with non-string entries
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump({"installed": [1, True, "existing-ext"]}))
# Should not raise TypeError on sort
executor.register_extension("new-ext")
config = executor.get_project_config()
# Non-string entries are dropped; valid strings are preserved
assert "existing-ext" in config["installed"]
assert "new-ext" in config["installed"]
assert 1 not in config["installed"]
assert True not in config["installed"]
def test_unregister_hooks_null_hook_values(self, project_dir):
"""Regression: hooks: {after_tasks: null} must not crash in unregister_hooks()."""
executor = HookExecutor(project_dir)
# Manually write a config with null hook event value
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump({
"installed": ["broken-ext"],
"hooks": {"after_tasks": None}
}))
# Should not raise TypeError when iterating None
executor.unregister_hooks("broken-ext")
config = executor.get_project_config()
assert "broken-ext" not in config["installed"]
def test_register_hooks_corrupted_hook_values(self, project_dir, tmp_path):
"""Regression: register_hooks() must handle non-list hook event values in config."""
executor = HookExecutor(project_dir)
# Manually write a config with null hook event value
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump({
"installed": ["some-ext"],
"hooks": {"after_tasks": None}
}))
# Create a manifest with a hook for the same event
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "new-ext",
"name": "New Ext",
"version": "1.0.0",
"description": "Test",
},
"requires": {
"speckit_version": ">=0.1.0",
"commands": []
},
"provides": {"commands": []},
"hooks": {"after_tasks": {"command": "speckit.new-ext.run"}}
}
manifest_path = tmp_path / "extension.yml"
with open(manifest_path, "w") as f:
yaml.dump(manifest_data, f)
manifest = ExtensionManifest(manifest_path)
# Should not raise TypeError when trying to append to None
executor.register_hooks(manifest)
config = executor.get_project_config()
assert "new-ext" in config["installed"]
assert isinstance(config["hooks"]["after_tasks"], list)
assert any(h["extension"] == "new-ext" for h in config["hooks"]["after_tasks"])
def test_register_extension_already_present_in_corrupted_list(self, project_dir):
"""Regression: if extension is already present but list has non-strings, it must still be sanitized."""
executor = HookExecutor(project_dir)
# Extension is present, but list has garbage
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump({"installed": [1, "test-ext", True]}))
# This should trigger sanitization and save, even though "test-ext" is already there
executor.register_extension("test-ext")
config = executor.get_project_config()
assert config["installed"] == ["test-ext"]
# Verify it was actually saved to disk
raw_config = yaml.safe_load(config_path.read_text())
assert raw_config["installed"] == ["test-ext"]
def test_register_extension_with_dict_entry(self, project_dir):
"""Review Feedback: register_extension should support and preserve dict entries."""
executor = HookExecutor(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
# Setup config with a pinned extension (dict)
pinned_ext = {"id": "pinned-ext", "version": "1.0.0"}
config_path.write_text(yaml.dump({
"installed": [pinned_ext, "string-ext"]
}))
# Register a new extension
executor.register_extension("new-ext")
config = executor.get_project_config()
# Should contain all three, sorted by id: new-ext, pinned-ext, string-ext
assert config["installed"] == ["new-ext", pinned_ext, "string-ext"]
def test_unregister_extension_with_dict_entry(self, project_dir):
"""Review Feedback: unregister_extension should support removing matching dict entries."""
executor = HookExecutor(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
pinned_ext = {"id": "to-remove", "version": "1.0.0"}
config_path.write_text(yaml.dump({
"installed": [pinned_ext, "other-ext"]
}))
# Unregister by ID
executor.unregister_extension("to-remove")
config = executor.get_project_config()
assert config["installed"] == ["other-ext"]
def test_unregister_extension_corrupted_installed(self, project_dir):
"""Hardening: unregister_extension should handle non-list installed key."""
executor = HookExecutor(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump({
"installed": "not-a-list"
}))
# Should not crash and should normalize to []
executor.unregister_extension("any-ext")
config = executor.get_project_config()
assert config["installed"] == []
def test_register_hooks_mixed_type_hook_list(self, project_dir, tmp_path):
"""Regression: register_hooks() must sanitize hook event lists by dropping non-dicts."""
executor = HookExecutor(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump({
"installed": ["some-ext"],
"hooks": {"after_tasks": [1, "corrupted", {"extension": "other", "command": "cmd"}]}
}))
manifest_path = tmp_path / "extension.yml"
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "new-ext",
"name": "New Ext",
"version": "1.0.0",
"description": "Test",
"author": "Test author"
},
"requires": {
"speckit_version": ">=0.1.0",
"commands": []
},
"provides": {"commands": []},
"hooks": {
"after_tasks": {"command": "new-cmd"}
}
}
manifest_path.write_text(yaml.dump(manifest_data))
manifest = ExtensionManifest(manifest_path)
executor.register_hooks(manifest)
config = executor.get_project_config()
hooks = config["hooks"]["after_tasks"]
# Should have 2 valid dict hooks, and 0 non-dict items
assert len(hooks) == 2
assert all(isinstance(h, dict) for h in hooks)
assert any(h.get("extension") == "other" for h in hooks)
assert any(h.get("extension") == "new-ext" for h in hooks)
def test_unregister_extension_scalar_root(self, project_dir):
"""Hardening: unregister_extension should handle scalar root config."""
executor = HookExecutor(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump(123))
# Should not crash and should normalize to {}
executor.unregister_extension("any-ext")
config = executor.get_project_config()
assert isinstance(config, dict)
assert config["installed"] == []
def test_unregister_hooks_scalar_hook_values(self, project_dir):
"""Regression: unregister_hooks() must handle scalar hook event values."""
executor = HookExecutor(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump({
"installed": ["some-ext"],
"hooks": {"after_tasks": 123}
}))
# Should not raise TypeError when iterating
executor.unregister_hooks("some-ext")
config = executor.get_project_config()
assert "some-ext" not in config["installed"]
assert "after_tasks" not in config["hooks"]

View File

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

View File

@@ -1846,7 +1846,7 @@ Run {SCRIPT}
registrar = CommandRegistrar()
from specify_cli.extensions import ExtensionManifest
manifest = ExtensionManifest(ext_dir / "extension.yml")
registered = registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
skill_subdir = skills_dir / "speckit-cleanup-ext-run"
assert skill_subdir.exists(), "Skill subdirectory should exist after registration"
@@ -2453,6 +2453,10 @@ class TestExtensionCatalog:
(project_dir / ".specify").mkdir()
return ExtensionCatalog(project_dir)
def _inject_github_config(self, monkeypatch, token_env="GH_TOKEN"):
from tests.auth_helpers import inject_github_config
inject_github_config(monkeypatch, token_env)
def test_make_request_no_token_no_auth_header(self, temp_dir, monkeypatch):
"""Without a token, requests carry no Authorization header."""
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
@@ -2473,6 +2477,7 @@ class TestExtensionCatalog:
"""When GITHUB_TOKEN is whitespace-only, GH_TOKEN is used as fallback."""
monkeypatch.setenv("GITHUB_TOKEN", " ")
monkeypatch.setenv("GH_TOKEN", "ghp_fallback")
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
assert req.get_header("Authorization") == "Bearer ghp_fallback"
@@ -2481,6 +2486,7 @@ class TestExtensionCatalog:
"""GITHUB_TOKEN is attached for raw.githubusercontent.com URLs."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
monkeypatch.delenv("GH_TOKEN", raising=False)
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
@@ -2489,49 +2495,40 @@ class TestExtensionCatalog:
"""GH_TOKEN is used when GITHUB_TOKEN is absent."""
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
monkeypatch.setenv("GH_TOKEN", "ghp_ghtoken")
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/ext.zip")
assert req.get_header("Authorization") == "Bearer ghp_ghtoken"
def test_make_request_github_token_takes_precedence_over_gh_token(self, temp_dir, monkeypatch):
"""GITHUB_TOKEN takes precedence over GH_TOKEN when both are set."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_primary")
monkeypatch.setenv("GH_TOKEN", "ghp_secondary")
def test_make_request_gh_token_takes_precedence_over_github_token(self, temp_dir, monkeypatch):
"""When auth.json uses GH_TOKEN, that token is used regardless of GITHUB_TOKEN."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_secondary")
monkeypatch.setenv("GH_TOKEN", "ghp_primary")
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://api.github.com/repos/org/repo")
assert req.get_header("Authorization") == "Bearer ghp_primary"
def test_make_request_token_not_added_for_non_github_url(self, temp_dir, monkeypatch):
"""Auth header is never attached to non-GitHub URLs to prevent credential leakage."""
def test_make_request_no_auth_for_non_matching_host(self, temp_dir, monkeypatch):
"""Auth is NOT attached to hosts not listed in auth.json."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://internal.example.com/catalog.json")
assert "Authorization" not in req.headers
def test_make_request_token_not_added_for_github_lookalike_host(self, temp_dir, monkeypatch):
"""Auth header is not attached to hosts that include github.com as a suffix."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
def test_make_request_no_auth_when_no_config(self, temp_dir, monkeypatch):
"""No auth header when no auth.json config exists."""
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GH_TOKEN", raising=False)
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://github.com.evil.com/org/repo/releases/download/v1/ext.zip")
assert "Authorization" not in req.headers
def test_make_request_token_not_added_for_github_in_path(self, temp_dir, monkeypatch):
"""Auth header is not attached when github.com appears only in the URL path."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://evil.example.com/github.com/org/repo/releases/download/v1/ext.zip")
assert "Authorization" not in req.headers
def test_make_request_token_not_added_for_github_in_query(self, temp_dir, monkeypatch):
"""Auth header is not attached when github.com appears only in the query string."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://evil.example.com/download?source=https://github.com/org/repo/v1/ext.zip")
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/ext.zip")
assert "Authorization" not in req.headers
def test_make_request_token_added_for_api_github_com(self, temp_dir, monkeypatch):
"""GITHUB_TOKEN is attached for api.github.com URLs."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://api.github.com/repos/org/repo/releases/assets/1")
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
@@ -2539,49 +2536,17 @@ class TestExtensionCatalog:
def test_make_request_token_added_for_codeload_github_com(self, temp_dir, monkeypatch):
"""GITHUB_TOKEN is attached for codeload.github.com URLs (GitHub archive redirects)."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://codeload.github.com/org/repo/zip/refs/tags/v1.0.0")
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
def test_redirect_preserves_auth_for_github_to_codeload(self):
"""Auth header is preserved when GitHub redirects to codeload.github.com."""
from specify_cli._github_http import _StripAuthOnRedirect
from urllib.request import Request
import io
handler = _StripAuthOnRedirect()
original_url = "https://github.com/org/repo/archive/refs/tags/v1.zip"
redirect_url = "https://codeload.github.com/org/repo/zip/refs/tags/v1"
req = Request(original_url, headers={"Authorization": "Bearer ghp_test"})
fp = io.BytesIO(b"")
new_req = handler.redirect_request(req, fp, 302, "Found", {}, redirect_url)
assert new_req is not None
auth = new_req.get_header("Authorization") or new_req.unredirected_hdrs.get("Authorization")
assert auth == "Bearer ghp_test"
def test_redirect_strips_auth_for_github_to_external(self):
"""Auth header is stripped when GitHub redirects to a non-GitHub host."""
from specify_cli._github_http import _StripAuthOnRedirect
from urllib.request import Request
import io
handler = _StripAuthOnRedirect()
original_url = "https://github.com/org/repo/releases/download/v1/asset.zip"
redirect_url = "https://objects.githubusercontent.com/github-production-release-asset/12345"
req = Request(original_url, headers={"Authorization": "Bearer ghp_test"})
fp = io.BytesIO(b"")
new_req = handler.redirect_request(req, fp, 302, "Found", {}, redirect_url)
assert new_req is not None
auth_header = new_req.headers.get("Authorization")
auth_unredirected = new_req.unredirected_hdrs.get("Authorization")
assert auth_header is None
assert auth_unredirected is None
def test_fetch_single_catalog_sends_auth_header(self, temp_dir, monkeypatch):
"""_fetch_single_catalog passes Authorization header via opener for GitHub URLs."""
"""_fetch_single_catalog passes Authorization header when a provider is configured."""
from unittest.mock import patch, MagicMock
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
catalog = self._make_catalog(temp_dir)
catalog_data = {"schema_version": "1.0", "extensions": {}}
@@ -2589,6 +2554,7 @@ class TestExtensionCatalog:
mock_response.read.return_value = json.dumps(catalog_data).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://raw.githubusercontent.com/org/repo/main/catalog.json"
captured = {}
mock_opener = MagicMock()
@@ -2606,17 +2572,19 @@ class TestExtensionCatalog:
install_allowed=True,
)
with patch("urllib.request.build_opener", return_value=mock_opener):
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
catalog._fetch_single_catalog(entry, force_refresh=True)
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
def test_download_extension_sends_auth_header(self, temp_dir, monkeypatch):
"""download_extension passes Authorization header via opener for GitHub URLs."""
"""download_extension passes Authorization header when a provider is configured."""
from unittest.mock import patch, MagicMock
import zipfile, io
import zipfile
import io
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
catalog = self._make_catalog(temp_dir)
# Build a minimal valid ZIP in memory
@@ -2631,7 +2599,6 @@ class TestExtensionCatalog:
mock_response.__exit__ = MagicMock(return_value=False)
captured = {}
mock_opener = MagicMock()
def fake_open(req, timeout=None):
@@ -2648,7 +2615,7 @@ class TestExtensionCatalog:
}
with patch.object(catalog, "get_extension_info", return_value=ext_info), \
patch("urllib.request.build_opener", return_value=mock_opener):
patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
catalog.download_extension("test-ext", target_dir=temp_dir)
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
@@ -2888,6 +2855,110 @@ class TestCatalogStack:
assert len(entries) == 1
assert entries[0].url == "http://localhost:8000/catalog.json"
@pytest.mark.parametrize(
"config_content", ["[]\n", "false\n", "0\n", "''\n", "- item\n"]
)
def test_load_catalog_config_rejects_non_mapping_roots(
self, temp_dir, config_content
):
"""Malformed roots raise ValidationError, not fallback or AttributeError."""
project_dir = self._make_project(temp_dir)
config_path = project_dir / ".specify" / "extension-catalogs.yml"
config_path.write_text(config_content, encoding="utf-8")
catalog = ExtensionCatalog(project_dir)
with pytest.raises(
ValidationError, match="expected a YAML mapping at the root"
) as exc_info:
catalog.get_active_catalogs()
assert str(config_path) in str(exc_info.value)
def test_load_catalog_config_rejects_boolean_priority(self, temp_dir):
"""Boolean priorities are rejected instead of being coerced to 1 or 0."""
import yaml as yaml_module
project_dir = self._make_project(temp_dir)
config_path = project_dir / ".specify" / "extension-catalogs.yml"
config_path.write_text(
yaml_module.dump(
{
"catalogs": [
{
"name": "bad-priority",
"url": "https://example.com/catalog.json",
"priority": True,
}
]
}
),
encoding="utf-8",
)
catalog = ExtensionCatalog(project_dir)
with pytest.raises(
ValidationError, match="Invalid priority|expected integer"
) as exc_info:
catalog.get_active_catalogs()
assert str(config_path) in str(exc_info.value)
def test_load_catalog_config_defaults_blank_names(self, temp_dir):
"""Blank and null names normalize by valid catalog order."""
import yaml as yaml_module
project_dir = self._make_project(temp_dir)
config_path = project_dir / ".specify" / "extension-catalogs.yml"
config_path.write_text(
yaml_module.dump(
{
"catalogs": [
{"name": "skipped", "url": " "},
{"name": None, "url": "https://one.example.com/catalog.json"},
{"name": " ", "url": "https://two.example.com/catalog.json"},
]
}
),
encoding="utf-8",
)
catalog = ExtensionCatalog(project_dir)
assert [entry.name for entry in catalog.get_active_catalogs()] == [
"catalog-1",
"catalog-2",
]
@pytest.mark.parametrize(
("url", "expected_detail"),
[
("relative/catalog.json", "HTTPS"),
("https:///no-host", "valid URL with a host"),
],
)
def test_load_catalog_config_invalid_url_includes_context(
self, temp_dir, url, expected_detail
):
"""Invalid catalog URLs include the config path and entry index."""
import yaml as yaml_module
project_dir = self._make_project(temp_dir)
config_path = project_dir / ".specify" / "extension-catalogs.yml"
config_path.write_text(
yaml_module.dump({"catalogs": [{"name": "bad", "url": url}]}),
encoding="utf-8",
)
catalog = ExtensionCatalog(project_dir)
with pytest.raises(ValidationError) as exc_info:
catalog.get_active_catalogs()
message = str(exc_info.value)
assert "Invalid catalog URL" in message
assert str(config_path) in message
assert "index 0" in message
assert expected_detail in message
# --- Merge conflict resolution ---
def test_merge_conflict_higher_priority_wins(self, temp_dir):

View File

@@ -1224,6 +1224,10 @@ class TestExtensionPriorityResolution:
class TestPresetCatalog:
"""Test template catalog functionality."""
def _inject_github_config(self, monkeypatch, token_env="GH_TOKEN"):
from tests.auth_helpers import inject_github_config
inject_github_config(monkeypatch, token_env)
def test_default_catalog_url(self, project_dir):
"""Test default catalog URL."""
catalog = PresetCatalog(project_dir)
@@ -1418,6 +1422,7 @@ class TestPresetCatalog:
"""When GITHUB_TOKEN is whitespace-only, GH_TOKEN is used as fallback."""
monkeypatch.setenv("GITHUB_TOKEN", " ")
monkeypatch.setenv("GH_TOKEN", "ghp_fallback")
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
assert req.get_header("Authorization") == "Bearer ghp_fallback"
@@ -1426,6 +1431,7 @@ class TestPresetCatalog:
"""GITHUB_TOKEN is attached for raw.githubusercontent.com URLs."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
monkeypatch.delenv("GH_TOKEN", raising=False)
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
@@ -1434,58 +1440,50 @@ class TestPresetCatalog:
"""GH_TOKEN is used when GITHUB_TOKEN is absent."""
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
monkeypatch.setenv("GH_TOKEN", "ghp_ghtoken")
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/pack.zip")
assert req.get_header("Authorization") == "Bearer ghp_ghtoken"
def test_make_request_github_token_takes_precedence(self, project_dir, monkeypatch):
"""GITHUB_TOKEN takes precedence over GH_TOKEN when both are set."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_primary")
monkeypatch.setenv("GH_TOKEN", "ghp_secondary")
def test_make_request_gh_token_takes_precedence(self, project_dir, monkeypatch):
"""When auth.json uses GH_TOKEN, that token is used regardless of GITHUB_TOKEN."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_secondary")
monkeypatch.setenv("GH_TOKEN", "ghp_primary")
self._inject_github_config(monkeypatch, token_env="GH_TOKEN")
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://api.github.com/repos/org/repo")
assert req.get_header("Authorization") == "Bearer ghp_primary"
def test_make_request_token_added_for_codeload_github_com(self, project_dir, monkeypatch):
"""GITHUB_TOKEN is attached for codeload.github.com URLs (GitHub archive redirects)."""
"""GITHUB_TOKEN is attached for codeload.github.com URLs."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://codeload.github.com/org/repo/zip/refs/tags/v1.0.0")
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
def test_make_request_token_not_added_for_non_github_url(self, project_dir, monkeypatch):
"""Auth header is never attached to non-GitHub URLs to prevent credential leakage."""
def test_make_request_no_auth_for_non_matching_host(self, project_dir, monkeypatch):
"""Auth is NOT attached to hosts not listed in auth.json."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://internal.example.com/catalog.json")
assert "Authorization" not in req.headers
def test_make_request_token_not_added_for_github_lookalike_host(self, project_dir, monkeypatch):
"""Auth header is not attached to hosts that include github.com as a suffix."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
def test_make_request_no_auth_when_no_config(self, project_dir, monkeypatch):
"""No auth header when no auth.json config exists."""
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GH_TOKEN", raising=False)
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://github.com.evil.com/org/repo/releases/download/v1/pack.zip")
assert "Authorization" not in req.headers
def test_make_request_token_not_added_for_github_in_path(self, project_dir, monkeypatch):
"""Auth header is not attached when github.com appears only in the URL path."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://evil.example.com/github.com/org/repo/releases/download/v1/pack.zip")
assert "Authorization" not in req.headers
def test_make_request_token_not_added_for_github_in_query(self, project_dir, monkeypatch):
"""Auth header is not attached when github.com appears only in the query string."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://evil.example.com/download?source=https://github.com/org/repo/v1/pack.zip")
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/pack.zip")
assert "Authorization" not in req.headers
def test_fetch_single_catalog_sends_auth_header(self, project_dir, monkeypatch):
"""_fetch_single_catalog passes Authorization header via opener for GitHub URLs."""
"""_fetch_single_catalog passes Authorization header when configured."""
from unittest.mock import patch, MagicMock
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
catalog = PresetCatalog(project_dir)
catalog_data = {"schema_version": "1.0", "presets": {}}
@@ -1493,6 +1491,7 @@ class TestPresetCatalog:
mock_response.read.return_value = json.dumps(catalog_data).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://raw.githubusercontent.com/org/repo/main/presets/catalog.json"
captured = {}
mock_opener = MagicMock()
@@ -1510,16 +1509,17 @@ class TestPresetCatalog:
install_allowed=True,
)
with patch("urllib.request.build_opener", return_value=mock_opener):
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
catalog._fetch_single_catalog(entry, force_refresh=True)
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
def test_download_pack_sends_auth_header(self, project_dir, monkeypatch):
"""download_pack passes Authorization header via opener for GitHub URLs."""
"""download_pack passes Authorization header when configured."""
from unittest.mock import patch, MagicMock
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
catalog = PresetCatalog(project_dir)
import io
@@ -1551,7 +1551,7 @@ class TestPresetCatalog:
}
with patch.object(catalog, "get_pack_info", return_value=pack_info), \
patch("urllib.request.build_opener", return_value=mock_opener):
patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
catalog.download_pack("test-pack", target_dir=project_dir)
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
@@ -1830,6 +1830,31 @@ class TestPresetCatalogMultiCatalog:
with pytest.raises(PresetValidationError, match="Invalid priority"):
catalog._load_catalog_config(config_path)
def test_load_catalog_config_rejects_boolean_priority(self, project_dir):
"""A YAML ``priority: true`` is a typo, not a request for priority 1.
``bool`` is a subclass of ``int`` in Python, so ``int(True)`` silently
returns ``1``. Without an explicit guard a malformed config like
``priority: yes`` would be accepted as a valid priority of 1 and
silently change catalog ordering. The sibling integration-catalog
reader rejects this case (see ``catalogs.py``); the preset catalog
reader must stay consistent.
"""
config_path = project_dir / ".specify" / "preset-catalogs.yml"
config_path.write_text(yaml.dump({
"catalogs": [
{
"name": "bool-priority",
"url": "https://example.com/catalog.json",
"priority": True,
}
]
}))
catalog = PresetCatalog(project_dir)
with pytest.raises(PresetValidationError, match="Invalid priority|expected integer"):
catalog._load_catalog_config(config_path)
def test_load_catalog_config_install_allowed_string(self, project_dir):
"""Test that install_allowed accepts string values."""
config_path = project_dir / ".specify" / "preset-catalogs.yml"
@@ -1949,7 +1974,16 @@ def install_self_test_preset(manager: PresetManager, speckit_version: str = "0.1
class TestSelfTestPreset:
"""Tests using the self-test preset that ships with the repo."""
"""Tests using the self-test preset that ships with the repo.
The self-test preset ships a wrap-strategy command (``speckit.wrap-test``)
without a corresponding core base layer; reconciliation deliberately
surfaces a UserWarning in that case. Tests install via
``install_self_test_preset`` (defined above), which scopes a narrow
``warnings.filterwarnings`` block to that specific message and
``UserWarning`` category — so the expected warning stays quiet without
masking unrelated warnings or real reconciliation failures.
"""
def test_self_test_preset_exists(self):
"""Verify the self-test preset directory and manifest exist."""
@@ -2237,7 +2271,12 @@ class TestInitOptions:
class TestPresetSkills:
"""Tests for preset skill registration and unregistration."""
"""Tests for preset skill registration and unregistration.
Tests that install the self-test preset use ``install_self_test_preset``
which scopes a narrow filter to the expected wrap-strategy warning.
Reconciliation failures remain audible so real regressions surface.
"""
def _write_init_options(self, project_dir, ai="claude", ai_skills=True, script="sh"):
from specify_cli import save_init_options
@@ -2252,6 +2291,37 @@ class TestPresetSkills:
)
return skill_dir
def _create_command_preset(self, temp_dir, preset_id, command_name, description, body):
preset_dir = temp_dir / preset_id
preset_dir.mkdir()
(preset_dir / "commands").mkdir()
command_file = f"{command_name}.md"
(preset_dir / "commands" / command_file).write_text(
f"---\ndescription: {description}\n---\n\n{body}\n"
)
manifest_data = {
"schema_version": "1.0",
"preset": {
"id": preset_id,
"name": preset_id,
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": command_name,
"file": f"commands/{command_file}",
}
]
},
}
with open(preset_dir / "preset.yml", "w") as f:
yaml.dump(manifest_data, f)
return preset_dir
def test_skill_overridden_on_preset_install(self, project_dir, temp_dir):
"""When --ai-skills was used, a preset command override should update the skill."""
# Simulate --ai-skills having been used: write init-options + create skill
@@ -2276,6 +2346,120 @@ class TestPresetSkills:
metadata = manager.registry.get("self-test")
assert "speckit-specify" in metadata.get("registered_skills", [])
def test_core_command_override_skill_uses_preset_command_description(self, project_dir, temp_dir):
"""Preset skill overrides for core commands should keep preset frontmatter descriptions."""
self._write_init_options(project_dir, ai="claude")
skills_dir = project_dir / ".claude" / "skills"
self._create_skill(skills_dir, "speckit-taskstoissues")
preset_dir = temp_dir / "taskstoissues-description"
preset_dir.mkdir()
(preset_dir / "commands").mkdir()
(preset_dir / "commands" / "speckit.repro.taskstoissues.md").write_text(
"---\n"
"description: COMMAND-FRONTMATTER-DESCRIPTION\n"
"---\n\n"
"# Repro command body\n"
)
manifest_data = {
"schema_version": "1.0",
"preset": {
"id": "taskstoissues-description",
"name": "Taskstoissues Description",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.taskstoissues",
"file": "commands/speckit.repro.taskstoissues.md",
"description": "MANIFEST-DESCRIPTION",
"replaces": "speckit.taskstoissues",
"strategy": "replace",
}
]
},
}
with open(preset_dir / "preset.yml", "w") as f:
yaml.dump(manifest_data, f)
manager = PresetManager(project_dir)
manager.install_from_directory(preset_dir, "0.1.5")
skill_file = skills_dir / "speckit-taskstoissues" / "SKILL.md"
content = skill_file.read_text()
assert "description: COMMAND-FRONTMATTER-DESCRIPTION" in content
assert "Convert tasks from tasks.md into GitHub issues." not in content
assert "source: preset:taskstoissues-description" in content
def test_core_skill_restore_uses_core_command_description(self, project_dir, temp_dir):
"""Core skill restore should keep core command frontmatter descriptions."""
self._write_init_options(project_dir, ai="claude")
skills_dir = project_dir / ".claude" / "skills"
self._create_skill(skills_dir, "speckit-taskstoissues")
core_cmds = project_dir / ".specify" / "templates" / "commands"
core_cmds.mkdir(parents=True, exist_ok=True)
(core_cmds / "taskstoissues.md").write_text(
"---\n"
"description: CORE-FRONTMATTER-DESCRIPTION\n"
"---\n\n"
"core taskstoissues body\n"
)
preset_dir = self._create_command_preset(
temp_dir,
"taskstoissues-restore",
"speckit.taskstoissues",
"PRESET-FRONTMATTER-DESCRIPTION",
"preset taskstoissues body\n",
)
manager = PresetManager(project_dir)
manager.install_from_directory(preset_dir, "0.1.5")
manager.remove("taskstoissues-restore")
skill_file = skills_dir / "speckit-taskstoissues" / "SKILL.md"
content = skill_file.read_text()
assert "description: CORE-FRONTMATTER-DESCRIPTION" in content
assert "Convert tasks from tasks.md into GitHub issues." not in content
assert "source: templates/commands/taskstoissues.md" in content
assert "core taskstoissues body" in content
def test_override_skill_reconcile_uses_override_command_description(self, project_dir, temp_dir):
"""Override skill reconciliation should keep override frontmatter descriptions."""
self._write_init_options(project_dir, ai="claude")
skills_dir = project_dir / ".claude" / "skills"
self._create_skill(skills_dir, "speckit-taskstoissues")
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
overrides_dir.mkdir(parents=True)
(overrides_dir / "speckit.taskstoissues.md").write_text(
"---\n"
"description: OVERRIDE-FRONTMATTER-DESCRIPTION\n"
"---\n\n"
"override taskstoissues body\n"
)
preset_dir = self._create_command_preset(
temp_dir,
"taskstoissues-reconcile",
"speckit.taskstoissues",
"PRESET-FRONTMATTER-DESCRIPTION",
"preset taskstoissues body\n",
)
manager = PresetManager(project_dir)
manager.install_from_directory(preset_dir, "0.1.5")
skill_file = skills_dir / "speckit-taskstoissues" / "SKILL.md"
content = skill_file.read_text()
assert "description: OVERRIDE-FRONTMATTER-DESCRIPTION" in content
assert "Convert tasks from tasks.md into GitHub issues." not in content
assert "source: override:speckit.taskstoissues" in content
assert "override taskstoissues body" in content
def test_skill_not_updated_when_ai_skills_disabled(self, project_dir, temp_dir):
"""When --ai-skills was NOT used, preset install should not touch skills."""
self._write_init_options(project_dir, ai="qwen", ai_skills=False)

View File

@@ -115,6 +115,36 @@ def ext_ps_git_repo(tmp_path: Path) -> Path:
return tmp_path
@pytest.fixture
def ps_git_repo(tmp_path: Path) -> Path:
"""Create a temp git repo with PowerShell scripts and a BOM-prefixed template."""
subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True)
subprocess.run(
["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True
)
subprocess.run(
["git", "config", "user.name", "Test User"], cwd=tmp_path, check=True
)
subprocess.run(
["git", "commit", "--allow-empty", "-m", "init", "-q"],
cwd=tmp_path,
check=True,
)
ps_dir = tmp_path / "scripts" / "powershell"
ps_dir.mkdir(parents=True)
shutil.copy(CREATE_FEATURE_PS, ps_dir / "create-new-feature.ps1")
common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
shutil.copy(common_ps, ps_dir / "common.ps1")
templates_dir = tmp_path / ".specify" / "templates"
templates_dir.mkdir(parents=True)
# Write a BOM-prefixed template to ensure the WriteAllText fix is actually exercised.
# If WriteAllText regresses, the output file will contain the BOM.
bom = b"\xef\xbb\xbf"
template_content = "# Feature Spec\n\nDescribe the feature here.\n"
(templates_dir / "spec-template.md").write_bytes(bom + template_content.encode("utf-8"))
return tmp_path
@pytest.fixture
def no_git_dir(tmp_path: Path) -> Path:
"""Create a temp directory without git, but with scripts."""
@@ -381,6 +411,7 @@ class TestGetFeaturePathsSinglePrefix:
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == str(tmp_path / "specs" / "001-target-spec")
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
def test_ps_specify_feature_prefixed_resolves_by_prefix(self, git_repo: Path):
"""PowerShell Get-FeaturePathsEnv: same prefix stripping as bash."""
@@ -650,6 +681,45 @@ class TestAllowExistingBranchPowerShell:
assert "$switchBranchError = git checkout -q $branchName 2>&1 | Out-String" in contents
assert "exists but could not be checked out.`n$($switchBranchError.Trim())" in contents
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
@pytest.mark.skipif(
os.name != "nt" or shutil.which("powershell.exe") is None,
reason="Windows PowerShell not installed",
)
def test_ps_spec_file_written_without_bom(self, ps_git_repo: Path):
"""spec.md generated from a BOM-prefixed template must not contain a UTF-8 BOM."""
result = subprocess.run(
[
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(CREATE_FEATURE_PS),
"-ShortName",
"bom-check",
"BOM check feature",
],
cwd=ps_git_repo,
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
spec_file = next((ps_git_repo / "specs").rglob("spec.md"), None)
assert spec_file is not None, (
f"spec.md was not created.\nstdout: {result.stdout}\nstderr: {result.stderr}"
)
raw = spec_file.read_bytes()
assert not raw.startswith(b"\xef\xbb\xbf"), (
f"spec.md must not start with a UTF-8 BOM — got first 3 bytes: {raw[:3]!r}"
)
# Verify template content was copied (not just an empty New-Item fallback)
assert "Feature Spec" in raw.decode("utf-8"), (
"spec.md does not contain template content — WriteAllText path was not exercised"
)
class TestGitExtensionParity:
def test_bash_extension_surfaces_checkout_errors(self):
@@ -904,30 +974,6 @@ def run_ps_script(cwd: Path, *args: str) -> subprocess.CompletedProcess:
return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
@pytest.fixture
def ps_git_repo(tmp_path: Path) -> Path:
"""Create a temp git repo with PowerShell scripts and .specify dir."""
subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True)
subprocess.run(
["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True
)
subprocess.run(
["git", "config", "user.name", "Test User"], cwd=tmp_path, check=True
)
subprocess.run(
["git", "commit", "--allow-empty", "-m", "init", "-q"],
cwd=tmp_path,
check=True,
)
ps_dir = tmp_path / "scripts" / "powershell"
ps_dir.mkdir(parents=True)
shutil.copy(CREATE_FEATURE_PS, ps_dir / "create-new-feature.ps1")
common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
shutil.copy(common_ps, ps_dir / "common.ps1")
(tmp_path / ".specify" / "templates").mkdir(parents=True)
return tmp_path
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not available")
class TestPowerShellDryRun:
def test_ps_dry_run_outputs_name(self, ps_git_repo: Path):
@@ -1259,13 +1305,13 @@ class TestFeatureDirectoryResolution:
pytest.fail("FEATURE_DIR not found in PowerShell output")
# ── Description Quoting Tests (issue #2339) ──────────────────────────────────
@requires_bash
class TestDescriptionQuoting:
"""Descriptions with quotes, apostrophes, and backslashes must not break the script.
Regression tests for https://github.com/github/spec-kit/issues/2339
"""
@@ -1273,9 +1319,9 @@ class TestDescriptionQuoting:
"description",
[
"Add user's profile page",
"Fix the \"login\" bug",
'Fix the "login" bug',
"Handle path\\with\\backslashes",
"It's a \"complex\" feature\\here",
'It\'s a "complex" feature\\here',
],
ids=["apostrophe", "double-quotes", "backslashes", "mixed"],
)
@@ -1290,16 +1336,22 @@ class TestDescriptionQuoting:
"description",
[
"Add user's profile page",
"Fix the \"login\" bug",
'Fix the "login" bug',
"Handle path\\with\\backslashes",
"It's a \"complex\" feature\\here",
'It\'s a "complex" feature\\here',
],
ids=["apostrophe", "double-quotes", "backslashes", "mixed"],
)
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."""
script = (
ext_git_repo / ".specify" / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh"
ext_git_repo
/ ".specify"
/ "extensions"
/ "git"
/ "scripts"
/ "bash"
/ "create-new-feature.sh"
)
result = subprocess.run(
["bash", str(script), "--dry-run", "--short-name", "feat", description],
@@ -1321,3 +1373,4 @@ class TestDescriptionQuoting:
"""Plain description without special characters continues to work."""
result = run_script(git_repo, "--dry-run", "--short-name", "feat", "Add login feature")
assert result.returncode == 0, result.stderr

View File

@@ -16,14 +16,13 @@ from unittest.mock import MagicMock, patch
import pytest
from typer.testing import CliRunner
from specify_cli import (
_get_installed_version,
from specify_cli import app
from specify_cli._version import (
_fetch_latest_release_tag,
_get_installed_version,
_is_newer,
_normalize_tag,
app,
)
from tests.conftest import strip_ansi
runner = CliRunner()
@@ -31,6 +30,10 @@ runner = CliRunner()
SENTINEL_GH_TOKEN = "SENTINEL-GH-TOKEN-VALUE"
SENTINEL_GITHUB_TOKEN = "SENTINEL-GITHUB-TOKEN-VALUE"
_RATE_LIMITED_REASON = (
"rate limited (configure ~/.specify/auth.json with a GitHub token)"
)
def _mock_urlopen_response(payload: dict) -> MagicMock:
body = json.dumps(payload).encode("utf-8")
@@ -66,11 +69,20 @@ class TestSelfUpgradeStub:
]
def test_stub_makes_no_network_call(self):
# If the stub ever starts calling urllib, this patch's side_effect
# would fire and the assertion below would fail.
with patch(
"specify_cli.urllib.request.urlopen",
side_effect=AssertionError("stub must not hit the network"),
# The stub must not hit the network via either urllib path:
# unauthenticated requests use urlopen() directly; authenticated ones
# go through build_opener(...).open(). Both are patched so that any
# accidental network call raises immediately.
network_error = AssertionError("stub must not hit the network")
with (
patch(
"specify_cli.authentication.http.urllib.request.urlopen",
side_effect=network_error,
),
patch(
"specify_cli.authentication.http.urllib.request.build_opener",
side_effect=network_error,
),
):
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 0
@@ -137,8 +149,8 @@ class TestNormalizeTag:
class TestUserStory1:
def test_newer_available_prints_update_and_install_command(self):
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
"specify_cli.urllib.request.urlopen",
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
"specify_cli.authentication.http.urllib.request.urlopen",
return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}),
):
result = runner.invoke(app, ["self", "check"])
@@ -150,8 +162,8 @@ class TestUserStory1:
assert "git+https://github.com/github/spec-kit.git@v0.9.0" in output
def test_up_to_date_prints_current_only(self):
with patch("specify_cli._get_installed_version", return_value="0.9.0"), patch(
"specify_cli.urllib.request.urlopen",
with patch("specify_cli._version._get_installed_version", return_value="0.9.0"), patch(
"specify_cli.authentication.http.urllib.request.urlopen",
return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}),
):
result = runner.invoke(app, ["self", "check"])
@@ -162,8 +174,8 @@ class TestUserStory1:
assert "git+https://" not in output
def test_dev_build_ahead_of_release_is_up_to_date(self):
with patch("specify_cli._get_installed_version", return_value="0.7.5.dev0"), patch(
"specify_cli.urllib.request.urlopen",
with patch("specify_cli._version._get_installed_version", return_value="0.7.5.dev0"), patch(
"specify_cli.authentication.http.urllib.request.urlopen",
return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}),
):
result = runner.invoke(app, ["self", "check"])
@@ -173,8 +185,8 @@ class TestUserStory1:
assert "Up to date" in output
def test_unknown_installed_still_prints_latest_and_reinstall(self):
with patch("specify_cli._get_installed_version", return_value="unknown"), patch(
"specify_cli.urllib.request.urlopen",
with patch("specify_cli._version._get_installed_version", return_value="unknown"), patch(
"specify_cli.authentication.http.urllib.request.urlopen",
return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}),
):
result = runner.invoke(app, ["self", "check"])
@@ -185,8 +197,8 @@ class TestUserStory1:
assert "git+https://github.com/github/spec-kit.git@v0.7.4" in output
def test_unparseable_tag_routes_to_indeterminate(self):
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
"specify_cli.urllib.request.urlopen",
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
"specify_cli.authentication.http.urllib.request.urlopen",
return_value=_mock_urlopen_response({"tag_name": "not-a-version"}),
):
result = runner.invoke(app, ["self", "check"])
@@ -200,7 +212,7 @@ class TestUserStory1:
class TestFailureCategorization:
def test_urlerror_maps_to_offline(self):
with patch(
"specify_cli.urllib.request.urlopen",
"specify_cli.authentication.http.urllib.request.urlopen",
side_effect=urllib.error.URLError("no route to host"),
):
tag, reason = _fetch_latest_release_tag()
@@ -209,7 +221,7 @@ class TestFailureCategorization:
def test_timeout_maps_to_offline(self):
with patch(
"specify_cli.urllib.request.urlopen",
"specify_cli.authentication.http.urllib.request.urlopen",
side_effect=TimeoutError(),
):
tag, reason = _fetch_latest_release_tag()
@@ -218,17 +230,17 @@ class TestFailureCategorization:
def test_403_maps_to_rate_limited(self):
with patch(
"specify_cli.urllib.request.urlopen",
"specify_cli.authentication.http.urllib.request.urlopen",
side_effect=_http_error(403, "rate limited"),
):
tag, reason = _fetch_latest_release_tag()
assert tag is None
assert reason == "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)"
assert reason == _RATE_LIMITED_REASON
@pytest.mark.parametrize("code", [404, 500, 502])
def test_other_http_uses_code_string(self, code):
with patch(
"specify_cli.urllib.request.urlopen",
"specify_cli.authentication.http.urllib.request.urlopen",
side_effect=_http_error(code, "oops"),
):
tag, reason = _fetch_latest_release_tag()
@@ -238,7 +250,7 @@ class TestFailureCategorization:
def test_generic_exception_propagates(self):
# Per research D-006, no catch-all exists; RuntimeError MUST bubble.
with patch(
"specify_cli.urllib.request.urlopen",
"specify_cli.authentication.http.urllib.request.urlopen",
side_effect=RuntimeError("boom"),
):
with pytest.raises(RuntimeError):
@@ -247,7 +259,7 @@ class TestFailureCategorization:
_FAILURE_CASES = [
("offline or timeout", urllib.error.URLError("down")),
("rate limited (try setting GH_TOKEN or GITHUB_TOKEN)", _http_error(403)),
(_RATE_LIMITED_REASON, _http_error(403)),
("HTTP 500", _http_error(500)),
]
@@ -257,23 +269,22 @@ class TestUserStory2:
def test_failure_prints_installed_plus_one_line_reason(
self, expected_reason, side_effect
):
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
"specify_cli.urllib.request.urlopen", side_effect=side_effect
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
):
result = runner.invoke(app, ["self", "check"])
output = strip_ansi(result.output)
assert "Installed: 0.7.4" in output
if expected_reason == "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)":
if expected_reason == _RATE_LIMITED_REASON:
assert "Could not check latest release: rate limited" in output
assert "GH_TOKEN" in output
assert "GITHUB_TOKEN" in output
assert "~/.specify/auth.json" in output
else:
assert f"Could not check latest release: {expected_reason}" in output
@pytest.mark.parametrize("_expected_reason, side_effect", _FAILURE_CASES)
def test_failure_exits_zero(self, _expected_reason, side_effect):
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
"specify_cli.urllib.request.urlopen", side_effect=side_effect
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
):
result = runner.invoke(app, ["self", "check"])
assert result.exit_code == 0
@@ -282,8 +293,8 @@ class TestUserStory2:
def test_failure_output_contains_no_traceback_no_url(
self, _expected_reason, side_effect
):
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
"specify_cli.urllib.request.urlopen", side_effect=side_effect
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
):
result = runner.invoke(app, ["self", "check"])
combined = (result.output or "") + (result.stderr or "")
@@ -302,12 +313,20 @@ def _capture_request_via_urlopen():
return captured, _side_effect
def _inject_github_config(monkeypatch, token_env="GH_TOKEN"):
from tests.auth_helpers import inject_github_config
inject_github_config(monkeypatch, token_env)
class TestUserStory3:
def test_gh_token_attached_as_bearer_header(self, monkeypatch):
monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN)
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
_inject_github_config(monkeypatch, token_env="GH_TOKEN")
captured, side_effect = _capture_request_via_urlopen()
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
mock_opener = MagicMock()
mock_opener.open.side_effect = side_effect
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
_fetch_latest_release_tag()
req = captured["request"]
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GH_TOKEN}"
@@ -315,8 +334,11 @@ class TestUserStory3:
def test_github_token_used_when_gh_token_unset(self, monkeypatch):
monkeypatch.delenv("GH_TOKEN", raising=False)
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
_inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
captured, side_effect = _capture_request_via_urlopen()
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
mock_opener = MagicMock()
mock_opener.open.side_effect = side_effect
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
_fetch_latest_release_tag()
req = captured["request"]
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}"
@@ -325,7 +347,7 @@ class TestUserStory3:
monkeypatch.delenv("GH_TOKEN", raising=False)
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
captured, side_effect = _capture_request_via_urlopen()
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
_fetch_latest_release_tag()
req = captured["request"]
assert req.get_header("Authorization") is None
@@ -333,8 +355,9 @@ class TestUserStory3:
def test_empty_string_gh_token_treated_as_unset(self, monkeypatch):
monkeypatch.setenv("GH_TOKEN", "")
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
_inject_github_config(monkeypatch, token_env="GH_TOKEN")
captured, side_effect = _capture_request_via_urlopen()
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
_fetch_latest_release_tag()
req = captured["request"]
assert req.get_header("Authorization") is None
@@ -342,8 +365,9 @@ class TestUserStory3:
def test_whitespace_only_gh_token_treated_as_unset(self, monkeypatch):
monkeypatch.setenv("GH_TOKEN", " ")
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
_inject_github_config(monkeypatch, token_env="GH_TOKEN")
captured, side_effect = _capture_request_via_urlopen()
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
_fetch_latest_release_tag()
req = captured["request"]
assert req.get_header("Authorization") is None
@@ -351,8 +375,11 @@ class TestUserStory3:
def test_whitespace_only_gh_token_falls_back_to_github_token(self, monkeypatch):
monkeypatch.setenv("GH_TOKEN", " ")
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
_inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
captured, side_effect = _capture_request_via_urlopen()
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
mock_opener = MagicMock()
mock_opener.open.side_effect = side_effect
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
_fetch_latest_release_tag()
req = captured["request"]
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}"
@@ -363,8 +390,8 @@ class TestUserStory3:
):
monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN)
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
"specify_cli.urllib.request.urlopen", side_effect=side_effect
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
):
result = runner.invoke(app, ["self", "check"])
combined = strip_ansi((result.output or "") + (result.stderr or ""))
@@ -376,8 +403,8 @@ class TestUserStory3:
):
monkeypatch.delenv("GH_TOKEN", raising=False)
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
"specify_cli.urllib.request.urlopen", side_effect=side_effect
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
):
result = runner.invoke(app, ["self", "check"])
combined = strip_ansi((result.output or "") + (result.stderr or ""))

View File

@@ -0,0 +1,21 @@
"""Regression guard: utility and asset symbols importable from specify_cli."""
from specify_cli import (
run_command, check_tool, is_git_repo, init_git_repo,
handle_vscode_settings, merge_json_files,
get_speckit_version,
CLAUDE_LOCAL_PATH, CLAUDE_NPM_LOCAL_PATH,
)
from pathlib import Path
def test_utils_symbols_importable():
assert callable(check_tool)
assert callable(merge_json_files)
assert callable(is_git_repo)
def test_get_speckit_version_returns_string():
version = get_speckit_version()
assert isinstance(version, str) and len(version) > 0
def test_claude_paths_are_paths():
assert isinstance(CLAUDE_LOCAL_PATH, Path)
assert isinstance(CLAUDE_NPM_LOCAL_PATH, Path)

View File

@@ -0,0 +1,41 @@
"""Regression guard: version symbols must remain importable from specify_cli."""
from specify_cli import (
GITHUB_API_LATEST,
self_check,
self_upgrade,
)
def test_version_symbols_importable():
assert isinstance(GITHUB_API_LATEST, str)
assert GITHUB_API_LATEST.startswith("https://")
assert callable(self_check)
assert callable(self_upgrade)
def test_version_symbols_available_from_star_import():
namespace = {}
exec("from specify_cli import *", namespace)
for symbol in ("GITHUB_API_LATEST", "self_check", "self_upgrade"):
assert symbol in namespace
def test_version_module_symbols_directly_importable():
from specify_cli._version import (
GITHUB_API_LATEST,
_fetch_latest_release_tag,
_get_installed_version,
_is_newer,
_normalize_tag,
self_app,
self_check,
self_upgrade,
)
assert callable(_get_installed_version)
assert callable(_normalize_tag)
assert callable(_is_newer)
assert callable(_fetch_latest_release_tag)
assert callable(self_check)
assert callable(self_upgrade)
assert self_app is not None

View File

@@ -463,6 +463,7 @@ class TestCommandStep:
assert any("missing 'command'" in e for e in errors)
def test_step_override_integration(self):
from unittest.mock import patch
from specify_cli.workflows.steps.command import CommandStep
from specify_cli.workflows.base import StepContext
@@ -474,7 +475,8 @@ class TestCommandStep:
"integration": "gemini",
"input": {},
}
result = step.execute(config, ctx)
with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None):
result = step.execute(config, ctx)
assert result.output["integration"] == "gemini"
def test_step_override_model(self):
@@ -626,6 +628,7 @@ class TestPromptStep:
assert result.output["dispatched"] is False
def test_execute_with_step_integration(self):
from unittest.mock import patch
from specify_cli.workflows.steps.prompt import PromptStep
from specify_cli.workflows.base import StepContext
@@ -637,10 +640,12 @@ class TestPromptStep:
"prompt": "Summarize the codebase",
"integration": "gemini",
}
result = step.execute(config, ctx)
with patch("specify_cli.workflows.steps.prompt.shutil.which", return_value=None):
result = step.execute(config, ctx)
assert result.output["integration"] == "gemini"
def test_execute_with_model(self):
from unittest.mock import patch
from specify_cli.workflows.steps.prompt import PromptStep
from specify_cli.workflows.base import StepContext
@@ -652,7 +657,8 @@ class TestPromptStep:
"prompt": "hello",
"model": "opus-4",
}
result = step.execute(config, ctx)
with patch("specify_cli.workflows.steps.prompt.shutil.which", return_value=None):
result = step.execute(config, ctx)
assert result.output["model"] == "opus-4"
def test_dispatch_with_mock_cli(self, tmp_path):
@@ -1495,6 +1501,656 @@ steps:
with pytest.raises(ValueError, match="Required input"):
engine.execute(definition, {})
def test_integration_auto_default_uses_project_integration(self, project_dir):
"""`integration: auto` should resolve to .specify/integration.json's integration."""
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
specify_dir = project_dir / ".specify"
specify_dir.mkdir(parents=True, exist_ok=True)
(specify_dir / "integration.json").write_text(
json.dumps({"integration": "opencode", "version": "0.7.4"}),
encoding="utf-8",
)
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "auto-default"
name: "Auto Default"
version: "1.0.0"
inputs:
integration:
type: string
default: "auto"
""")
engine = WorkflowEngine(project_dir)
resolved = engine._resolve_inputs(definition, {})
assert resolved["integration"] == "opencode"
def test_integration_auto_default_falls_back_when_no_integration_json(self, project_dir):
"""`integration: auto` should keep the literal "auto" when project state is missing.
The engine itself must not invent an integration when
``.specify/integration.json`` is absent; any later validation or
command resolution will handle an unresolved ``"auto"`` value.
"""
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "auto-fallback"
name: "Auto Fallback"
version: "1.0.0"
inputs:
integration:
type: string
default: "auto"
""")
engine = WorkflowEngine(project_dir)
resolved = engine._resolve_inputs(definition, {})
assert resolved["integration"] == "auto"
def test_integration_explicit_input_overrides_auto(self, project_dir):
"""An explicit --input integration=X must win over `auto` even when integration.json exists."""
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
specify_dir = project_dir / ".specify"
specify_dir.mkdir(parents=True, exist_ok=True)
(specify_dir / "integration.json").write_text(
json.dumps({"integration": "opencode"}),
encoding="utf-8",
)
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "explicit-wins"
name: "Explicit Wins"
version: "1.0.0"
inputs:
integration:
type: string
default: "auto"
""")
engine = WorkflowEngine(project_dir)
resolved = engine._resolve_inputs(definition, {"integration": "claude"})
assert resolved["integration"] == "claude"
def test_integration_explicit_auto_resolves_like_default(self, project_dir):
"""Passing ``integration=auto`` explicitly must resolve the sentinel,
not pass it through as a literal — the workflow prompt advertises
``auto`` as a valid value, so the dispatch path must never see it.
"""
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
specify_dir = project_dir / ".specify"
specify_dir.mkdir(parents=True, exist_ok=True)
(specify_dir / "integration.json").write_text(
json.dumps({"integration": "opencode"}),
encoding="utf-8",
)
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "explicit-auto"
name: "Explicit Auto"
version: "1.0.0"
inputs:
integration:
type: string
default: "auto"
""")
engine = WorkflowEngine(project_dir)
resolved = engine._resolve_inputs(definition, {"integration": "auto"})
assert resolved["integration"] == "opencode"
def test_integration_auto_ignores_malformed_integration_json(self, project_dir):
"""A malformed integration.json must not crash — fall back to the literal default."""
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
specify_dir = project_dir / ".specify"
specify_dir.mkdir(parents=True, exist_ok=True)
(specify_dir / "integration.json").write_text("{not json", encoding="utf-8")
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "auto-malformed"
name: "Auto Malformed"
version: "1.0.0"
inputs:
integration:
type: string
default: "auto"
""")
engine = WorkflowEngine(project_dir)
resolved = engine._resolve_inputs(definition, {})
assert resolved["integration"] == "auto"
def test_integration_auto_ignores_non_utf8_integration_json(self, project_dir):
"""A non-UTF8 integration.json must not crash — fall back to the literal default."""
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
specify_dir = project_dir / ".specify"
specify_dir.mkdir(parents=True, exist_ok=True)
# 0xFF is invalid as the leading byte of a UTF-8 sequence, so
# ``Path.read_text(encoding="utf-8")`` raises UnicodeDecodeError.
(specify_dir / "integration.json").write_bytes(b"\xff\xfe\x00\x00")
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "auto-non-utf8"
name: "Auto Non UTF-8"
version: "1.0.0"
inputs:
integration:
type: string
default: "auto"
""")
engine = WorkflowEngine(project_dir)
resolved = engine._resolve_inputs(definition, {})
assert resolved["integration"] == "auto"
def test_integration_auto_resolves_modern_normalized_state(self, project_dir):
"""`integration: auto` must resolve modern state files that record
``default_integration`` / ``installed_integrations`` and omit the
legacy ``integration`` field."""
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
specify_dir = project_dir / ".specify"
specify_dir.mkdir(parents=True, exist_ok=True)
(specify_dir / "integration.json").write_text(
json.dumps(
{
"version": "0.8.3",
"integration_state_schema": 1,
"default_integration": "claude",
"installed_integrations": ["claude", "copilot"],
"integration_settings": {},
}
),
encoding="utf-8",
)
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "auto-modern"
name: "Auto Modern"
version: "1.0.0"
inputs:
integration:
type: string
default: "auto"
""")
engine = WorkflowEngine(project_dir)
resolved = engine._resolve_inputs(definition, {})
assert resolved["integration"] == "claude"
def test_integration_auto_rejects_future_state_schema(self, project_dir):
"""`integration: auto` must not silently use a state file written by a newer
CLI (``integration_state_schema`` greater than the current supported value);
the resolver falls back to the literal default rather than guessing."""
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
from specify_cli.integration_state import INTEGRATION_STATE_SCHEMA
specify_dir = project_dir / ".specify"
specify_dir.mkdir(parents=True, exist_ok=True)
(specify_dir / "integration.json").write_text(
json.dumps(
{
"version": "99.0.0",
"integration_state_schema": INTEGRATION_STATE_SCHEMA + 1,
"default_integration": "claude",
"installed_integrations": ["claude"],
"integration_settings": {},
}
),
encoding="utf-8",
)
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "auto-future-schema"
name: "Auto Future Schema"
version: "1.0.0"
inputs:
integration:
type: string
default: "auto"
""")
engine = WorkflowEngine(project_dir)
resolved = engine._resolve_inputs(definition, {})
assert resolved["integration"] == "auto"
def test_default_value_is_validated_against_enum(self, project_dir):
"""Defaults must run through the same coercion/enum check as provided inputs."""
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "default-enum"
name: "Default Enum"
version: "1.0.0"
inputs:
scope:
type: string
default: "not-in-enum"
enum: ["full", "backend-only", "frontend-only"]
""")
engine = WorkflowEngine(project_dir)
with pytest.raises(ValueError, match="not in allowed values"):
engine._resolve_inputs(definition, {})
def test_default_value_is_coerced_to_declared_type(self, project_dir):
"""A numeric default declared as a string should still be coerced like a provided input."""
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "default-coerce"
name: "Default Coerce"
version: "1.0.0"
inputs:
retries:
type: number
default: "3"
""")
engine = WorkflowEngine(project_dir)
resolved = engine._resolve_inputs(definition, {})
assert resolved["retries"] == 3
assert isinstance(resolved["retries"], int)
def test_validate_workflow_rejects_invalid_default(self):
"""Authoring-time validation should reject defaults that violate enum."""
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "bad-default"
name: "Bad Default"
version: "1.0.0"
inputs:
scope:
type: string
default: "not-in-enum"
enum: ["full", "backend-only", "frontend-only"]
steps:
- id: noop
type: gate
message: "noop"
options: [approve]
""")
errors = validate_workflow(definition)
assert any("invalid default" in e for e in errors), errors
def test_validate_workflow_exempts_integration_auto_sentinel(self):
"""``integration: auto`` is a runtime-resolved sentinel and must not fail validation."""
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "auto-ok"
name: "Auto OK"
version: "1.0.0"
inputs:
integration:
type: string
default: "auto"
enum: ["copilot", "claude", "gemini"]
steps:
- id: noop
type: gate
message: "noop"
options: [approve]
""")
errors = validate_workflow(definition)
assert not any("invalid default" in e for e in errors), errors
def test_validate_workflow_still_checks_type_for_auto_sentinel(self):
"""The ``auto`` exemption only skips enum-membership; declared type is still enforced."""
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "auto-bad-type"
name: "Auto Bad Type"
version: "1.0.0"
inputs:
integration:
type: number
default: "auto"
steps:
- id: noop
type: gate
message: "noop"
options: [approve]
""")
errors = validate_workflow(definition)
assert any("invalid default" in e for e in errors), errors
def test_validate_workflow_rejects_bool_default_for_number_type(self):
"""``type: number`` paired with a bool default must fail — bool is a
subclass of int so ``float(True)`` would otherwise silently coerce
``true`` to ``1``.
"""
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "bool-as-number"
name: "Bool As Number"
version: "1.0.0"
inputs:
count:
type: number
default: true
steps:
- id: noop
type: gate
message: "noop"
options: [approve]
""")
errors = validate_workflow(definition)
assert any("invalid default" in e for e in errors), errors
def test_validate_workflow_rejects_non_string_default_for_string_type(self):
"""``type: string`` must require an actual string — a numeric YAML
default like ``5`` would otherwise slip through unvalidated.
"""
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "number-as-string"
name: "Number As String"
version: "1.0.0"
inputs:
label:
type: string
default: 5
steps:
- id: noop
type: gate
message: "noop"
options: [approve]
""")
errors = validate_workflow(definition)
assert any("invalid default" in e for e in errors), errors
def test_while_loop_condition_reads_latest_iteration(self, project_dir):
"""Regression: while-loop condition must see updated step output
from the most recent iteration, not stale iteration-0 data.
See https://github.com/github/spec-kit/issues/2592
"""
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
from specify_cli.workflows.base import RunStatus
# Shell step echoes a counter via a file.
# Condition: exit_code != 0 means "keep looping" — but a non-zero
# exit code would mark the step FAILED and abort the run, so we
# use stdout-based comparison instead.
#
# Iteration 0: counter=1, echoes "1" → not "done" → loop continues
# Iteration 1: counter=2, echoes "done" → condition false → stop
# Without the fix, condition always reads iteration-0 stdout,
# so the loop runs all max_iterations.
import sys
counter_file = project_dir / ".counter"
counter_file.write_text("0", encoding="utf-8")
py = sys.executable
script_file = project_dir / "_tick.py"
script_file.write_text(
f"import pathlib; p = pathlib.Path(r'{counter_file}')\n"
"n = int(p.read_text()) + 1; p.write_text(str(n))\n"
"print('done' if n >= 2 else str(n), end='')\n",
encoding="utf-8",
)
yaml_str = f"""
schema_version: "1.0"
workflow:
id: "while-condition-update"
name: "While Condition Update"
version: "1.0.0"
steps:
- id: retry-loop
type: while
condition: "{{{{ 'done' not in steps.attempt.output.stdout }}}}"
max_iterations: 5
steps:
- id: attempt
type: shell
run: '"{py}" "{script_file}"'
"""
definition = WorkflowDefinition.from_string(yaml_str)
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
assert state.status == RunStatus.COMPLETED
# The unprefixed key should reflect the latest iteration's result.
assert state.step_results["attempt"]["output"]["stdout"] == "done"
# Namespaced iteration-1 result should also exist.
assert "retry-loop:attempt:1" in state.step_results
# Counter should be 2 (iteration 0 + iteration 1), not 5.
assert counter_file.read_text(encoding="utf-8").strip() == "2"
def test_do_while_loop_condition_reads_latest_iteration(self, project_dir):
"""Regression: do-while loop condition must also see updated output.
See https://github.com/github/spec-kit/issues/2592
"""
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
from specify_cli.workflows.base import RunStatus
import sys
counter_file = project_dir / ".counter"
counter_file.write_text("0", encoding="utf-8")
py = sys.executable
script_file = project_dir / "_tick.py"
script_file.write_text(
f"import pathlib; p = pathlib.Path(r'{counter_file}')\n"
"n = int(p.read_text()) + 1; p.write_text(str(n))\n"
"print('done' if n >= 2 else str(n), end='')\n",
encoding="utf-8",
)
yaml_str = f"""
schema_version: "1.0"
workflow:
id: "do-while-condition-update"
name: "Do While Condition Update"
version: "1.0.0"
steps:
- id: retry-loop
type: do-while
condition: "{{{{ 'done' not in steps.attempt.output.stdout }}}}"
max_iterations: 5
steps:
- id: attempt
type: shell
run: '"{py}" "{script_file}"'
"""
definition = WorkflowDefinition.from_string(yaml_str)
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
assert state.status == RunStatus.COMPLETED
assert state.step_results["attempt"]["output"]["stdout"] == "done"
assert counter_file.read_text(encoding="utf-8").strip() == "2"
def test_while_loop_runs_to_max_when_condition_stays_true(self, project_dir):
"""While loop must still run to max_iterations when the condition
never becomes false — copy-back must not break this path.
See https://github.com/github/spec-kit/issues/2592
"""
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
from specify_cli.workflows.base import RunStatus
import sys
counter_file = project_dir / ".counter"
counter_file.write_text("0", encoding="utf-8")
py = sys.executable
script_file = project_dir / "_tick.py"
script_file.write_text(
f"import pathlib; p = pathlib.Path(r'{counter_file}')\n"
"n = int(p.read_text()) + 1; p.write_text(str(n))\n"
"print('pending', end='')\n",
encoding="utf-8",
)
yaml_str = f"""
schema_version: "1.0"
workflow:
id: "while-max-iterations"
name: "While Max Iterations"
version: "1.0.0"
steps:
- id: retry-loop
type: while
condition: "{{{{ 'done' not in steps.tick.output.stdout }}}}"
max_iterations: 3
steps:
- id: tick
type: shell
run: '"{py}" "{script_file}"'
"""
definition = WorkflowDefinition.from_string(yaml_str)
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
assert state.status == RunStatus.COMPLETED
# All 3 iterations ran (iteration 0 + 2 loop iterations).
assert counter_file.read_text(encoding="utf-8").strip() == "3"
# Unprefixed key holds the last iteration's result.
assert state.step_results["tick"]["output"]["stdout"] == "pending"
# Namespaced keys for loop iterations exist.
assert "retry-loop:tick:1" in state.step_results
assert "retry-loop:tick:2" in state.step_results
def test_do_while_loop_runs_to_max_when_condition_stays_true(self, project_dir):
"""Do-while loop must still run to max_iterations when the condition
never becomes false.
See https://github.com/github/spec-kit/issues/2592
"""
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
from specify_cli.workflows.base import RunStatus
import sys
counter_file = project_dir / ".counter"
counter_file.write_text("0", encoding="utf-8")
py = sys.executable
script_file = project_dir / "_tick.py"
script_file.write_text(
f"import pathlib; p = pathlib.Path(r'{counter_file}')\n"
"n = int(p.read_text()) + 1; p.write_text(str(n))\n"
"print('pending', end='')\n",
encoding="utf-8",
)
yaml_str = f"""
schema_version: "1.0"
workflow:
id: "do-while-max-iterations"
name: "Do While Max Iterations"
version: "1.0.0"
steps:
- id: retry-loop
type: do-while
condition: "{{{{ 'done' not in steps.tick.output.stdout }}}}"
max_iterations: 3
steps:
- id: tick
type: shell
run: '"{py}" "{script_file}"'
"""
definition = WorkflowDefinition.from_string(yaml_str)
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
assert state.status == RunStatus.COMPLETED
assert counter_file.read_text(encoding="utf-8").strip() == "3"
assert state.step_results["tick"]["output"]["stdout"] == "pending"
def test_while_loop_multi_step_body_inter_step_refs(self, project_dir):
"""Multi-step loop body: step B must see step A's output from the
current iteration, not a stale previous one.
See https://github.com/github/spec-kit/issues/2592
"""
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
from specify_cli.workflows.base import RunStatus
import sys
counter_file = project_dir / ".counter"
counter_file.write_text("0", encoding="utf-8")
py = sys.executable
# Step A: increments counter file, echoes the value.
step_a_file = project_dir / "_step_a.py"
step_a_file.write_text(
f"import pathlib; p = pathlib.Path(r'{counter_file}')\n"
"n = int(p.read_text()) + 1; p.write_text(str(n))\n"
"print(str(n), end='')\n",
encoding="utf-8",
)
# Step B uses {{ steps.step-a.output.stdout }} expression
# substitution in its run command so the engine resolves the
# aliased unprefixed key — this is the real inter-step test.
yaml_str = f"""
schema_version: "1.0"
workflow:
id: "while-multi-step"
name: "While Multi Step"
version: "1.0.0"
steps:
- id: retry-loop
type: while
condition: "{{{{ 'done' not in steps.step-a.output.stdout }}}}"
max_iterations: 3
steps:
- id: step-a
type: shell
run: '"{py}" "{step_a_file}"'
- id: step-b
type: shell
run: "echo b-saw-{{{{ steps.step-a.output.stdout }}}}"
"""
definition = WorkflowDefinition.from_string(yaml_str)
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
assert state.status == RunStatus.COMPLETED
# Both unprefixed keys reflect the latest iteration's results.
assert state.step_results["step-a"]["output"]["stdout"] == "3"
# Step B saw step A's output via expression substitution.
assert "b-saw-3" in state.step_results["step-b"]["output"]["stdout"]
# Namespaced keys exist for loop iterations.
assert "retry-loop:step-a:1" in state.step_results
assert "retry-loop:step-b:1" in state.step_results
assert "retry-loop:step-a:2" in state.step_results
assert "retry-loop:step-b:2" in state.step_results
# ===== State Persistence Tests =====

View File

@@ -7,9 +7,23 @@ workflow:
description: "Runs specify → plan → tasks → implement with review gates"
requires:
speckit_version: ">=0.7.2"
# 0.8.5 is the first release with engine-side resolution of the
# ``integration: "auto"`` default. Older versions would treat "auto"
# as a literal integration key and fail at dispatch.
speckit_version: ">=0.8.5"
integrations:
any: ["copilot", "claude", "gemini"]
# The four commands below (specify, plan, tasks, implement) are core
# spec-kit commands provided by every integration. The list here is an
# advisory, non-exhaustive compatibility hint following the documented
# ``any: [...]`` schema -- it is NOT a closed set. The workflow runs
# against any integration the project was initialized with, including
# ones not listed below, as long as that integration provides the four
# core commands referenced in ``steps``.
any:
- "claude"
- "copilot"
- "gemini"
- "opencode"
inputs:
spec:
@@ -18,8 +32,8 @@ inputs:
prompt: "Describe what you want to build"
integration:
type: string
default: "copilot"
prompt: "Integration to use (e.g. claude, copilot, gemini)"
default: "auto"
prompt: "Integration to use (e.g. claude, copilot, gemini; 'auto' uses the project's initialized integration)"
scope:
type: string
default: "full"