Compare commits

..

13 Commits

Author SHA1 Message Date
github-actions[bot]
5b70de43dc chore: bump version to 0.7.5 2026-04-22 21:22:39 +00:00
swithek
ecb3b94b43 fix: resolve skill placeholders for all SKILL.md agents, not just codex/kimi (#2313)
* fix: resolve skill placeholders for all SKILL.md agents, not just codex/kimi

* chore: remove unused NATIVE_SKILLS_AGENTS constant
2026-04-22 15:12:44 -05:00
김준호
c5c20134df feat(cli): add specify self check and self upgrade stub (#2316)
* feat(cli): add specify self check and self upgrade stub (#2282)

Introduce a new `specify self` Typer sub-app with two subcommands.

`specify self check` performs a read-only lookup against the GitHub Releases
API, compares the installed version to the latest tag with PEP 440 semantics,
and prints one of four verdicts (newer-available, up-to-date, indeterminate,
graceful-failure). When a newer stable release is available, the output
includes a copy-pasteable `uv tool install --force --from git+...@<tag>`
reinstall command. `GH_TOKEN` / `GITHUB_TOKEN` is attached as a bearer
credential when set so users behind shared IPs escape the anonymous 60/hour
rate limit.

`specify self upgrade` is a documented non-destructive stub in this release:
three-line guidance output, exit 0, no outbound call, no install-method
detection. The real destructive implementation is planned as follow-up work.

Failure categorization is a fixed three-entry enum (offline or timeout,
rate limited, HTTP <code>). Anything outside those three categories
propagates as a non-zero exit so bugs surface instead of being silently
swallowed. No machine-readable output, no retries, no caching in this
release — see issue #2282 discussion.

Tests mock `urllib.request.urlopen`; the suite performs zero real network
calls. Full regression suite: 1586 passed.

* fix(cli): disable Rich highlight for deterministic output

Rich's default `highlight=True` applies ANSI color to detected patterns
(integers, version strings, paths) whenever stdout is deemed a TTY.
This caused intermittent failures in existing pytest assertions in
tests/test_cli_version.py and tests/test_extensions.py::TestExtensionRemoveCLI
that compare plain-text output without passing through `strip_ansi()`.

Setting `Console(highlight=False)` globally makes all CLI output
deterministic and fixes the flake without modifying the affected tests.
The numeric cyan highlighting was not a documented part of the CLI
visual contract.

* fix: address copilot review feedback

* fix: tighten self-check token handling

* fix: align self-check helpers and script metadata

* fix: harden self-check version handling

* fix: guard self-check failure rendering
2026-04-22 13:33:03 -05:00
Kevin Brown
58f7a43ec3 Update version-guard to v1.1.0 (#2318)
- Version: 1.0.0 → 1.1.0
- Commands: 1 → 3 (check, load, validate)
- Hooks: 2 → 4 (before_plan, before_tasks, before_implement, after_implement)
- Added: persistent constraints artifact, two-channel model, CVE detection,
  decision record fallback for greenfield projects, skip artifact persistence

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-22 13:27:56 -05:00
Manfred Riem
efb04e26eb docs: move community presets from README to docs/community (#2314)
Move the community presets table from the main README to a dedicated
docs/community/presets.md page, matching the pattern used for
walkthroughs and friends.

- Add docs/community/presets.md with the full presets table
- Add Claude AskUserQuestion preset (was in catalog but missing from table)
- Add Presets entry to docs/toc.yml under Community
- Replace inline README table with a short link to the docs page
2026-04-22 10:05:14 -05:00
TortoiseWolfe
c52ea23ba2 catalog: add wireframe extension (v0.1.1) (#2262)
* catalog: add wireframe extension

Adds https://github.com/TortoiseWolfe/spec-kit-extension-wireframe
(v0.1.0) to the community catalog. Provides a visual feedback loop
for spec-driven development: SVG wireframe generation, review, and
sign-off. Approved wireframes become spec constraints honored by
/plan, /tasks, and /implement.

Supersedes #1410 — the old PR predated the extension system
introduced in #2130 and proposed commands in templates/commands/,
which is no longer the right home for third-party commands.

* catalog: address review feedback (position + author)

Two changes per Copilot review:
- Move `wireframe` entry alphabetically between `whatif` and
  `worktree` (was appended after `worktrees`).
- Simplify `author` from "TortoiseWolfe (turtlewolfe.com)" to
  just "TortoiseWolfe" so the exact-match author filter in
  `ExtensionCatalog.search` finds the entry. Portfolio URL
  remains accessible via `homepage`/`repository`.

Thanks @Copilot, @mnriem for the review.

* docs(readme): add Wireframe Visual Feedback Loop row

Addresses @mnriem's follow-up: the README extension table also
needs an entry, not just the catalog JSON. Slots in alphabetically
between "What-if Analysis" and "Worktree Isolation" with category
`visibility` and Read+Write effect (since sign-off writes the
approved wireframe paths into spec.md).

* catalog: use speckit-prefixed command names in wireframe description

Address remaining Copilot review comment on PR #2262. The actual
commands are /speckit.plan, /speckit.tasks, /speckit.implement;
the unprefixed names would mislead catalog users.

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

* catalog: bump wireframe extension to v0.1.1

v0.1.1 of spec-kit-extension-wireframe ships the /speckit.-prefixed
command references in extension.yml and README.md. This updates the
catalog entry to point at the new release tag so `specify extension
add wireframe` installs the corrected version.

* catalog: set wireframe created_at to current timestamp

Per EXTENSION-PUBLISHING-GUIDE.md: newly added entries should use
the current timestamp for both created_at and updated_at. The 04-17
value reflected when I drafted the entry locally, not when the
catalog submission landed.

---------

Co-authored-by: TortoiseWolfe <jonpohlner@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:20:13 -05:00
Manfred Riem
d402a392c3 Move community walkthroughs from README to docs/community (#2312)
* Move community walkthroughs from README to docs/community

Extract the community walkthroughs section from README.md into its own
docs/community/walkthroughs.md file and replace it with a short summary
linking to the GitHub Pages URL.

* Address review: fix double-See phrasing, add walkthroughs to docs nav
2026-04-22 09:17:48 -05:00
Ash Brener
deb80956f3 docs(readme): list red-team in community-extensions table (#2311)
Follow-up to #2306 (merged). Per maintainer request
(https://github.com/github/spec-kit/pull/2306#issuecomment-4296655643),
adds the red-team entry to the alphabetically-ordered community-extensions
table in README.md so the extension is discoverable alongside the other
community entries — not only via catalog.community.json.

Slotted alphabetically between "Reconcile Extension" and "Repository
Index". Category: docs. Effect: Read+Write (produces a structured
findings-report file at specs/<feature-id>/red-team-findings-*.md; does
not modify specs — every resolution is maintainer-authorised).

Co-authored-by: Ash Brener <ashley@midletearth.com>
2026-04-22 09:03:06 -05:00
Ash Brener
4dcf2921d1 feat(catalog): add red-team extension to community catalog (#2306)
* feat(catalog): add red-team extension

Adds the `red-team` community extension to the catalog:

- Adversarial review of functional specs before /speckit.plan locks in
  architecture.
- Complements /speckit.clarify (correctness) and /speckit.analyze
  (consistency) with parallel adversarial lens agents.
- One command: speckit.red-team.run
- MIT licensed; requires spec-kit >= 0.7.0.

Origin: this extension was originally proposed as a core command
(github/spec-kit#2303). Per maintainer guidance (mnriem's comment on
that PR), it's been restructured as a community extension hosted at
https://github.com/ashbrener/spec-kit-red-team.

Dogfood-validated on a 500-line functional spec: 5 lens agents
dispatched in parallel returned 25 findings in ~1.5 min wall-clock,
19 of which met the meaningful-finding bar (severity >= HIGH AND
novel adversarial angle that clarify/analyze structurally cannot
catch). Full detail in the extension's CHANGELOG.

* catalog: shorten red-team description to fit <200 char schema limit

Resolves Copilot review comment on #2306. Previous description (259
chars) exceeded the extensions/EXTENSION-PUBLISHING-GUIDE.md Appendix
schema ceiling. Shortened to 188 chars, keeping the distinctive
value proposition (adversarial, complements clarify/analyze) and
moving the per-phase mechanics to the extension's own README.

* catalog: bump red-team to v1.0.1 (lower required spec-kit version)

Follow-up to v1.0.0 catalog entry:
- version: 1.0.0 -> 1.0.1
- download_url: points at v1.0.1 release asset
- requires.speckit_version: >=0.7.0 -> >=0.1.0

The v1.0.0 requirement was too strict and blocked installation on
common 0.6.x field versions (confirmed via local install attempt).
The extension uses no 0.7.x-specific APIs; matches community norm
(reconcile, refine, others use >=0.1.0).

* catalog: bump red-team to v1.0.2 (adds mandatory before_plan gate)

v1.0.2 ships a /speckit.red-team.gate command wired as a mandatory
before_plan hook so /speckit.plan auto-invokes it on every run against
qualifying specs. Non-qualifying specs return PROCEED silently; qualifying
specs without findings on record return HALT with explicit remediation
(run /speckit.red-team.run, or opt out via --skip-red-team-gate: <reason>
which is recorded as an Accepted Risk [red-team-skipped] in the plan).

Catalog metadata delta:
- version: 1.0.1 -> 1.0.2
- download_url: v1.0.2/red-team-v1.0.2.zip
- provides.commands: 1 -> 2 (adds speckit.red-team.gate)
- provides.hooks: 0 -> 1 (adds before_plan hook)

No breaking changes. Projects that do not want the gate simply do not
install the extension.

---------

Co-authored-by: Ash Brener <ashley@midletearth.com>
2026-04-22 08:33:08 -05:00
WangX
dd9c0b0500 Add superpowers-bridge community extension (#2309)
* Add superpowers-bridge community extension

Adds the superpowers-bridge extension by WangX0111 to the community
catalog and README table. This extension bridges spec-kit with
obra/superpowers (brainstorming, TDD, subagent-driven-development,
code-review) into a unified, resumable workflow with graceful
degradation and session progress tracking.

Extension details:
- ID: superpowers-bridge
- Repository: https://github.com/WangX0111/superspec
- Version: 1.0.0
- Commands: 5, Hooks: 3
- License: MIT

* Address Copilot review feedback

- Update top-level updated_at to 2026-04-22
- Shorten description to under 200 characters

---------

Co-authored-by: 乘浩 <wch453799@alibaba-inc.com>
2026-04-22 08:14:58 -05:00
Kennedy
22e76995c7 feat: implement preset wrap strategy (#2189)
* feat: implement strategy: wrap

* fix: resolve merge conflict for strategy wrap correctness

* feat: multi-preset composable wrapping with priority ordering

Implements comment #4 from PR review: multiple installed wrap presets
now compose in priority order rather than overwriting each other.

Key changes:
- PresetResolver.resolve() gains skip_presets flag; resolve_core() wraps
  it to skip tier 2, preventing accidental nesting during replay
- _replay_wraps_for_command() recomposed all enabled wrap presets for a
  command in ascending priority order (innermost-first) after any
  install or remove
- _replay_skill_override() keeps SKILL.md in sync with the recomposed
  command body for ai-skills-enabled projects
- install_from_directory() detects strategy: wrap commands, stores
  wrap_commands in the registry entry, and calls replay after install
- remove() reads wrap_commands before deletion, removes registry entry
  before rmtree so replay sees post-removal state, then replays
  remaining wraps or unregisters when none remain

Tests: TestResolveCore (5), TestReplayWrapsForCommand (5),
TestInstallRemoveWrapLifecycle (5), plus 2 skill/alias regression tests

* fix: resolve extension commands via manifest file mapping

PresetResolver.resolve_extension_command_via_manifest() consults each
installed extension.yml to find the actual file declared for a command
name, rather than assuming the file is named <cmd_name>.md.  This fixes
_substitute_core_template for extensions like selftest where the manifest
maps speckit.selftest.extension → commands/selftest.md.

Resolution order in _substitute_core_template is now:
  1. resolve_core(cmd_name) — project overrides win, then name-based lookup
  2. resolve_extension_command_via_manifest(cmd_name) — manifest fallback
  3. resolve_core(short_name) — core template short-name fallback

Path traversal guard mirrors the containment check already present in
ExtensionManager to reject absolute paths or paths escaping the extension
root.

* fix: add bundled core_pack as Priority 5 in PresetResolver.resolve()

resolve_core() was returning None for built-in commands (implement,
specify, etc.) because PresetResolver only checked .specify/templates/
commands/ (Priority 4), which is never populated for commands in a
normal project. strategy:wrap presets rely on resolve_core() to fetch
the {CORE_TEMPLATE} body, so the wrap was silently skipped and SKILL.md
was never updated.

Priority 5 now checks core_pack/commands/ (wheel install) or
repo_root/templates/commands/ (source checkout), mirroring the pattern
used by _locate_core_pack() elsewhere.

Updated two tests whose assertions assumed resolve_core() always
returned None when .specify/templates/commands/ was absent.

* fix: harden preset wrap replay removal

* fix: stabilize existing directory error output

* fix: track outermost_pack_id from contributing preset; use Path.parts in tests

- outermost_pack_id now updates alongside outermost_frontmatter inside
  the wrap loop, so it reflects the actual last contributing preset
  rather than always taking wrap_presets[0] (which may have been skipped)
- Replace str(path) substring checks in TestResolveCore with Path.parts
  tuple comparisons for correct behaviour on Windows (CI runs windows-latest)

* fix: guard against non-mapping YAML manifests; apply integration post-processing in replay

- ExtensionManifest._load raises ValidationError for non-dict YAML roots instead of TypeError
- PresetManager._replay_wraps_for_command calls integration.post_process_skill_content,
  matching _register_skills behaviour
- PresetResolver skips extensions that raise OSError/TypeError/AttributeError on manifest load
- Tests: non-mapping YAML, OSError manifest skip, and replay integration post-processing
2026-04-21 15:02:31 -05:00
김준호
569d18a59d fix(agents): block directory traversal in command write paths (#2229) (#2296)
Extend the alias containment guard from b67b285 to the two remaining
write paths that derive filenames from free-form command/alias names:

- Primary command write in CommandRegistrar.register_commands()
- CommandRegistrar.write_copilot_prompt()

Consolidate the check into a shared _ensure_inside() helper. Per
maintainer guidance on #2229, use a lexical
(os.path.normpath + Path.is_relative_to) containment check rather than
resolve() so `..` / absolute-path traversal is rejected while
intentionally symlinked sub-directories under an agent's commands
directory (e.g. .claude/skills/shared -> /team/shared-skills) keep
working for existing extension setups.

Add 22 parametrised regression cases covering traversal payloads on
primary commands, aliases, and the Copilot companion prompt, plus a
positive case that confirms symlinked sub-directories remain supported.
2026-04-21 12:06:09 -05:00
Manfred Riem
f10fd07481 chore: release 0.7.4, begin 0.7.5.dev0 development (#2299)
* chore: bump version to 0.7.4

* chore: begin 0.7.5.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-21 11:50:23 -05:00
19 changed files with 2788 additions and 84 deletions

View File

@@ -2,6 +2,23 @@
<!-- insert new changelog below this comment -->
## [0.7.5] - 2026-04-22
### Changed
- fix: resolve skill placeholders for all SKILL.md agents, not just codex/kimi (#2313)
- feat(cli): add specify self check and self upgrade stub (#2316)
- Update version-guard to v1.1.0 (#2318)
- docs: move community presets from README to docs/community (#2314)
- catalog: add wireframe extension (v0.1.1) (#2262)
- Move community walkthroughs from README to docs/community (#2312)
- docs(readme): list red-team in community-extensions table (#2311)
- feat(catalog): add red-team extension to community catalog (#2306)
- Add superpowers-bridge community extension (#2309)
- feat: implement preset wrap strategy (#2189)
- fix(agents): block directory traversal in command write paths (#2229) (#2296)
- chore: release 0.7.4, begin 0.7.5.dev0 development (#2299)
## [0.7.4] - 2026-04-21
### Changed

View File

@@ -236,6 +236,7 @@ The following community-contributed extensions are available in [`catalog.commun
| 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/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) |
@@ -256,12 +257,14 @@ The following community-contributed extensions are available in [`catalog.commun
| 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) |
| 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) |
| 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) |
@@ -269,44 +272,11 @@ To submit your own extension, see the [Extension Publishing Guide](extensions/EX
## 🎨 Community Presets
> [!NOTE]
> Community presets are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the preset code itself**. Review preset source code before installation and use at your own discretion.
The following community-contributed presets customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. Presets are available in [`catalog.community.json`](presets/catalog.community.json):
| Preset | Purpose | Provides | Requires | URL |
|--------|---------|----------|----------|-----|
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) |
| 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 | — | [spec-kit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
| 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) |
| Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) |
| VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) |
To build and publish your own preset, see the [Presets Publishing Guide](presets/PUBLISHING.md).
Community-contributed presets that customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. See the full list on the [Community Presets](https://github.github.io/spec-kit/community/presets.html) page.
## 🚶 Community Walkthroughs
> [!NOTE]
> Community walkthroughs are independently created and maintained by their respective authors. They are **not reviewed, nor endorsed, nor supported by GitHub**. Review their content before following along and use at your own discretion.
See Spec-Driven Development in action across different scenarios with these community-contributed walkthroughs:
- **[Greenfield .NET CLI tool](https://github.com/mnriem/spec-kit-dotnet-cli-demo)** — Builds a Timezone Utility as a .NET single-binary CLI tool from a blank directory, covering the full spec-kit workflow: constitution, specify, plan, tasks, and multi-pass implement using GitHub Copilot agents.
- **[Greenfield Spring Boot + React platform](https://github.com/mnriem/spec-kit-spring-react-demo)** — Builds an LLM performance analytics platform (REST API, graphs, iteration tracking) from scratch using Spring Boot, embedded React, PostgreSQL, and Docker Compose, with a clarify step and a cross-artifact consistency analysis pass included.
- **[Brownfield ASP.NET CMS extension](https://github.com/mnriem/spec-kit-aspnet-brownfield-demo)** — Extends an existing open-source .NET CMS (CarrotCakeCMS-Core, ~307,000 lines of C#, Razor, SQL, JavaScript, and config files) with two new features — cross-platform Docker Compose infrastructure and a token-authenticated headless REST API — demonstrating how spec-kit fits into existing codebases without prior specs or a constitution.
- **[Brownfield Java runtime extension](https://github.com/mnriem/spec-kit-java-brownfield-demo)** — Extends an existing open-source Jakarta EE runtime (Piranha, ~420,000 lines of Java, XML, JSP, HTML, and config files across 180 Maven modules) with a password-protected Server Admin Console, demonstrating spec-kit on a large multi-module Java project with no prior specs or constitution.
- **[Brownfield Go / React dashboard demo](https://github.com/mnriem/spec-kit-go-brownfield-demo)** — Demonstrates spec-kit driven entirely from the **terminal using GitHub Copilot CLI**. Extends NASA's open-source Hermes ground support system (Go) with a lightweight React-based web telemetry dashboard, showing that the full constitution → specify → plan → tasks → implement workflow works from the terminal.
- **[Greenfield Spring Boot MVC with a custom preset](https://github.com/mnriem/spec-kit-pirate-speak-preset-demo)** — Builds a Spring Boot MVC application from scratch using a custom pirate-speak preset, demonstrating how presets can reshape the entire spec-kit experience: specifications become "Voyage Manifests," plans become "Battle Plans," and tasks become "Crew Assignments" — all generated in full pirate vernacular without changing any tooling.
- **[Greenfield Spring Boot + React with a custom extension](https://github.com/mnriem/spec-kit-aide-extension-demo)** — Walks through the **AIDE extension**, a community extension that adds an alternative spec-driven workflow to spec-kit with high-level specs (vision) and low-level specs (work items) organized in a 7-step iterative lifecycle: vision → roadmap → progress tracking → work queue → work items → execution → feedback loops. Uses a family trading platform (Spring Boot 4, React 19, PostgreSQL, Docker Compose) as the scenario to illustrate how the extension mechanism lets you plug in a different style of spec-driven development without changing any core tooling — truly utilizing the "Kit" in Spec Kit.
See Spec-Driven Development in action across different scenarios with community-contributed walkthroughs; find the full list on the [Community Walkthroughs](https://github.github.io/spec-kit/community/walkthroughs.html) page.
## 🛠️ Community Friends

20
docs/community/presets.md Normal file
View File

@@ -0,0 +1,20 @@
# Community Presets
> [!NOTE]
> Community presets are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the preset code itself**. Review preset source code before installation and use at your own discretion.
The following community-contributed presets customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. Presets are available in [`catalog.community.json`](https://github.com/github/spec-kit/blob/main/presets/catalog.community.json):
| Preset | Purpose | Provides | Requires | URL |
|--------|---------|----------|----------|-----|
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) |
| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) |
| 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 | — | [spec-kit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
| 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) |
| Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) |
| VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) |
To build and publish your own preset, see the [Presets Publishing Guide](https://github.com/github/spec-kit/blob/main/presets/PUBLISHING.md).

View File

@@ -0,0 +1,20 @@
# Community Walkthroughs
> [!NOTE]
> Community walkthroughs are independently created and maintained by their respective authors. They are **not reviewed, nor endorsed, nor supported by GitHub**. Review their content before following along and use at your own discretion.
See Spec-Driven Development in action across different scenarios with these community-contributed walkthroughs:
- **[Greenfield .NET CLI tool](https://github.com/mnriem/spec-kit-dotnet-cli-demo)** — Builds a Timezone Utility as a .NET single-binary CLI tool from a blank directory, covering the full spec-kit workflow: constitution, specify, plan, tasks, and multi-pass implement using GitHub Copilot agents.
- **[Greenfield Spring Boot + React platform](https://github.com/mnriem/spec-kit-spring-react-demo)** — Builds an LLM performance analytics platform (REST API, graphs, iteration tracking) from scratch using Spring Boot, embedded React, PostgreSQL, and Docker Compose, with a clarify step and a cross-artifact consistency analysis pass included.
- **[Brownfield ASP.NET CMS extension](https://github.com/mnriem/spec-kit-aspnet-brownfield-demo)** — Extends an existing open-source .NET CMS (CarrotCakeCMS-Core, ~307,000 lines of C#, Razor, SQL, JavaScript, and config files) with two new features — cross-platform Docker Compose infrastructure and a token-authenticated headless REST API — demonstrating how spec-kit fits into existing codebases without prior specs or a constitution.
- **[Brownfield Java runtime extension](https://github.com/mnriem/spec-kit-java-brownfield-demo)** — Extends an existing open-source Jakarta EE runtime (Piranha, ~420,000 lines of Java, XML, JSP, HTML, and config files across 180 Maven modules) with a password-protected Server Admin Console, demonstrating spec-kit on a large multi-module Java project with no prior specs or constitution.
- **[Brownfield Go / React dashboard demo](https://github.com/mnriem/spec-kit-go-brownfield-demo)** — Demonstrates spec-kit driven entirely from the **terminal using GitHub Copilot CLI**. Extends NASA's open-source Hermes ground support system (Go) with a lightweight React-based web telemetry dashboard, showing that the full constitution → specify → plan → tasks → implement workflow works from the terminal.
- **[Greenfield Spring Boot MVC with a custom preset](https://github.com/mnriem/spec-kit-pirate-speak-preset-demo)** — Builds a Spring Boot MVC application from scratch using a custom pirate-speak preset, demonstrating how presets can reshape the entire spec-kit experience: specifications become "Voyage Manifests," plans become "Battle Plans," and tasks become "Crew Assignments" — all generated in full pirate vernacular without changing any tooling.
- **[Greenfield Spring Boot + React with a custom extension](https://github.com/mnriem/spec-kit-aide-extension-demo)** — Walks through the **AIDE extension**, a community extension that adds an alternative spec-driven workflow to spec-kit with high-level specs (vision) and low-level specs (work items) organized in a 7-step iterative lifecycle: vision → roadmap → progress tracking → work queue → work items → execution → feedback loops. Uses a family trading platform (Spring Boot 4, React 19, PostgreSQL, Docker Compose) as the scenario to illustrate how the extension mechanism lets you plug in a different style of spec-driven development without changing any core tooling — truly utilizing the "Kit" in Spec Kit.

View File

@@ -37,5 +37,9 @@
# Community
- name: Community
items:
- name: Presets
href: community/presets.md
- name: Walkthroughs
href: community/walkthroughs.md
- name: Friends
href: community/friends.md

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-04-21T00:00:00Z",
"updated_at": "2026-04-22T17:54:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -1523,6 +1523,38 @@
"created_at": "2026-03-14T00:00:00Z",
"updated_at": "2026-03-14T00:00:00Z"
},
"red-team": {
"name": "Red Team",
"id": "red-team",
"description": "Adversarial review of functional specs before /speckit.plan. Parallel adversarial lens agents catch hostile actors, silent failures, and regulatory blind spots that clarify/analyze cannot.",
"author": "Ash Brener",
"version": "1.0.2",
"download_url": "https://github.com/ashbrener/spec-kit-red-team/releases/download/v1.0.2/red-team-v1.0.2.zip",
"repository": "https://github.com/ashbrener/spec-kit-red-team",
"homepage": "https://github.com/ashbrener/spec-kit-red-team",
"documentation": "https://github.com/ashbrener/spec-kit-red-team/blob/main/README.md",
"changelog": "https://github.com/ashbrener/spec-kit-red-team/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 2,
"hooks": 1
},
"tags": [
"adversarial-review",
"quality-gate",
"spec-hardening",
"pre-plan",
"audit"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-22T00:00:00Z",
"updated_at": "2026-04-22T00:00:00Z"
},
"refine": {
"name": "Spec Refine",
"id": "refine",
@@ -2122,6 +2154,39 @@
"created_at": "2026-03-30T00:00:00Z",
"updated_at": "2026-04-16T14:08:23Z"
},
"superpowers-bridge": {
"name": "Superpowers Bridge",
"id": "superpowers-bridge",
"description": "Bridges spec-kit workflows with obra/superpowers capabilities for brainstorming, TDD, code review, and resumable execution.",
"author": "WangX0111",
"version": "1.0.0",
"download_url": "https://github.com/WangX0111/superspec/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/WangX0111/superspec",
"homepage": "https://github.com/WangX0111/superspec",
"documentation": "https://github.com/WangX0111/superspec/blob/main/README.md",
"changelog": "https://github.com/WangX0111/superspec/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 5,
"hooks": 3
},
"tags": [
"superpowers",
"brainstorming",
"tdd",
"code-review",
"subagent",
"workflow"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-22T00:00:00Z",
"updated_at": "2026-04-22T00:00:00Z"
},
"sync": {
"name": "Spec Sync",
"id": "sync",
@@ -2286,8 +2351,8 @@
"id": "version-guard",
"description": "Verify tech stack versions against live registries before planning and implementation",
"author": "KevinBrown5280",
"version": "1.0.0",
"download_url": "https://github.com/KevinBrown5280/spec-kit-version-guard/archive/refs/tags/v1.0.0.zip",
"version": "1.1.0",
"download_url": "https://github.com/KevinBrown5280/spec-kit-version-guard/archive/refs/tags/v1.1.0.zip",
"repository": "https://github.com/KevinBrown5280/spec-kit-version-guard",
"homepage": "https://github.com/KevinBrown5280/spec-kit-version-guard",
"documentation": "https://github.com/KevinBrown5280/spec-kit-version-guard/blob/main/README.md",
@@ -2297,8 +2362,8 @@
"speckit_version": ">=0.2.0"
},
"provides": {
"commands": 1,
"hooks": 2
"commands": 3,
"hooks": 4
},
"tags": [
"versioning",
@@ -2310,7 +2375,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-04-20T00:00:00Z",
"updated_at": "2026-04-20T00:00:00Z"
"updated_at": "2026-04-22T17:54:00Z"
},
"whatif": {
"name": "What-if Analysis",
@@ -2340,6 +2405,41 @@
"created_at": "2026-04-13T00:00:00Z",
"updated_at": "2026-04-13T00:00:00Z"
},
"wireframe": {
"name": "Wireframe Visual Feedback Loop",
"id": "wireframe",
"description": "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.",
"author": "TortoiseWolfe",
"version": "0.1.1",
"download_url": "https://github.com/TortoiseWolfe/spec-kit-extension-wireframe/archive/refs/tags/v0.1.1.zip",
"repository": "https://github.com/TortoiseWolfe/spec-kit-extension-wireframe",
"homepage": "https://github.com/TortoiseWolfe/spec-kit-extension-wireframe",
"documentation": "https://github.com/TortoiseWolfe/spec-kit-extension-wireframe/blob/main/README.md",
"changelog": "https://github.com/TortoiseWolfe/spec-kit-extension-wireframe/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.6.0"
},
"provides": {
"commands": 6,
"hooks": 3
},
"tags": [
"wireframe",
"visual",
"design",
"ui",
"mockup",
"svg",
"feedback-loop",
"sign-off"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-22T00:00:00Z",
"updated_at": "2026-04-22T00:00:00Z"
},
"worktree": {
"name": "Worktree Isolation",
"id": "worktree",
@@ -2405,4 +2505,4 @@
"updated_at": "2026-04-13T00:00:00Z"
}
}
}
}

View File

@@ -116,5 +116,5 @@ The following enhancements are under consideration for future releases:
| **command** | ✓ (default) | ✓ | ✓ | ✓ |
| **script** | ✓ (default) | — | — | ✓ |
For artifacts and commands (which are LLM directives), `wrap` would inject preset content before and after the core template using a `{CORE_TEMPLATE}` placeholder. For scripts, `wrap` would run custom logic before/after the core script via a `$CORE_SCRIPT` variable.
For artifacts and commands (which are LLM directives), `wrap` injects preset content before and after the core template using a `{CORE_TEMPLATE}` placeholder (implemented). For scripts, `wrap` would run custom logic before/after the core script via a `$CORE_SCRIPT` variable (not yet implemented).
- **Script overrides** — Enable presets to provide alternative versions of core scripts (e.g. `create-new-feature.sh`) for workflow customization. A `strategy: "wrap"` option could allow presets to run custom logic before/after the core script without fully replacing it.

View File

@@ -0,0 +1,14 @@
---
description: "Self-test wrap command — pre/post around core"
strategy: wrap
---
## Preset Pre-Logic
preset:self-test wrap-pre
{CORE_TEMPLATE}
## Preset Post-Logic
preset:self-test wrap-post

View File

@@ -56,6 +56,11 @@ provides:
description: "Self-test override of the specify command"
replaces: "speckit.specify"
- type: "command"
name: "speckit.wrap-test"
file: "commands/speckit.wrap-test.md"
description: "Self-test wrap strategy command"
tags:
- "testing"
- "self-test"

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.7.4"
version = "0.7.5"
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

@@ -7,6 +7,8 @@
# "platformdirs",
# "readchar",
# "json5",
# "pyyaml",
# "packaging",
# ]
# ///
"""
@@ -34,8 +36,12 @@ import json
import json5
import stat
import shlex
import urllib.error
import urllib.request
import yaml
from pathlib import Path
from packaging.version import InvalidVersion, Version
from typing import Any, Optional
import typer
@@ -51,6 +57,8 @@ from typer.core import TyperGroup
# For cross-platform keyboard input
import readchar
GITHUB_API_LATEST = "https://api.github.com/repos/github/spec-kit/releases/latest"
def _build_agent_config() -> dict[str, dict[str, Any]]:
"""Derive AGENT_CONFIG from INTEGRATION_REGISTRY."""
from .integrations import INTEGRATION_REGISTRY
@@ -318,7 +326,7 @@ def select_with_arrows(options: dict, prompt_text: str = "Select an option", def
return selected_key
console = Console()
console = Console(highlight=False)
class BannerGroup(TyperGroup):
"""Custom group that shows banner before help."""
@@ -920,7 +928,6 @@ def _get_skills_dir(project_path: Path, selected_ai: str) -> Path:
# Constants kept for backward compatibility with presets and extensions.
DEFAULT_SKILLS_DIR = ".agents/skills"
NATIVE_SKILLS_AGENTS = {"codex", "kimi"}
SKILL_DESCRIPTIONS = {
"specify": "Create or update feature specifications from natural language descriptions.",
"plan": "Generate technical implementation plans from feature specifications.",
@@ -1115,7 +1122,7 @@ def init(
console.print(f"[cyan]--force supplied: merging into existing directory '[cyan]{project_name}[/cyan]'[/cyan]")
else:
error_panel = Panel(
f"Directory '[cyan]{project_name}[/cyan]' already exists\n"
f"Directory already exists: '[cyan]{project_name}[/cyan]'\n"
"Please choose a different project name or remove the existing directory.\n"
"Use [bold]--force[/bold] to merge into the existing directory.",
title="[red]Directory Conflict[/red]",
@@ -1371,7 +1378,6 @@ def init(
"branch_numbering": branch_numbering or "sequential",
"context_file": resolved_integration.context_file,
"here": here,
"preset": preset,
"script": selected_script,
"speckit_version": get_speckit_version(),
}
@@ -1600,25 +1606,10 @@ def check():
def version():
"""Display version and system information."""
import platform
import importlib.metadata
show_banner()
# Get CLI version from package metadata
cli_version = "unknown"
try:
cli_version = importlib.metadata.version("specify-cli")
except Exception:
# Fallback: try reading from pyproject.toml if running from source
try:
import tomllib
pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
if pyproject_path.exists():
with open(pyproject_path, "rb") as f:
data = tomllib.load(f)
cli_version = data.get("project", {}).get("version", "unknown")
except Exception:
pass
cli_version = get_speckit_version()
info_table = Table(show_header=False, box=None, padding=(0, 2))
info_table.add_column("Key", style="cyan", justify="right")
@@ -1641,6 +1632,163 @@ def version():
console.print(panel)
console.print()
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).
"""
req = urllib.request.Request(
GITHUB_API_LATEST,
headers={"Accept": "application/vnd.github+json"},
)
token = None
for env_var in ("GH_TOKEN", "GITHUB_TOKEN"):
candidate = os.environ.get(env_var)
if candidate is not None:
candidate = candidate.strip()
if candidate:
token = candidate
break
if token:
req.add_header("Authorization", f"Bearer {token}")
try:
with urllib.request.urlopen(req, timeout=5) 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 (try setting GH_TOKEN or 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,
)
app.add_typer(self_app, name="self")
@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.")
# ===== Extension Commands =====

View File

@@ -6,6 +6,7 @@ Used by both the extension system and the preset system to write
command files into agent-specific directories in the correct format.
"""
import os
from pathlib import Path
from typing import Dict, List, Any
@@ -281,7 +282,8 @@ class CommandRegistrar:
if not isinstance(frontmatter, dict):
frontmatter = {}
if agent_name in {"codex", "kimi"}:
agent_config = self.AGENT_CONFIGS.get(agent_name, {})
if agent_config.get("extension") == "/SKILL.md":
body = self.resolve_skill_placeholders(
agent_name, frontmatter, body, project_root
)
@@ -399,6 +401,28 @@ class CommandRegistrar:
return f"speckit-{short_name}"
@staticmethod
def _ensure_inside(candidate: Path, base: Path) -> None:
"""Validate that a write target stays within the expected base directory.
Uses lexical normalization so traversal via ``..`` or absolute paths is
rejected while intentionally symlinked sub-directories remain
supported.
Args:
candidate: Path that will be written.
base: Directory the write must remain within.
Raises:
ValueError: If the normalized candidate path escapes ``base``.
"""
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}"
)
def register_commands(
self,
agent_name: str,
@@ -445,6 +469,15 @@ class CommandRegistrar:
content = source_file.read_text(encoding="utf-8")
frontmatter, body = self.parse_frontmatter(content)
if frontmatter.get("strategy") == "wrap":
from .presets import _substitute_core_template
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:
frontmatter[key] = core_frontmatter[key]
frontmatter.pop("strategy", None)
frontmatter = self._adjust_script_paths(frontmatter)
for key in agent_config.get("strip_frontmatter_keys", []):
@@ -472,10 +505,12 @@ class CommandRegistrar:
project_root,
)
elif agent_config["format"] == "markdown":
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"])
output = self.render_toml_command(frontmatter, body, source_id)
elif agent_config["format"] == "yaml":
output = self.render_yaml_command(
@@ -485,6 +520,7 @@ class CommandRegistrar:
raise ValueError(f"Unsupported format: {agent_config['format']}")
dest_file = commands_dir / f"{output_name}{agent_config['extension']}"
self._ensure_inside(dest_file, commands_dir)
dest_file.parent.mkdir(parents=True, exist_ok=True)
dest_file.write_text(output, encoding="utf-8")
@@ -550,12 +586,7 @@ class CommandRegistrar:
alias_file = (
commands_dir / f"{alias_output_name}{agent_config['extension']}"
)
try:
alias_file.resolve().relative_to(commands_dir.resolve())
except ValueError:
raise ValueError(
f"Alias output path escapes commands directory: {alias_file!r}"
)
self._ensure_inside(alias_file, commands_dir)
alias_file.parent.mkdir(parents=True, exist_ok=True)
alias_file.write_text(alias_output, encoding="utf-8")
if agent_name == "copilot":
@@ -575,6 +606,7 @@ class CommandRegistrar:
prompts_dir = project_root / ".github" / "prompts"
prompts_dir.mkdir(parents=True, exist_ok=True)
prompt_file = prompts_dir / f"{cmd_name}.prompt.md"
CommandRegistrar._ensure_inside(prompt_file, prompts_dir)
prompt_file.write_text(f"---\nagent: {cmd_name}\n---\n", encoding="utf-8")
def register_commands_for_all_agents(

View File

@@ -140,11 +140,16 @@ class ExtensionManifest:
"""Load YAML file safely."""
try:
with open(path, 'r') as f:
return yaml.safe_load(f) or {}
data = yaml.safe_load(f)
except yaml.YAMLError as e:
raise ValidationError(f"Invalid YAML in {path}: {e}")
except FileNotFoundError:
raise ValidationError(f"Manifest not found: {path}")
if not isinstance(data, dict):
raise ValidationError(
f"Manifest must be a YAML mapping, got {type(data).__name__}: {path}"
)
return data
def _validate(self):
"""Validate manifest structure and required fields."""

View File

@@ -16,7 +16,10 @@ import zipfile
import shutil
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Dict, List, Any
from typing import TYPE_CHECKING, Optional, Dict, List, Any
if TYPE_CHECKING:
from .agents import CommandRegistrar
from datetime import datetime, timezone
import re
@@ -27,6 +30,59 @@ from packaging.specifiers import SpecifierSet, InvalidSpecifier
from .extensions import ExtensionRegistry, normalize_priority
def _substitute_core_template(
body: str,
cmd_name: str,
project_root: "Path",
registrar: "CommandRegistrar",
) -> "tuple[str, dict]":
"""Substitute {CORE_TEMPLATE} with the body of the installed core command template.
Args:
body: Preset command body (may contain {CORE_TEMPLATE} placeholder).
cmd_name: Full command name (e.g. "speckit.git.feature" or "speckit.specify").
project_root: Project root path.
registrar: CommandRegistrar instance for parse_frontmatter.
Returns:
A tuple of (body, core_frontmatter) where body has {CORE_TEMPLATE} replaced
by the core template body and core_frontmatter holds the core template's parsed
frontmatter (so callers can inherit scripts/agent_scripts from it). Both are
unchanged / empty when the placeholder is absent or the core template file does
not exist.
"""
if "{CORE_TEMPLATE}" not in body:
return body, {}
# Derive the short name (strip "speckit." prefix) used by core command templates.
short_name = cmd_name
if short_name.startswith("speckit."):
short_name = short_name[len("speckit."):]
resolver = PresetResolver(project_root)
# Resolution order for the core template:
# 1. resolve_core(cmd_name) — covers tier-1 project overrides and tier-3/4
# name-based lookup (file named <cmd_name>.md). Checked first so that a
# local override always wins, even for extension commands.
# 2. resolve_extension_command_via_manifest(cmd_name) — manifest-based tier-3
# fallback for extension commands whose file is named differently from the
# command name (e.g. speckit.selftest.extension → commands/selftest.md).
# 3. resolve_core(short_name) — core template fallback using the unprefixed
# name (e.g. specify → templates/commands/specify.md).
# resolve_core() skips installed presets (tier 2) to prevent accidental nesting
# where another preset's wrap output is mistaken for the real core.
core_file = (
resolver.resolve_core(cmd_name, "command")
or resolver.resolve_extension_command_via_manifest(cmd_name)
or resolver.resolve_core(short_name, "command")
)
if core_file is None:
return body, {}
core_frontmatter, core_body = registrar.parse_frontmatter(core_file.read_text(encoding="utf-8"))
return body.replace("{CORE_TEMPLATE}", core_body), core_frontmatter
@dataclass
class PresetCatalogEntry:
"""Represents a single entry in the preset catalog stack."""
@@ -555,6 +611,232 @@ class PresetManager:
registrar = CommandRegistrar()
registrar.unregister_commands(registered_commands, self.project_root)
def _replay_wraps_for_command(self, cmd_name: str) -> None:
"""Recompose and rewrite agent files for a wrap-strategy command.
Collects all installed presets that declare cmd_name in their
wrap_commands registry field, sorts them so the highest-precedence
preset (lowest priority number) wraps outermost, then writes the
fully composed output to every agent directory.
Called after every install and remove to keep agent files correct
regardless of installation order.
Args:
cmd_name: Full command name (e.g. "speckit.specify")
"""
try:
from .agents import CommandRegistrar
except ImportError:
return
# Collect enabled presets that wrap this command, sorted ascending
# (lowest priority number = highest precedence = outermost).
wrap_presets = []
for pack_id, metadata in self.registry.list_by_priority(include_disabled=False):
if cmd_name not in metadata.get("wrap_commands", []):
continue
pack_dir = self.presets_dir / pack_id
if not pack_dir.is_dir():
continue # corrupted state — skip
wrap_presets.append((pack_id, pack_dir))
if not wrap_presets:
return
# Derive short name for core resolution fallback.
short_name = cmd_name
if short_name.startswith("speckit."):
short_name = short_name[len("speckit."):]
resolver = PresetResolver(self.project_root)
core_file = (
resolver.resolve_core(cmd_name, "command")
or resolver.resolve_extension_command_via_manifest(cmd_name)
or (
resolver.resolve_extension_command_via_manifest(short_name)
if short_name != cmd_name
else None
)
or resolver.resolve_core(short_name, "command")
)
if core_file is None:
return
registrar = CommandRegistrar()
core_frontmatter, core_body = registrar.parse_frontmatter(
core_file.read_text(encoding="utf-8")
)
replay_aliases: List[str] = []
seen_aliases: set[str] = set()
# Apply wraps innermost-first (reverse of ascending list).
accumulated_body = core_body
outermost_frontmatter = {}
outermost_pack_id = wrap_presets[0][0] # fallback; updated per contributing preset
for pack_id, pack_dir in reversed(wrap_presets):
manifest_path = pack_dir / "preset.yml"
cmd_file: Optional[Path] = None
if manifest_path.exists():
try:
manifest = PresetManifest(manifest_path)
except (PresetValidationError, KeyError, TypeError, ValueError):
manifest = None
if manifest is not None:
for template in manifest.templates:
if template.get("type") != "command" or template.get("name") != cmd_name:
continue
file_rel = template.get("file")
if isinstance(file_rel, str):
rel_path = Path(file_rel)
if not rel_path.is_absolute():
try:
preset_root = pack_dir.resolve()
candidate = (preset_root / rel_path).resolve()
candidate.relative_to(preset_root)
except (OSError, ValueError):
candidate = None
if candidate is not None:
cmd_file = candidate
aliases = template.get("aliases", [])
if not isinstance(aliases, list):
aliases = []
for alias in aliases:
if isinstance(alias, str) and alias not in seen_aliases:
replay_aliases.append(alias)
seen_aliases.add(alias)
break
if cmd_file is None:
cmd_file = pack_dir / "commands" / f"{cmd_name}.md"
if not cmd_file.exists():
continue
wrap_fm, wrap_body = registrar.parse_frontmatter(
cmd_file.read_text(encoding="utf-8")
)
accumulated_body = wrap_body.replace("{CORE_TEMPLATE}", accumulated_body)
outermost_frontmatter = wrap_fm # last iteration = outermost preset
outermost_pack_id = pack_id
# Build final frontmatter: outermost preset wins; fall back to core for
# scripts/agent_scripts if the outermost preset does not define them.
final_frontmatter = dict(outermost_frontmatter)
final_frontmatter.pop("strategy", None)
for key in ("scripts", "agent_scripts"):
if key not in final_frontmatter and key in core_frontmatter:
final_frontmatter[key] = core_frontmatter[key]
composed_content = (
registrar.render_frontmatter(final_frontmatter) + "\n" + accumulated_body
)
self._replay_skill_override(cmd_name, composed_content, outermost_pack_id)
with tempfile.TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
cmd_dir = tmp_path / "commands"
cmd_dir.mkdir()
(cmd_dir / f"{cmd_name}.md").write_text(composed_content, encoding="utf-8")
registrar._ensure_configs()
for agent_name, agent_config in registrar.AGENT_CONFIGS.items():
if agent_config.get("extension") == "/SKILL.md":
continue
agent_dir = self.project_root / agent_config["dir"]
if not agent_dir.exists():
continue
try:
registrar.register_commands(
agent_name,
[{
"name": cmd_name,
"file": f"commands/{cmd_name}.md",
"aliases": replay_aliases,
}],
f"preset:{outermost_pack_id}",
tmp_path,
self.project_root,
)
except ValueError:
continue
def _replay_skill_override(
self,
cmd_name: str,
composed_content: str,
outermost_pack_id: str,
) -> None:
"""Rewrite any active SKILL.md override for a replayed wrap command."""
skills_dir = self._get_skills_dir()
if not skills_dir:
return
from . import SKILL_DESCRIPTIONS, load_init_options
from .agents import CommandRegistrar
from .integrations import get_integration
init_opts = load_init_options(self.project_root)
if not isinstance(init_opts, dict):
init_opts = {}
selected_ai = init_opts.get("ai")
if not isinstance(selected_ai, str):
return
registrar = CommandRegistrar()
integration = get_integration(selected_ai)
agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {})
create_missing_skills = bool(init_opts.get("ai_skills")) and agent_config.get("extension") != "/SKILL.md"
skill_name, legacy_skill_name = self._skill_names_for_command(cmd_name)
target_skill_names: List[str] = []
if (skills_dir / skill_name).is_dir():
target_skill_names.append(skill_name)
if legacy_skill_name != skill_name and (skills_dir / legacy_skill_name).is_dir():
target_skill_names.append(legacy_skill_name)
if not target_skill_names and create_missing_skills:
missing_skill_dir = skills_dir / skill_name
if not missing_skill_dir.exists():
target_skill_names.append(skill_name)
if not target_skill_names:
return
raw_short_name = cmd_name
if raw_short_name.startswith("speckit."):
raw_short_name = raw_short_name[len("speckit."):]
short_name = raw_short_name.replace(".", "-")
skill_title = self._skill_title_from_command(cmd_name)
frontmatter, body = registrar.parse_frontmatter(composed_content)
original_desc = frontmatter.get("description", "")
enhanced_desc = SKILL_DESCRIPTIONS.get(
short_name,
original_desc or f"Spec-kit workflow command: {short_name}",
)
body = registrar.resolve_skill_placeholders(
selected_ai, dict(frontmatter), body, self.project_root
)
for target_skill_name in target_skill_names:
skill_subdir = skills_dir / target_skill_name
if skill_subdir.exists() and not skill_subdir.is_dir():
continue
skill_subdir.mkdir(parents=True, exist_ok=True)
frontmatter_data = registrar.build_skill_frontmatter(
selected_ai,
target_skill_name,
enhanced_desc,
f"preset:{outermost_pack_id}",
)
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
skill_content = (
f"---\n"
f"{frontmatter_text}\n"
f"---\n\n"
f"# Speckit {skill_title} Skill\n\n"
f"{body}\n"
)
if integration is not None and hasattr(integration, "post_process_skill_content"):
skill_content = integration.post_process_skill_content(skill_content)
(skill_subdir / "SKILL.md").write_text(skill_content, encoding="utf-8")
def _get_skills_dir(self) -> Optional[Path]:
"""Return the active skills directory for preset skill overrides.
@@ -624,7 +906,7 @@ class PresetManager:
try:
manifest = ExtensionManifest(manifest_path)
except ValidationError:
except (ValidationError, TypeError, AttributeError):
continue
ext_root = ext_dir.resolve()
@@ -761,6 +1043,13 @@ class PresetManager:
content = source_file.read_text(encoding="utf-8")
frontmatter, body = registrar.parse_frontmatter(content)
if frontmatter.get("strategy") == "wrap":
body, core_frontmatter = _substitute_core_template(body, cmd_name, self.project_root, registrar)
frontmatter = dict(frontmatter)
for key in ("scripts", "agent_scripts"):
if key not in frontmatter and key in core_frontmatter:
frontmatter[key] = core_frontmatter[key]
original_desc = frontmatter.get("description", "")
enhanced_desc = SKILL_DESCRIPTIONS.get(
short_name,
@@ -974,6 +1263,24 @@ class PresetManager:
# Update corresponding skills when --ai-skills was previously used
registered_skills = self._register_skills(manifest, dest_dir)
# Detect wrap commands before registry.add() so a read failure doesn't
# leave a partially-committed registry entry.
wrap_commands = []
try:
from .agents import CommandRegistrar as _CR
_registrar = _CR()
for cmd_tmpl in manifest.templates:
if cmd_tmpl.get("type") != "command":
continue
cmd_file = dest_dir / cmd_tmpl["file"]
if not cmd_file.exists():
continue
cmd_fm, _ = _registrar.parse_frontmatter(cmd_file.read_text(encoding="utf-8"))
if cmd_fm.get("strategy") == "wrap":
wrap_commands.append(cmd_tmpl["name"])
except ImportError:
pass
self.registry.add(manifest.id, {
"version": manifest.version,
"source": "local",
@@ -982,8 +1289,12 @@ class PresetManager:
"priority": priority,
"registered_commands": registered_commands,
"registered_skills": registered_skills,
"wrap_commands": wrap_commands,
})
for cmd_name in wrap_commands:
self._replay_wraps_for_command(cmd_name)
return manifest
def install_from_zip(
@@ -1058,9 +1369,16 @@ class PresetManager:
# Restore original skills when preset is removed
registered_skills = metadata.get("registered_skills", []) if metadata else []
registered_commands = metadata.get("registered_commands", {}) if metadata else {}
wrap_commands = metadata.get("wrap_commands", []) if metadata else []
pack_dir = self.presets_dir / pack_id
# _unregister_skills must run before directory deletion (reads preset files)
if registered_skills:
self._unregister_skills(registered_skills, pack_dir)
# When _unregister_skills has already handled skill-agent files, strip
# those entries from registered_commands to avoid double-deletion.
# (When registered_skills is empty, skill-agent entries in
# registered_commands are the only deletion path for those files.)
try:
from .agents import CommandRegistrar
except ImportError:
@@ -1072,14 +1390,44 @@ class PresetManager:
if CommandRegistrar.AGENT_CONFIGS.get(agent_name, {}).get("extension") != "/SKILL.md"
}
# Unregister non-skill command files from AI agents.
if registered_commands:
self._unregister_commands(registered_commands)
# Delete the preset directory before mutating the registry so a
# filesystem failure cannot leave files on disk without a registry entry.
if pack_dir.exists():
shutil.rmtree(pack_dir)
# Remove from registry before replaying so _replay_wraps_for_command sees
# the post-removal registry state.
self.registry.remove(pack_id)
# Separate wrap commands from non-wrap commands in registered_commands.
non_wrap_commands = {
agent_name: [c for c in cmd_names if c not in wrap_commands]
for agent_name, cmd_names in registered_commands.items()
}
non_wrap_commands = {k: v for k, v in non_wrap_commands.items() if v}
# Unregister non-wrap command files from AI agents.
if non_wrap_commands:
self._unregister_commands(non_wrap_commands)
# For each wrapped command, either re-compose remaining wraps or delete.
for cmd_name in wrap_commands:
remaining = [
pid for pid, meta in self.registry.list().items()
if cmd_name in meta.get("wrap_commands", [])
]
if remaining:
self._replay_wraps_for_command(cmd_name)
else:
# No wrap presets remain — delete the agent file entirely.
wrap_agent_commands = {
agent_name: [c for c in cmd_names if c == cmd_name]
for agent_name, cmd_names in registered_commands.items()
}
wrap_agent_commands = {k: v for k, v in wrap_agent_commands.items() if v}
if wrap_agent_commands:
self._unregister_commands(wrap_agent_commands)
return True
def list_installed(self) -> List[Dict[str, Any]]:
@@ -1735,6 +2083,7 @@ class PresetResolver:
self,
template_name: str,
template_type: str = "template",
skip_presets: bool = False,
) -> Optional[Path]:
"""Resolve a template name to its file path.
@@ -1743,6 +2092,8 @@ class PresetResolver:
Args:
template_name: Template name (e.g., "spec-template")
template_type: Template type ("template", "command", or "script")
skip_presets: When True, skip tier 2 (installed presets). Use
resolve_core() as the preferred caller-facing API for this.
Returns:
Path to the resolved template file, or None if not found
@@ -1771,7 +2122,7 @@ class PresetResolver:
return override
# Priority 2: Installed presets (sorted by priority — lower number wins)
if self.presets_dir.exists():
if not skip_presets and self.presets_dir.exists():
registry = PresetRegistry(self.presets_dir)
for pack_id, _metadata in registry.list_by_priority():
pack_dir = self.presets_dir / pack_id
@@ -1810,6 +2161,99 @@ class PresetResolver:
if core.exists():
return core
# Priority 5: Bundled core_pack (wheel install) or repo-root templates
# (source-checkout / editable install). This is the canonical home for
# speckit's built-in command/template files and must always be checked
# so that strategy:wrap presets can locate {CORE_TEMPLATE}.
from specify_cli import _locate_core_pack # local import to avoid cycles
_core_pack = _locate_core_pack()
if _core_pack is not None:
# Wheel install path
if template_type == "template":
candidate = _core_pack / "templates" / f"{template_name}.md"
elif template_type == "command":
candidate = _core_pack / "commands" / f"{template_name}.md"
elif template_type == "script":
candidate = _core_pack / "scripts" / f"{template_name}{ext}"
else:
candidate = _core_pack / f"{template_name}.md"
if candidate.exists():
return candidate
else:
# Source-checkout / editable install: templates live at repo root
repo_root = Path(__file__).parent.parent.parent
if template_type == "template":
candidate = repo_root / "templates" / f"{template_name}.md"
elif template_type == "command":
candidate = repo_root / "templates" / "commands" / f"{template_name}.md"
elif template_type == "script":
candidate = repo_root / "scripts" / f"{template_name}{ext}"
else:
candidate = repo_root / f"{template_name}.md"
if candidate.exists():
return candidate
return None
def resolve_core(
self,
template_name: str,
template_type: str = "template",
) -> Optional[Path]:
"""Resolve while skipping installed presets (tier 2).
Searches tiers 1, 3, 4, and 5 (bundled core_pack / repo-root fallback).
Use when resolving {CORE_TEMPLATE} to guarantee the result is actual
base content, never another preset's wrap output.
"""
return self.resolve(template_name, template_type, skip_presets=True)
def resolve_extension_command_via_manifest(self, cmd_name: str) -> Optional[Path]:
"""Resolve an extension command by consulting installed extension manifests.
Walks installed extension directories in priority order, loads each
extension.yml via ExtensionManifest, and looks up the command by its
declared name to find the actual file path. This is necessary because
the manifest's ``provides.commands[].file`` field is authoritative and
may differ from the command name
(e.g. ``speckit.selftest.extension`` → ``commands/selftest.md``).
Returns None if no manifest maps the given command name, so the caller
can fall back to the name-based lookup.
"""
if not self.extensions_dir.exists():
return None
from .extensions import ExtensionManifest, ValidationError
for _priority, ext_id, _metadata in self._get_all_extensions_by_priority():
ext_dir = self.extensions_dir / ext_id
manifest_path = ext_dir / "extension.yml"
if not manifest_path.is_file():
continue
try:
manifest = ExtensionManifest(manifest_path)
except (ValidationError, OSError, TypeError, AttributeError):
continue
for cmd_info in manifest.commands:
if cmd_info.get("name") != cmd_name:
continue
file_rel = cmd_info.get("file")
if not file_rel:
continue
# Mirror the containment check in ExtensionManager to guard against
# path traversal via a malformed manifest (e.g. file: ../../AGENTS.md).
cmd_path = Path(file_rel)
if cmd_path.is_absolute():
continue
try:
ext_root = ext_dir.resolve()
candidate = (ext_root / cmd_path).resolve()
candidate.relative_to(ext_root) # raises ValueError if outside
except (OSError, ValueError):
continue
if candidate.is_file():
return candidate
return None
def resolve_with_source(

View File

@@ -261,7 +261,7 @@ class TestForceExistingDirectory:
], catch_exceptions=False)
assert result.exit_code == 1
assert "already exists" in result.output
assert "already exists" in _normalize_cli_output(result.output)
class TestGitExtensionAutoInstall:

View File

@@ -217,6 +217,14 @@ class TestExtensionManifest:
with pytest.raises(ValidationError, match="Missing required field"):
ExtensionManifest(manifest_path)
def test_non_mapping_yaml_raises_validation_error(self, temp_dir):
"""Manifest whose YAML root is a scalar or list raises ValidationError, not TypeError."""
manifest_path = temp_dir / "extension.yml"
for bad_content in ("42\n", "[]\n", "null\n"):
manifest_path.write_text(bad_content)
with pytest.raises(ValidationError, match="YAML mapping"):
ExtensionManifest(manifest_path)
def test_invalid_extension_id(self, temp_dir, valid_manifest_data):
"""Test manifest with invalid extension ID format."""
import yaml
@@ -1361,6 +1369,79 @@ Agent __AGENT__
assert "{ARGS}" not in content
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
@pytest.mark.parametrize("agent_name,skills_path", [
("codex", ".agents/skills"),
("kimi", ".kimi/skills"),
("claude", ".claude/skills"),
("cursor-agent", ".cursor/skills"),
("trae", ".trae/skills"),
("agy", ".agents/skills"),
])
def test_all_skill_agents_register_commands_with_resolved_placeholders(
self, project_dir, temp_dir, agent_name, skills_path
):
"""All SKILL.md agents must produce fully resolved SKILL.md files when commands are registered."""
import yaml
ext_dir = temp_dir / f"ext-{agent_name}"
ext_dir.mkdir()
(ext_dir / "commands").mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": f"ext-{agent_name}",
"name": "Scripted Extension",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": f"speckit.ext-{agent_name}.run",
"file": "commands/run.md",
"description": "Scripted command",
}
]
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.dump(manifest_data, f)
(ext_dir / "commands" / "run.md").write_text(
"---\n"
"description: Scripted command\n"
"scripts:\n"
' sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}"\n'
"---\n\n"
"Run {SCRIPT}\n"
"Agent is __AGENT__.\n"
)
init_options = project_dir / ".specify" / "init-options.json"
init_options.parent.mkdir(parents=True, exist_ok=True)
init_options.write_text(f'{{"ai":"{agent_name}","script":"sh"}}')
skills_dir = project_dir
for part in skills_path.split("/"):
skills_dir = skills_dir / part
skills_dir.mkdir(parents=True)
manifest = ExtensionManifest(ext_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_agent(agent_name, manifest, ext_dir, project_dir)
skill_dir_name = f"speckit-ext-{agent_name}-run"
skill_file = skills_dir / skill_dir_name / "SKILL.md"
assert skill_file.exists(), f"SKILL.md not created for {agent_name}"
content = skill_file.read_text()
assert "{SCRIPT}" not in content, f"{{SCRIPT}} not resolved for {agent_name}"
assert "__AGENT__" not in content, f"__AGENT__ not resolved for {agent_name}"
assert "{ARGS}" not in content, f"{{ARGS}} not resolved for {agent_name}"
assert '.specify/scripts/bash/setup-plan.sh' in content
def test_codex_skill_alias_frontmatter_matches_alias_name(self, project_dir, temp_dir):
"""Codex alias skills should render their own matching `name:` frontmatter."""
import yaml

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,204 @@
"""Tests for CommandRegistrar directory traversal guards around issue #2229."""
import errno
from pathlib import Path
import pytest
from specify_cli.agents import CommandRegistrar
TRAVERSAL_PAYLOADS = [
"../pwned",
"../../etc/passwd",
"subdir/../../escape",
"/absolute/evil",
]
def _write_source(ext_dir: Path) -> Path:
ext_dir.mkdir(parents=True, exist_ok=True)
(ext_dir / "commands").mkdir(exist_ok=True)
(ext_dir / "commands" / "cmd.md").write_text(
"---\ndescription: test\n---\n\nbody\n", encoding="utf-8"
)
return ext_dir
def _cmd(name: str, aliases: list[str] | None = None) -> dict[str, object]:
return {
"name": name,
"file": "commands/cmd.md",
"aliases": list(aliases or []),
}
def _project_and_source(tmp_path):
project = tmp_path / "project"
project.mkdir()
ext_dir = _write_source(tmp_path / "ext-src")
return project, ext_dir
def _assert_no_stray_files(tmp_root: Path, marker: str) -> None:
"""Fail if a file matching ``marker`` exists outside the project tree."""
stray = [
p for p in tmp_root.rglob("*")
if p.is_file() and marker in p.name and "project" not in p.parts
]
assert stray == [], (
f"Traversal payload leaked files outside the project tree: {stray}"
)
class TestPrimaryCommandTraversal:
"""Primary command names must not escape the agent's commands directory."""
@pytest.mark.parametrize("bad_name", TRAVERSAL_PAYLOADS)
def test_gemini_rejects_traversal_in_primary_name(self, tmp_path, bad_name):
project, ext_dir = _project_and_source(tmp_path)
(project / ".gemini" / "commands").mkdir(parents=True)
registrar = CommandRegistrar()
with pytest.raises(ValueError, match="escapes|outside|Invalid"):
registrar.register_commands(
"gemini", [_cmd(bad_name)], "myext", ext_dir, project
)
_assert_no_stray_files(tmp_path, Path(bad_name).name.replace("/", ""))
@pytest.mark.parametrize("bad_name", TRAVERSAL_PAYLOADS)
def test_copilot_rejects_traversal_in_primary_name(self, tmp_path, bad_name):
project, ext_dir = _project_and_source(tmp_path)
(project / ".github" / "agents").mkdir(parents=True)
(project / ".github" / "prompts").mkdir(parents=True)
registrar = CommandRegistrar()
with pytest.raises(ValueError, match="escapes|outside|Invalid"):
registrar.register_commands(
"copilot", [_cmd(bad_name)], "myext", ext_dir, project
)
_assert_no_stray_files(tmp_path, Path(bad_name).name.replace("/", ""))
class TestAliasTraversal:
"""Free-form aliases must not escape commands_dir (regression for b67b285)."""
@pytest.mark.parametrize("bad_alias", TRAVERSAL_PAYLOADS)
def test_gemini_rejects_traversal_in_alias(self, tmp_path, bad_alias):
project, ext_dir = _project_and_source(tmp_path)
(project / ".gemini" / "commands").mkdir(parents=True)
registrar = CommandRegistrar()
with pytest.raises(ValueError, match="escapes|outside|Invalid"):
registrar.register_commands(
"gemini",
[_cmd("speckit.myext.ok", [bad_alias])],
"myext",
ext_dir,
project,
)
_assert_no_stray_files(tmp_path, Path(bad_alias).name.replace("/", ""))
@pytest.mark.parametrize("bad_alias", TRAVERSAL_PAYLOADS)
def test_copilot_rejects_traversal_in_alias(self, tmp_path, bad_alias):
project, ext_dir = _project_and_source(tmp_path)
(project / ".github" / "agents").mkdir(parents=True)
(project / ".github" / "prompts").mkdir(parents=True)
registrar = CommandRegistrar()
with pytest.raises(ValueError, match="escapes|outside|Invalid"):
registrar.register_commands(
"copilot",
[_cmd("speckit.myext.ok", [bad_alias])],
"myext",
ext_dir,
project,
)
_assert_no_stray_files(tmp_path, Path(bad_alias).name.replace("/", ""))
class TestCopilotPromptTraversal:
"""`write_copilot_prompt` is a public static method — guard it directly."""
@pytest.mark.parametrize("bad_name", TRAVERSAL_PAYLOADS)
def test_rejects_traversal_names(self, tmp_path, bad_name):
project = tmp_path / "project"
(project / ".github" / "prompts").mkdir(parents=True)
with pytest.raises(ValueError, match="escapes|outside|Invalid"):
CommandRegistrar.write_copilot_prompt(project, bad_name)
_assert_no_stray_files(tmp_path, Path(bad_name).name.replace("/", ""))
class TestSafeRegistration:
"""Positive regression — well-formed names continue to register."""
def test_symlinked_subdir_under_commands_dir_is_preserved(self, tmp_path):
"""Lexical check must not block legitimately symlinked sub-directories.
Teams sometimes symlink shared skills into their agent commands dir
(e.g. ``.gemini/commands/shared -> /team/shared-commands``). The
guard is purely lexical, so such a setup continues to work even though
the resolved target lives outside commands_dir on disk.
"""
project, ext_dir = _project_and_source(tmp_path)
commands_dir = project / ".gemini" / "commands"
commands_dir.mkdir(parents=True)
external_shared = tmp_path / "external-shared"
external_shared.mkdir()
try:
(commands_dir / "shared").symlink_to(
external_shared, target_is_directory=True
)
except OSError as exc:
if exc.errno in {errno.EPERM, errno.EACCES}:
pytest.skip("symlink creation is not permitted in this environment")
raise
registrar = CommandRegistrar()
registered = registrar.register_commands(
"gemini",
[_cmd("shared/hello")],
"myext",
ext_dir,
project,
)
assert registered == ["shared/hello"]
assert (external_shared / "hello.toml").exists()
def test_safe_command_and_alias_still_register(self, tmp_path):
project, ext_dir = _project_and_source(tmp_path)
(project / ".claude" / "skills").mkdir(parents=True)
registrar = CommandRegistrar()
registered = registrar.register_commands(
"claude",
[_cmd("speckit.myext.hello", ["speckit.myext.hi"])],
"myext",
ext_dir,
project,
)
assert "speckit.myext.hello" in registered
assert "speckit.myext.hi" in registered
assert (
project
/ ".claude"
/ "skills"
/ "speckit-myext-hello"
/ "SKILL.md"
).exists()
assert (
project
/ ".claude"
/ "skills"
/ "speckit-myext-hi"
/ "SKILL.md"
).exists()

371
tests/test_upgrade.py Normal file
View File

@@ -0,0 +1,371 @@
"""Tests for the `specify self` sub-app (`self check` and `self upgrade`).
Network isolation contract (SC-004 / FR-014): every test that exercises
`specify self check` or `_fetch_latest_release_tag()` MUST mock
`urllib.request.urlopen` so no real outbound call ever reaches
api.github.com. The `self upgrade` stub tests do not need that patch because
the stub is contractually network-free. Run this module under `pytest-socket`
(if installed) with `--disable-socket` as an extra safety net.
"""
import json
import urllib.error
import importlib.metadata
from unittest.mock import MagicMock, patch
import pytest
from typer.testing import CliRunner
from specify_cli import (
_get_installed_version,
_fetch_latest_release_tag,
_is_newer,
_normalize_tag,
app,
)
from tests.conftest import strip_ansi
runner = CliRunner()
SENTINEL_GH_TOKEN = "SENTINEL-GH-TOKEN-VALUE"
SENTINEL_GITHUB_TOKEN = "SENTINEL-GITHUB-TOKEN-VALUE"
def _mock_urlopen_response(payload: dict) -> MagicMock:
body = json.dumps(payload).encode("utf-8")
resp = MagicMock()
resp.read.return_value = body
cm = MagicMock()
cm.__enter__.return_value = resp
cm.__exit__.return_value = False
return cm
def _http_error(code: int, message: str = "error") -> urllib.error.HTTPError:
return urllib.error.HTTPError(
url="https://api.github.com/repos/github/spec-kit/releases/latest",
code=code,
msg=message,
hdrs={}, # type: ignore[arg-type]
fp=None,
)
class TestSelfUpgradeStub:
"""Pins the `specify self upgrade` stub output + exit code (contract §3.5, FR-016)."""
def test_prints_exactly_three_lines_and_exits_zero(self):
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 0
lines = strip_ansi(result.output).strip().splitlines()
assert lines == [
"specify self upgrade is not implemented yet.",
"Run 'specify self check' to see whether a newer release is available.",
"Actual self-upgrade is planned as follow-up work.",
]
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"),
):
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 0
class TestIsNewer:
def test_latest_strictly_greater_returns_true(self):
assert _is_newer("0.8.0", "0.7.4") is True
def test_equal_versions_returns_false(self):
assert _is_newer("0.7.4", "0.7.4") is False
def test_current_greater_than_latest_returns_false(self):
assert _is_newer("0.7.0", "0.7.4") is False
def test_dev_build_ahead_of_release_returns_false(self):
assert _is_newer("0.7.4", "0.7.5.dev0") is False
def test_invalid_version_returns_false(self):
assert _is_newer("not-a-version", "0.7.4") is False
def test_local_version_containing_unknown_is_not_treated_as_sentinel(self):
assert _is_newer("1.2.4", "1.2.3+unknown") is True
class TestInstalledVersion:
def test_invalid_metadata_error_returns_unknown(self):
invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None)
if invalid_metadata_error is None:
pytest.skip("InvalidMetadataError is not available on this Python version")
with patch(
"importlib.metadata.version",
side_effect=invalid_metadata_error("bad metadata"),
):
assert _get_installed_version() == "unknown"
class TestNormalizeTag:
def test_strips_single_leading_v(self):
assert _normalize_tag("v0.7.4") == "0.7.4"
def test_idempotent_when_no_leading_v(self):
assert _normalize_tag("0.7.4") == "0.7.4"
def test_strips_exactly_one_v(self):
assert _normalize_tag("vv0.7.4") == "v0.7.4"
def test_empty_string_passthrough(self):
assert _normalize_tag("") == ""
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",
return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}),
):
result = runner.invoke(app, ["self", "check"])
output = strip_ansi(result.output)
assert result.exit_code == 0
assert "Update available" in output
assert "0.7.4" in output
assert "0.9.0" in output
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",
return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}),
):
result = runner.invoke(app, ["self", "check"])
output = strip_ansi(result.output)
assert result.exit_code == 0
assert "Up to date: 0.9.0" in output
assert "Update available" not in output
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",
return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}),
):
result = runner.invoke(app, ["self", "check"])
output = strip_ansi(result.output)
assert result.exit_code == 0
assert "Update available" not in output
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",
return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}),
):
result = runner.invoke(app, ["self", "check"])
output = strip_ansi(result.output)
assert result.exit_code == 0
assert "Current version could not be determined" in output
assert "0.7.4" in output
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",
return_value=_mock_urlopen_response({"tag_name": "not-a-version"}),
):
result = runner.invoke(app, ["self", "check"])
output = strip_ansi(result.output)
assert result.exit_code == 0
assert "Update available" not in output
assert "Up to date" in output
assert "0.7.4" in output
class TestFailureCategorization:
def test_urlerror_maps_to_offline(self):
with patch(
"specify_cli.urllib.request.urlopen",
side_effect=urllib.error.URLError("no route to host"),
):
tag, reason = _fetch_latest_release_tag()
assert tag is None
assert reason == "offline or timeout"
def test_timeout_maps_to_offline(self):
with patch(
"specify_cli.urllib.request.urlopen",
side_effect=TimeoutError(),
):
tag, reason = _fetch_latest_release_tag()
assert tag is None
assert reason == "offline or timeout"
def test_403_maps_to_rate_limited(self):
with patch(
"specify_cli.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)"
@pytest.mark.parametrize("code", [404, 500, 502])
def test_other_http_uses_code_string(self, code):
with patch(
"specify_cli.urllib.request.urlopen",
side_effect=_http_error(code, "oops"),
):
tag, reason = _fetch_latest_release_tag()
assert tag is None
assert reason == f"HTTP {code}"
def test_generic_exception_propagates(self):
# Per research D-006, no catch-all exists; RuntimeError MUST bubble.
with patch(
"specify_cli.urllib.request.urlopen",
side_effect=RuntimeError("boom"),
):
with pytest.raises(RuntimeError):
_fetch_latest_release_tag()
_FAILURE_CASES = [
("offline or timeout", urllib.error.URLError("down")),
("rate limited (try setting GH_TOKEN or GITHUB_TOKEN)", _http_error(403)),
("HTTP 500", _http_error(500)),
]
class TestUserStory2:
@pytest.mark.parametrize("expected_reason, side_effect", _FAILURE_CASES)
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
):
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)":
assert "Could not check latest release: rate limited" in output
assert "GH_TOKEN" in output
assert "GITHUB_TOKEN" 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
):
result = runner.invoke(app, ["self", "check"])
assert result.exit_code == 0
@pytest.mark.parametrize("_expected_reason, side_effect", _FAILURE_CASES)
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
):
result = runner.invoke(app, ["self", "check"])
combined = (result.output or "") + (result.stderr or "")
combined = strip_ansi(combined)
assert "Traceback" not in combined
assert "https://api.github.com" not in combined
def _capture_request_via_urlopen():
captured = {}
def _side_effect(req, timeout=None):
captured["request"] = req
return _mock_urlopen_response({"tag_name": "v0.7.4"})
return captured, _side_effect
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)
captured, side_effect = _capture_request_via_urlopen()
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
_fetch_latest_release_tag()
req = captured["request"]
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GH_TOKEN}"
def test_github_token_used_when_gh_token_unset(self, monkeypatch):
monkeypatch.delenv("GH_TOKEN", raising=False)
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
captured, side_effect = _capture_request_via_urlopen()
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
_fetch_latest_release_tag()
req = captured["request"]
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}"
def test_no_authorization_header_when_both_unset(self, monkeypatch):
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):
_fetch_latest_release_tag()
req = captured["request"]
assert req.get_header("Authorization") is None
def test_empty_string_gh_token_treated_as_unset(self, monkeypatch):
monkeypatch.setenv("GH_TOKEN", "")
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
captured, side_effect = _capture_request_via_urlopen()
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
_fetch_latest_release_tag()
req = captured["request"]
assert req.get_header("Authorization") is None
def test_whitespace_only_gh_token_treated_as_unset(self, monkeypatch):
monkeypatch.setenv("GH_TOKEN", " ")
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
captured, side_effect = _capture_request_via_urlopen()
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
_fetch_latest_release_tag()
req = captured["request"]
assert req.get_header("Authorization") is None
def test_whitespace_only_gh_token_falls_back_to_github_token(self, monkeypatch):
monkeypatch.setenv("GH_TOKEN", " ")
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
captured, side_effect = _capture_request_via_urlopen()
with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect):
_fetch_latest_release_tag()
req = captured["request"]
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}"
@pytest.mark.parametrize("_reason, side_effect", _FAILURE_CASES)
def test_gh_token_never_appears_in_failure_output(
self, _reason, side_effect, monkeypatch
):
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
):
result = runner.invoke(app, ["self", "check"])
combined = strip_ansi((result.output or "") + (result.stderr or ""))
assert SENTINEL_GH_TOKEN not in combined
@pytest.mark.parametrize("_reason, side_effect", _FAILURE_CASES)
def test_github_token_never_appears_in_failure_output(
self, _reason, side_effect, monkeypatch
):
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
):
result = runner.invoke(app, ["self", "check"])
combined = strip_ansi((result.output or "") + (result.stderr or ""))
assert SENTINEL_GITHUB_TOKEN not in combined