Compare commits

..

21 Commits

Author SHA1 Message Date
github-actions[bot]
77bf078c9d chore: bump version to 0.11.7 2026-06-24 20:00:03 +00:00
Zied Jlassi
b042d2a843 feat(extensions): verify catalog archive sha256 before install (#3080)
* feat(extensions): verify catalog archive sha256 before install

Extension and preset archives were downloaded over HTTPS and unpacked
(with Zip-Slip protection) but their bytes were never checked against a
known digest. Trust rested entirely on TLS and the integrity of the
release host, so a tampered or swapped archive from a compromised
third-party release would be installed silently. Maintainers do not audit
extension code, so consumer-side integrity is the only available defence.

Catalog entries may now pin an optional `sha256` digest. When present, the
downloaded archive is verified before it is written to disk and installed;
a mismatch aborts with a clear error. Entries without `sha256` keep
working unchanged (a DEBUG line records that the download was unverified),
so the change is backwards compatible. The check runs on both download
paths (extensions and presets) via a single shared helper so the two stay
in parity.

- Add `verify_archive_sha256` helper in shared_infra (digest match,
  `sha256:` prefix, case-insensitive; DEBUG log when no digest declared)
- Enforce it in ExtensionCatalog.download_extension and
  PresetCatalog.download_pack, before the archive is written to disk
- Document the optional `sha256` field in the publishing guides
- Tests: helper unit tests + matching/mismatch/no-digest on both paths

Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com>
Assisted-by: AI

* fix(extensions): harden sha256 parsing and tidy download test mocks

Follow-up to the review on #3080:

- shared_infra.verify_archive_sha256: strip only a literal `sha256:`
  algorithm prefix (case-insensitive) instead of `split(':', 1)[-1]`,
  which silently dropped any prefix — so `md5:<64-hex>` was accepted as
  if it were a valid SHA-256. Validate that the declared value is exactly
  64 hex characters and raise a clear error otherwise, and compare with
  `hmac.compare_digest` for a constant-time check. Add tests covering a
  malformed digest and a non-`sha256:` prefix (both previously accepted).
- Download test helpers: configure the context-manager mock via
  `__enter__.return_value`/`__exit__.return_value` rather than assigning a
  `lambda s: s`, which is clearer and independent of the invocation arity.

Assisted-by: AI
Signed-off-by: Zied Jlassi (Architect AI) <6190550+zied-jlassi@users.noreply.github.com>

* fix(extensions): reject a declared-but-empty sha256 instead of skipping verification

verify_archive_sha256 skipped on any falsy expected value, so a present-but-empty digest (e.g. sha256: "" reached via ...get("sha256")) silently disabled the integrity check instead of surfacing the authoring error. Guard on expected is None so only an absent digest skips; blank/whitespace/bare-prefix values fall through to the 64-hex validation and are rejected. Adds a regression test.

Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com>

* docs(shared_infra): clarify _SHA256_HEX_RE accepts and normalizes uppercase

The comment described the regex as matching '64 lowercase' hex characters,
but verify_archive_sha256 lowercases the declared value (raw.lower()) before
matching, so an uppercase digest is accepted and normalized rather than
rejected. Clarify the comment to avoid misleading future readers.

Addresses Copilot review feedback on shared_infra.py.

Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com>

* test(presets): cover the no-sha256 backwards-compatible path

Address Copilot review: download_pack's optional sha256 verification was
tested for match/mismatch but not the backwards-compatible path where a
catalog entry has no sha256 (pack_info.get("sha256") is None). Add a
no-sha256 test mirroring the extensions coverage so the helper never
silently becomes mandatory for presets.

Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com>

---------

Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com>
Signed-off-by: Zied Jlassi (Architect AI) <6190550+zied-jlassi@users.noreply.github.com>
2026-06-24 14:52:24 -05:00
Zied Jlassi
f846d6526c fix(workflows): validate requires keys and reject phantom permissions gate (#3079)
* fix(workflows): validate requires keys and reject phantom permissions gate

A workflow's `requires` block was parsed but its keys were never
validated, so a typo or an unsupported key was silently ignored. Most
importantly, authors could write `requires.permissions.shell: true`
expecting a runtime capability gate — but no such gate exists: a `shell`
step always runs with the user's privileges. The declaration gave a
false sense of sandboxing.

`validate_workflow` now accepts only the recognised keys
(`speckit_version`, `integrations`, `tools`, `mcp`) and rejects anything
else, with an explicit error for `requires.permissions` pointing authors
to `gate` steps for approval. Docs and the model comment are updated to
state that `requires` is advisory, not a security boundary.

- Reject non-mapping `requires`, unknown keys, and `requires.permissions`
- Clarify workflows reference + PUBLISHING.md shell-step guidance
- Tests for valid keys, non-mapping, unknown key, and permissions

Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com>
Assisted-by: AI

* fix(workflows): address review feedback on requires validation

Follow-up to the review on #3079:

- Guard `requires` validation on `is not None` instead of truthiness so a
  falsy non-mapping value (e.g. `requires: []` or `requires: ''`) is
  reported as an error instead of being silently skipped; `requires:`
  (YAML null) is still treated as an omitted block. Add a regression test.
- Reword the workflows security note so `requires.permissions` is shown
  as rejected/unsupported rather than as a valid example of `requires`.
- Standardize on US spelling (`_RECOGNIZED_REQUIRES_KEYS`, "recognized")
  to match the surrounding code and ease searching.
- Tighten the permissions-rejection test to assert on specific message
  markers (`requires.permissions` and the `gate` guidance) so it fails if
  the validation path or wording drifts.

Assisted-by: AI
Signed-off-by: Zied Jlassi (Architect AI) <6190550+zied-jlassi@users.noreply.github.com>

* fix(workflows): scope requires validation to workflow keys (drop tools/mcp)

tools and mcp belong to the bundle manifest requires schema (bundler/models/manifest.py, resolved in bundler/services/resolver.py), not the workflow requires validated here. Drop them from _RECOGNIZED_REQUIRES_KEYS and revert the PUBLISHING.md claim that this PR had introduced, so workflow requires only recognizes speckit_version and integrations.

This keeps the existing docs accurate and resolves the inline doc-consistency review comments.

Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com>

* refactor(workflows): type WorkflowDefinition.requires as Any pre-validation

self.requires holds the raw parsed value, which before validate_workflow()
runs may be a non-mapping (None for a bare 'requires:', a list for
'requires: []', etc.). Annotating it dict[str, Any] was misleading for
editors/type-checkers; use Any and document that validate_workflow() enforces
the mapping shape.

Addresses Copilot review feedback on engine.py.

Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com>

* fix(workflows): reject YAML-null requires: as a non-mapping

Address Copilot review: validate requires the same way as inputs. A
bare requires: parses as YAML null and was previously treated as an
omitted block, which is inconsistent with inputs and lets a stray
requires: line be silently ignored.

Drop the is-not-None guard and check isinstance(..., dict) directly: an
omitted block still defaults to {} (valid), but a present-but-non-mapping
value -- YAML null, [] or '' -- is now an authoring error that surfaces.

Tests: add YAML-null rejection + an omitted-is-still-valid guard test.
Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com>

---------

Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com>
Signed-off-by: Zied Jlassi (Architect AI) <6190550+zied-jlassi@users.noreply.github.com>
2026-06-24 14:49:43 -05:00
Quratulain-bilal
37e0e71b4e fix(scripts): use case-sensitive match for acronym retention in PS branch names (#3130)
The branch-name generator keeps a short (<3 char) word only when it
appears in uppercase in the description, treating it as an acronym (the
comment says as much). The bash script uses a case-sensitive grep for
this, but the PowerShell script used -match, which is case-insensitive
by default. As a result every short non-stop word was retained on
PowerShell even when lowercase, so the same description produced
different branch names across the two shells (e.g. 'go AI now' ->
001-go-ai-now on PS vs 001-ai-now on bash).

Switch to -cmatch so the check is case-sensitive and the two shells
agree. Adds parity tests covering a dropped lowercase short word and a
kept uppercase acronym.
2026-06-24 14:02:41 -05:00
Omar
44ef11aa18 feat(integrations): add omp support (#3107)
* feat(integrations): add omp support

* Update updated_at timestamp

* refactor(integrations): delegate omp build_exec_args to base, register in issue templates

Inherit MarkdownIntegration.build_exec_args so omp picks up shared CLI
contract changes (requires_cli gating, extra-args ordering, --model
handling) automatically; only specialize the --mode json flag.

Also add Oh My Pi / omp to the issue-template agent lists so
test_issue_template_agent_lists_match_runtime_integrations passes.

* fix(integrations): use --print + positional prompt for omp argv

OMP's CLI parser treats `-p`/`--print` as a boolean (one-shot mode)
and consumes the prompt as a positional message; the previous
inherited `-p <prompt>` shape worked by accident only because `-p`
ignores its next token. Build the argv explicitly with flags first
and the prompt as a trailing positional, matching upstream args.ts.
2026-06-24 13:44:34 -05:00
Ali jawwad
034fbfcbb4 fix: render valid TOML when a command body contains backslashes (#3135)
render_toml_command() emitted the body inside a multiline *basic* TOML
string ("""..."""), which processes backslash escape sequences. A command
body containing a backslash — e.g. a Windows path like C:\Users\... whose
\U reads as an invalid unicode escape — therefore produced unparseable TOML
("Invalid hex value"), so the generated Gemini/Tabnine command file failed
to load. A body ending in a backslash also silently ate the closing newline
via TOML line-continuation.

Route bodies containing a backslash to the multiline *literal* form
('''...'''), which does not process escapes, or to the escaped basic string
when both triple-quote styles are present. Mirrors the escaping already done
by base.py's TomlIntegration.

Add tests covering a Windows path, a trailing backslash, and the
backslash + both-triple-quote-styles fallback.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:13:44 -05:00
Pascal THUET
8e76ff3d5c harden: reject shell=True in run_command (#3132)
run_command() forwarded shell= straight to subprocess.run, so a caller
passing shell=True would invoke a shell. Reject shell=True with ValueError
(keeping the parameter for signature compatibility) and drop shell= from
both subprocess.run calls.

Enable ruff S602/S604/S605 to flag any future shell=True reintroduction,
annotate the one intentional workflow shell sink with # noqa: S602, and
document the shell-step execution risk in workflows/PUBLISHING.md.
2026-06-24 13:05:21 -05:00
Pascal THUET
b6b74d4ccf docs: add monorepo guide (#3084)
* docs: add monorepo guide

Adds docs/guides/monorepo.md covering per-project .specify/, targeting a member project from the repo root with SPECIFY_INIT_DIR, agent env propagation, the git extension scoping limitation (#3081), and per-project constitutions. Wires it into docs/toc.yml under Development.

* docs: correct monorepo Git guidance

* docs: drop open-issue reference and polish monorepo guide prose

* docs: fix SPECIFY_INIT_DIR error example (absolute path, non-project dir)

* docs: address Copilot wording nits in monorepo guide

* docs: clarify monorepo constitution sharing

Assisted-by: Codex (model: GPT-5, autonomous)
2026-06-23 14:20:28 -05:00
Quratulain-bilal
0ef53eb91f fix(scripts): send check-prerequisites.ps1 errors to stderr (#3123)
* fix(scripts): send check-prerequisites.ps1 errors to stderr

The validation errors and run-hints in check-prerequisites.ps1 were
written with Write-Output, so they went to stdout. This script is
usually run with -Json and its stdout parsed by the agent, so an error
(e.g. missing plan.md) leaves the parser with an error string instead
of JSON. The bash counterpart already writes these to stderr (>&2), as
do the sibling PowerShell scripts (setup-tasks.ps1, common.ps1's
Get-FeaturePathsEnv). Switch the six error/hint lines to
[Console]::Error.WriteLine so stdout stays clean and the two shells
match.

* test(scripts): assert check-prerequisites errors stay on stderr

Per the #3122 bug assessment, tighten the failure-path tests so they
verify stdout stays clean (empty / valid JSON) and the error text only
appears on stderr, instead of checking the combined stdout+stderr
string. Covers all three PowerShell validation paths (missing feature
dir, missing plan.md, missing tasks.md with -RequireTasks) and the bash
counterpart. The two new error-routing tests fail on the pre-fix
script (errors on stdout) and pass after it.
2026-06-23 14:09:17 -05:00
JinHyuk Sung
0c975bbef7 fix: write Codex dev skills as files (#2988)
* fix: write Codex dev skills as files

* fix: route codex dev symlink policy through metadata

* fix: replace codex dev symlinks on refresh

* fix: migrate codex dev skill symlinks

* fix: avoid inactive shared skill dev symlinks

* fix: preserve unrelated dev skill symlinks
2026-06-23 10:55:38 -05:00
Manfred Riem
59ffa918df chore: release 0.11.6, begin 0.11.7.dev0 development (#3121)
* chore: bump version to 0.11.6

* chore: begin 0.11.7.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-23 09:47:17 -05:00
github-actions[bot]
45423d6bc6 [extension] Update Spec Kit Preview extension to v1.1.0 and sync Firebender agent lists (#3116)
* Update Spec Kit Preview extension to v1.1.0

Update preview extension submitted by @bigsmartben to:
- extensions/catalog.community.json (version, name, description, download_url, commands, tags, updated_at)
- docs/community/extensions.md community extensions table (name, description, alphabetical order)

Closes #3109

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

* Sync issue templates with firebender integration

Assisted-by: GitHub Copilot (model: GPT-5, autonomous)

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
2026-06-23 09:32:16 -05:00
github-actions[bot]
a86ee0e8b6 Add Spec Kit Discovery Extension to community catalog (#3119)
Add discovery extension submitted by @bigsmartben to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #3113

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-23 08:30:21 -05:00
github-actions[bot]
8c85919f0f Update Architecture Workflow extension to v1.2.1 (#3118)
Update arch extension submitted by @bigsmartben to:
- extensions/catalog.community.json (version, download_url, description, provides.commands)
- docs/community/extensions.md community extensions table

Closes #3111

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-23 08:30:01 -05:00
YNan_varamor
3cfc81ff31 docs: clarify project-defined constitution articles (#2994)
Co-authored-by: yann lei <yann.lei@hotmail.com>
2026-06-23 08:27:26 -05:00
github-actions[bot]
2344eafdd9 Add Intake extension to community catalog (#3117)
Add intake extension submitted by @bigsmartben to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #3110

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-23 08:16:15 -05:00
Ali jawwad
0a126256e0 feat: add Firebender integration (Android Studio / IntelliJ) (#3077)
* feat: add Firebender integration (Android Studio / IntelliJ)

Firebender (https://firebender.com/) is an AI coding agent for Android
Studio and IntelliJ. It reads project-local custom slash commands from
.firebender/commands/*.mdc and project rules from .firebender/rules/*.mdc.

Add a FirebenderIntegration (MarkdownIntegration) that installs the
speckit command templates as .mdc command files and writes the managed
context section into .firebender/rules/specify-rules.mdc. command_filename
is overridden so init-time commands also use the .mdc extension Firebender
requires. Register it in the integration registry, add the catalog entry
and docs row, and add an integration test covering the .mdc command output.

Closes #1548

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

* feat: address review - bump catalog updated_at and list firebender as multi-install safe

Bump the catalog top-level updated_at to reflect the new entry, and add firebender (with its .firebender/commands + .firebender/rules/specify-rules.mdc isolation paths) to the 'currently declared multi-install safe integrations' table in the docs.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 08:01:00 -05:00
github-actions[bot]
2bd97543cc Update DocGuard — CDD Enforcement extension to v0.28.0 (#3115)
Update docguard extension submitted by @raccioly:
- extensions/catalog.community.json (version, download_url, updated_at)
- docs/community/extensions.md community extensions table (no changes needed)

Closes #3106

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-23 07:46:19 -05:00
WOLIKIMCHENG
ac4f646144 chore: sync issue template agent lists (#3052)
* chore: sync issue template agent lists

* test: harden agent template consistency check

* test: harden agent template drift checks

---------

Co-authored-by: root <kinsonnee@gmail.com>
2026-06-23 07:41:58 -05:00
José Villaseñor Montfort
e5a03bffc8 fix(shared-infra): remove stale managed scripts the core no longer ships (#3076) (#3098)
* fix(shared-infra): remove stale managed scripts the core no longer ships (#3076)

install_shared_infra never removed shared scripts a prior (pre-refactor) install recorded but the current core no longer ships — e.g. the legacy scripts/<variant>/update-agent-context.sh, superseded by the bundled agent-context extension. On a legacy project the orphan lingers and crashes when it sources a refreshed common.sh (HAS_GIT unbound under set -u).

Apply the stale-removal that integration_upgrade already performs to install_shared_infra: manifest-tracked scripts the current bundle no longer produces are removed, but only managed copies (hash matches the manifest); user-customized files, symlinks, and recovered entries are preserved. Guarded so a missing/empty source can't trigger mass deletion, and the safe-destination check prevents unlinking through a symlinked ancestor.

Add IntegrationManifest.remove(); drop the stale update-agent-context.sh reference in CONTRIBUTING.md.

AI assistance: implemented with Claude Code (Anthropic); reviewed and validated locally (ruff clean, full suite 4176 passed, manual CLI repro).

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

* fix(shared-infra): harden stale-cleanup per review (empty source + orphan manifest)

- Set scripts_scanned only after a real source file is seen, so an empty variant source can't trigger mass deletion of tracked scripts.
- Prune a stale manifest entry even when its file is already gone from disk, keeping the manifest consistent (previously left tracked forever).
- Add a test for each edge case.

Addresses the Copilot review comments on #3098. AI assistance: Claude Code (Anthropic), reviewed/validated locally (ruff clean, full suite 4178 passed).

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

* fix(shared-infra): guard unsafe manifest keys in stale-cleanup (review)

- Skip absolute / '..' manifest keys before any filesystem access in stale-cleanup, so a corrupted/hand-edited manifest can't make it touch paths outside the project root (mirrors IntegrationManifest.check_modified / uninstall).
- Clarify the scripts_scanned comment: the safety hinge is that flag, not seen_rels (which also holds template paths).
- Add a containment test: a traversal manifest key is skipped, its target untouched.

Addresses the second round of Copilot review on #3098. AI assistance: Claude Code (Anthropic); validated locally (ruff clean, full suite 4179 passed).

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

* fix(manifest): make remove() reject absolute/.. keys like its siblings (review)

IntegrationManifest.remove() now applies the same lexical validation and normalization as record_existing() / is_recovered(): absolute paths and '..' segments are rejected (return False) instead of being used verbatim as a key. Keeps the manifest API consistent. Adds tests (valid drop + no-op, absolute rejected, traversal rejected).

Addresses the third round of Copilot review on #3098. AI assistance: Claude Code (Anthropic); validated locally (ruff clean, full suite 4182 passed).

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

* fix(shared-infra): validate stale-cleanup keys for containment, not just lexically (review)

The stale-script cleanup guarded manifest keys with a lexical check only
(is_absolute() / ".." segments). On Windows a drive-relative key such as
"C:tmp\\file" is not is_absolute(), yet joining it onto the project path
discards the root — so cleanup could stat/unlink outside the project before
_ensure_safe_shared_destination raised, and a corrupted manifest key turned
into an install-time hard failure (ValueError) instead of being skipped.

Reuse the canonical containment helper (_validate_rel_path, the same one
IntegrationManifest.is_recovered / remove use): after the fast lexical reject,
resolve the join and confirm it stays within the project root; a key that
still escapes is skipped, never unlinked, never fatal.

Adds a regression test that forces _validate_rel_path to reject a managed key
(portably simulating the Windows drive-relative escape) and asserts the
install skips it without failing and still installs the real scripts.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 07:36:54 -05:00
Manfred Riem
3c11f4d90b chore: release 0.11.5, begin 0.11.6.dev0 development (#3105)
* chore: bump version to 0.11.5

* chore: begin 0.11.6.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-22 17:52:26 -05:00
49 changed files with 1994 additions and 85 deletions

View File

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

View File

@@ -62,24 +62,42 @@ body:
label: AI Agent
description: Which AI agent are you using?
options:
- Amp
- Antigravity
- Auggie CLI
- Claude Code
- Cline
- CodeBuddy
- Codex CLI
- Cursor
- Devin for Terminal
- Firebender
- Forge
- Gemini CLI
- GitHub Copilot
- Cursor
- Qwen Code
- opencode
- Codex CLI
- Windsurf
- Kilo Code
- Auggie CLI
- Roo Code
- CodeBuddy
- Qoder CLI
- Kiro CLI
- Amp
- SHAI
- Goose
- Hermes Agent
- IBM Bob
- Antigravity
- iFlow CLI
- Junie
- Kilo Code
- Kimi Code
- Kiro CLI
- Lingma
- Mistral Vibe
- Oh My Pi
- opencode
- Pi Coding Agent
- Qoder CLI
- Qwen Code
- Roo Code
- RovoDev ACLI
- SHAI
- Tabnine CLI
- Trae
- Windsurf
- ZCode
- Zed
- Not applicable
validations:
required: true

View File

@@ -56,24 +56,42 @@ body:
description: Does this feature relate to a specific AI agent?
options:
- All agents
- Amp
- Antigravity
- Auggie CLI
- Claude Code
- Cline
- CodeBuddy
- Codex CLI
- Cursor
- Devin for Terminal
- Firebender
- Forge
- Gemini CLI
- GitHub Copilot
- Cursor
- Qwen Code
- opencode
- Codex CLI
- Windsurf
- Kilo Code
- Auggie CLI
- Roo Code
- CodeBuddy
- Qoder CLI
- Kiro CLI
- Amp
- SHAI
- Goose
- Hermes Agent
- IBM Bob
- Antigravity
- iFlow CLI
- Junie
- Kilo Code
- Kimi Code
- Kiro CLI
- Lingma
- Mistral Vibe
- Oh My Pi
- opencode
- Pi Coding Agent
- Qoder CLI
- Qwen Code
- Roo Code
- RovoDev ACLI
- SHAI
- Tabnine CLI
- Trae
- Windsurf
- ZCode
- Zed
- Not applicable
- type: textarea

View File

@@ -2,6 +2,36 @@
<!-- insert new changelog below this comment -->
## [0.11.7] - 2026-06-24
### Changed
- feat(extensions): verify catalog archive sha256 before install (#3080)
- fix(workflows): validate requires keys and reject phantom permissions gate (#3079)
- fix(scripts): use case-sensitive match for acronym retention in PS branch names (#3130)
- feat(integrations): add omp support (#3107)
- fix: render valid TOML when a command body contains backslashes (#3135)
- harden: reject shell=True in run_command (#3132)
- docs: add monorepo guide (#3084)
- fix(scripts): send check-prerequisites.ps1 errors to stderr (#3123)
- fix: write Codex dev skills as files (#2988)
- chore: release 0.11.6, begin 0.11.7.dev0 development (#3121)
## [0.11.6] - 2026-06-23
### Changed
- [extension] Update Spec Kit Preview extension to v1.1.0 and sync Firebender agent lists (#3116)
- Add Spec Kit Discovery Extension to community catalog (#3119)
- Update Architecture Workflow extension to v1.2.1 (#3118)
- docs: clarify project-defined constitution articles (#2994)
- Add Intake extension to community catalog (#3117)
- feat: add Firebender integration (Android Studio / IntelliJ) (#3077)
- Update DocGuard — CDD Enforcement extension to v0.28.0 (#3115)
- chore: sync issue template agent lists (#3052)
- fix(shared-infra): remove stale managed scripts the core no longer ships (#3076) (#3098)
- chore: release 0.11.5, begin 0.11.6.dev0 development (#3105)
## [0.11.5] - 2026-06-22
### Changed

View File

@@ -167,7 +167,7 @@ the command templates in templates/commands/ to understand what each command
invokes. Use these mapping rules:
- templates/commands/X.md → the command it defines
- scripts/bash/Y.sh or scripts/powershell/Y.ps1 → every command that invokes that script (grep templates/commands/ for the script name). Also check transitive dependencies: if the changed script is sourced by other scripts (e.g., common.sh is sourced by create-new-feature.sh, check-prerequisites.sh, setup-plan.sh, update-agent-context.sh), then every command invoking those downstream scripts is also affected
- scripts/bash/Y.sh or scripts/powershell/Y.ps1 → every command that invokes that script (grep templates/commands/ for the script name). Also check transitive dependencies: if the changed script is sourced by other scripts (e.g., common.sh is sourced by create-new-feature.sh, check-prerequisites.sh, setup-plan.sh), then every command invoking those downstream scripts is also affected
- templates/Z-template.md → every command that consumes that template during execution
- src/specify_cli/*.py → CLI commands (`specify init`, `specify check`, `specify extension *`, `specify preset *`); test the affected CLI command and, for init/scaffolding changes, at minimum test /speckit.specify
- extensions/X/commands/* → the extension command it defines

View File

@@ -403,7 +403,7 @@ specify init . --force --integration copilot
specify init --here --force --integration copilot
```
The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, Forge, Goose, Mistral Vibe, or ZCode installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command:
The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, Oh My Pi, Forge, Goose, Mistral Vibe, or ZCode installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command:
```bash
specify init <project_name> --integration copilot --ignore-agent-tools

View File

@@ -31,7 +31,7 @@ The following community-contributed extensions are available in [`catalog.commun
| API Evolve | Managed API contract evolution — breaking-change detection, semver enforcement, deprecation orchestration, and lifecycle gates across REST, GraphQL, and gRPC | `process` | Read+Write | [spec-kit-api-evolve](https://github.com/Quratulain-bilal/spec-kit-api-evolve) |
| Architect Impact Previewer | Predicts architectural impact, complexity, and risks of proposed changes before implementation. | `visibility` | Read-only | [spec-kit-architect-preview](https://github.com/UmmeHabiba1312/spec-kit-architect-preview) |
| Architecture Guard | Framework-agnostic architecture review extension for validating implementation against governance and architecture constitutions, detecting architectural drift, and generating non-blocking refactor tasks | `process` | Read+Write | [spec-kit-architecture-guard](https://github.com/DyanGalih/spec-kit-architecture-guard) |
| Architecture Workflow | Generate or reverse project-level 4+1 architecture view artifacts and synthesis | `docs` | Read+Write | [spec-kit-arch](https://github.com/bigsmartben/spec-kit-arch) |
| Architecture Workflow | Generate or reverse project-level 4+1 architecture views as separate commands | `docs` | Read+Write | [spec-kit-arch](https://github.com/bigsmartben/spec-kit-arch) |
| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
| Blueprint | Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs | `docs` | Read+Write | [spec-kit-blueprint](https://github.com/chordpli/spec-kit-blueprint) |
@@ -57,7 +57,7 @@ The following community-contributed extensions are available in [`catalog.commun
| GitHub Issues Integration 1 | Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability | `integration` | Read+Write | [spec-kit-github-issues](https://github.com/Fatima367/spec-kit-github-issues) |
| GitHub Issues Integration 2 | Creates and syncs local specs from an existing GitHub issue | `integration` | Read+Write | [spec-kit-issue](https://github.com/aaronrsun/spec-kit-issue) |
| Improve Extension | Audits any codebase as a senior advisor and writes prioritized, self-contained spec prompts under specs/ that the spec-kit lifecycle can process | `process` | Read+Write | [spec-kit-improve](https://github.com/d0whc3r/spec-kit-improve) |
| Interactive HTML Preview | Generate self-contained interactive HTML prototypes from Spec Kit artifacts | `docs` | Read+Write | [spec-kit-preview](https://github.com/bigsmartben/spec-kit-preview) |
| Intake | Normalize PRD, design, and test-case evidence into SDD-ready intake artifacts | `docs` | Read+Write | [spec-kit-intake](https://github.com/bigsmartben/spec-kit-intake) |
| Intelligent Agent Orchestrator | Cross-catalog agent discovery and intelligent prompt-to-command routing | `process` | Read+Write | [spec-kit-orchestrator](https://github.com/pragya247/spec-kit-orchestrator) |
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
@@ -110,6 +110,8 @@ The following community-contributed extensions are available in [`catalog.commun
| Spec Changelog | Auto-generate changelogs and release notes from spec git history and requirement diffs | `docs` | Read-only | [spec-kit-changelog](https://github.com/Quratulain-bilal/spec-kit-changelog) |
| Spec Critique Extension | Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives | `docs` | Read-only | [spec-kit-critique](https://github.com/arunt14/spec-kit-critique) |
| Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) |
| Spec Kit Discovery Extension | Run technical discovery commands for feasibility, technology selection, scenario-specific technical decisions, legacy codebase assessment, implementation understanding, and proof-of-concept validation | `process` | Read+Write | [spec-kit-discovery](https://github.com/bigsmartben/spec-kit-discovery) |
| Spec Kit Preview | Generate evidence-backed low, mid, or high fidelity previews from Spec Kit artifacts as Markdown or self-contained HTML | `docs` | Read+Write | [spec-kit-preview](https://github.com/bigsmartben/spec-kit-preview) |
| Spec Kit Schedule | Optimal multi-agent task scheduling via CP-SAT — DAG precedence, hallucination-aware caps, file-conflict avoidance, stochastic durations, replanning, and interactive HTML output | `process` | Read+Write | [spec-kit-schedule](https://github.com/jfranc38/spec-kit-schedule) |
| Spec Kit TLDR | Render a feature's spec.md / plan.md into a review-oriented TLDR (self-contained HTML dashboard + PR-native Markdown) that surfaces risks for faster PR review. | `visibility` | Read+Write | [speckit-tldr](https://github.com/qurore/speckit-tldr) |
| Spec Orchestrator | Cross-feature orchestration — track state, select tasks, and detect conflicts across parallel specs | `process` | Read-only | [spec-kit-orchestrator](https://github.com/Quratulain-bilal/spec-kit-orchestrator) |

111
docs/guides/monorepo.md Normal file
View File

@@ -0,0 +1,111 @@
# Using Spec Kit in a Monorepo
A Spec Kit project is **directory-scoped**: the project is whichever directory
contains `.specify/`. A monorepo can hold several independent Spec Kit projects
under one repository root, each with its own `.specify/`, `specs/`, constitution,
and feature numbering.
Root resolution already prefers the **nearest** `.specify/` over the Git
toplevel, so commands run from inside a member project resolve to that project,
not the repo root.
## Layout
```text
my-monorepo/
├── .git/ # one Git repository at the root
├── apps/
│ ├── web/
│ │ └── .specify/ # Spec Kit project "web"
│ │ └── memory/constitution.md
│ └── api/
│ └── .specify/ # Spec Kit project "api"
│ └── memory/constitution.md
└── packages/
└── ui/
└── .specify/ # Spec Kit project "ui"
```
Initialize each member project independently:
```bash
specify init apps/web --integration claude
specify init apps/api --integration claude
```
Each project keeps its own `specs/` directory and numbers features
independently (`apps/web/specs/001-…`, `apps/api/specs/001-…`).
## Working inside a member project
The default workflow is unchanged: change into the project directory and run the
slash commands. Root resolution finds the nearest `.specify/`.
```bash
cd apps/web
# then run /speckit.specify, /speckit.plan, … in your agent
```
## Targeting a member project from the repo root
For non-interactive or CI runs where you do not want to `cd`, set
**`SPECIFY_INIT_DIR`** to the member project root (the directory *containing*
`.specify/`). Relative paths resolve against the current directory.
```bash
# operate on apps/web from the monorepo root (no cd required)
export SPECIFY_INIT_DIR=apps/web
```
The path must exist and contain `.specify/`. If it does not, the command
**errors and does not fall back** to the current directory or the Git toplevel.
This is deliberate: a typo never writes specs into the wrong project. A
nonexistent path is reported as you typed it; a path that exists but is not a
Spec Kit project is reported as its resolved absolute path:
```text
# SPECIFY_INIT_DIR=apps/wbe (typo: no such directory)
ERROR: SPECIFY_INIT_DIR does not point to an existing directory: apps/wbe
# SPECIFY_INIT_DIR=apps (exists, but has no .specify/ of its own)
ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): /home/you/my-monorepo/apps
```
`SPECIFY_INIT_DIR` selects the **project**; `SPECIFY_FEATURE_DIRECTORY` selects
the **feature** within it. They compose: set both to pick a project and a
feature non-interactively. See the
[`SPECIFY_INIT_DIR` reference](../reference/core.md#environment-variables) for
the full contract and the two-axes model.
## How `SPECIFY_INIT_DIR` reaches your agent
`SPECIFY_INIT_DIR` is read by the shell scripts that the slash commands invoke
(`get_repo_root` in Bash, `Get-RepoRoot` in PowerShell). It takes effect only
when it is present in the environment of the shell that runs those scripts.
- **Scripted / CI runs:** export it in the same shell that drives the commands;
it is reliable there.
- **Interactive agents:** whether an exported variable reaches the shell tool an
agent uses is agent-specific. Export `SPECIFY_INIT_DIR` *before* launching the
agent, and verify once (e.g. run `/speckit.specify` and confirm the new feature
landed under the intended project's `specs/`).
## Git in a monorepo
> [!NOTE]
> Spec Kit project files are scoped to the **resolved project root**, but Git
> operations still run in the containing Git work tree. In a monorepo with a
> single Git repository at the root and projects in subdirectories, feature
> branch creation creates or switches branches in the shared root repository.
> Spec directories still live under the selected member project, while the Git
> branch namespace is shared by the whole monorepo. Manage branches and commits
> at the repository root, or initialize Git per member project if you want
> isolated per-project branch namespaces.
## Constitutions
Each member project has its own `.specify/memory/constitution.md` and
`/speckit.constitution` edits the local project's file. Spec Kit does not provide
a built-in base/inheritance mechanism; if you want one constitution to reference
shared rules elsewhere in the monorepo, you need to maintain that wiring yourself.
Otherwise, duplicate or sync shared engineering rules per project.

View File

@@ -3,7 +3,7 @@
## Prerequisites
- **Linux/macOS** (or Windows; PowerShell scripts now supported without WSL)
- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Pi Coding Agent](https://pi.dev)
- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi Coding Agent](https://pi.dev), or [Oh My Pi](https://www.npmjs.com/package/@oh-my-pi/pi-coding-agent)
- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pipx.pypa.io/) for persistent installation
- [Python 3.11+](https://www.python.org/downloads/)
- [Git](https://git-scm.com/downloads) _(optional — required only when the git extension is enabled)_
@@ -51,6 +51,7 @@ specify init <project_name> --integration gemini
specify init <project_name> --integration copilot
specify init <project_name> --integration codebuddy
specify init <project_name> --integration pi
specify init <project_name> --integration omp
```
### Specify Script Type (Shell vs PowerShell)

View File

@@ -15,6 +15,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Codex CLI](https://github.com/openai/codex) | `codex` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `$speckit-<command>` |
| [Cursor](https://cursor.sh/) | `cursor-agent` | |
| [Devin for Terminal](https://cli.devin.ai/docs) | `devin` | Skills-based integration; installs skills into `.devin/skills/` and invokes them as `/speckit-<command>` |
| [Firebender](https://firebender.com/) | `firebender` | IDE-based agent for Android Studio / IntelliJ |
| [Forge](https://forgecode.dev/) | `forge` | |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | |
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | |
@@ -28,6 +29,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Kiro CLI does not substitute `$ARGUMENTS` in file-based prompts, so Spec Kit ships a prose fallback at render time (see [Manage prompts](https://kiro.dev/docs/cli/chat/manage-prompts/) and issue [#1926](https://github.com/github/spec-kit/issues/1926)). Alias: `--integration kiro` |
| [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically |
| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | |
| [Oh My Pi](https://www.npmjs.com/package/@oh-my-pi/pi-coding-agent) | `omp` | Installs slash commands into `.omp/commands` |
| [opencode](https://opencode.ai/) | `opencode` | |
| [Pi Coding Agent](https://pi.dev) | `pi` | Pi doesn't have MCP support out of the box, so `taskstoissues` won't work as intended. MCP support can be added via [extensions](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent#extensions) |
| [Qoder CLI](https://qoder.com/cli) | `qodercli` | |
@@ -185,6 +187,7 @@ The currently declared multi-install safe integrations are:
| `codebuddy` | `.codebuddy/commands`, `CODEBUDDY.md` |
| `codex` | `.agents/skills`, `AGENTS.md` |
| `cursor-agent` | `.cursor/skills`, `.cursor/rules/specify-rules.mdc` |
| `firebender` | `.firebender/commands`, `.firebender/rules/specify-rules.mdc` |
| `gemini` | `.gemini/commands`, `GEMINI.md` |
| `iflow` | `.iflow/commands`, `IFLOW.md` |
| `junie` | `.junie/commands`, `.junie/AGENTS.md` |

View File

@@ -270,6 +270,8 @@ specify workflow run speckit -i spec="Build a kanban board with drag-and-drop ta
| `fan-out` | Dispatch a step for each item in a list |
| `fan-in` | Aggregate results from a fan-out step |
> **Security note:** a `shell` step runs a local command with **your** privileges. There is no capability sandbox — `requires` is an advisory pre-condition block (spec-kit version, integrations), not a runtime gate, so it does **not** restrict what a step can do. In particular there is no `requires.permissions` capability gate: it is rejected by validation precisely because it would imply a sandbox that does not exist. Review any catalog or downloaded workflow before running it, and use a `gate` step to require explicit approval before sensitive or destructive shell commands.
## Expressions
Steps can reference inputs and previous step outputs using `{{ expression }}` syntax:

View File

@@ -53,6 +53,8 @@
href: local-development.md
- name: Evolving Specs
href: guides/evolving-specs.md
- name: Monorepos
href: guides/monorepo.md
# Community
- name: Community

View File

@@ -308,6 +308,7 @@ Alternatively, run the `/speckit.specify` command which creates `.specify/featur
ls -la .gemini/commands/ # Gemini
ls -la .cursor/skills/ # Cursor
ls -la .pi/prompts/ # Pi Coding Agent
ls -la .omp/commands/ # Oh My Pi
```
3. **Check agent-specific setup:**
@@ -427,7 +428,7 @@ The `specify` CLI tool is used for:
- **Upgrades:** `specify init --here --force` to update templates and commands
- **Diagnostics:** `specify check` to verify tool installation
Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/speckit.plan`, etc.) are **permanently installed** in your project's agent folder (`.claude/`, `.github/prompts/`, `.pi/prompts/`, etc.). Your AI coding agent reads these command files directly—no need to run `specify` again.
Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/speckit.plan`, etc.) are **permanently installed** in your project's agent folder (`.claude/`, `.github/prompts/`, `.pi/prompts/`, `.omp/commands/`, etc.). Your AI coding agent reads these command files directly—no need to run `specify` again.
**If your agent isn't recognizing slash commands:**
@@ -442,6 +443,9 @@ Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/s
# For Pi
ls -la .pi/prompts/
# For Oh My Pi
ls -la .omp/commands/
```
2. **Restart your IDE/editor completely** (not just reload window)

View File

@@ -320,6 +320,7 @@ A: Extensions should be free and open-source. Commercial support/services are al
"author": "string (required)",
"version": "string (required, semver)",
"download_url": "string (required, valid URL)",
"sha256": "string (optional, SHA-256 hex digest of the archive at download_url; verified before install)",
"repository": "string (required, valid URL)",
"homepage": "string (optional, valid URL)",
"documentation": "string (optional, valid URL)",

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-06-22T00:00:00Z",
"updated_at": "2026-06-23T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -187,10 +187,10 @@
"arch": {
"name": "Architecture Workflow",
"id": "arch",
"description": "Generate or reverse project-level 4+1 architecture view artifacts and synthesis",
"description": "Generate or reverse project-level 4+1 architecture views as separate commands",
"author": "bigsmartben",
"version": "1.1.0",
"download_url": "https://github.com/bigsmartben/spec-kit-arch/archive/refs/tags/v1.1.0.zip",
"version": "1.2.1",
"download_url": "https://github.com/bigsmartben/spec-kit-arch/archive/refs/tags/v1.2.1.zip",
"repository": "https://github.com/bigsmartben/spec-kit-arch",
"homepage": "https://github.com/bigsmartben/spec-kit-arch",
"documentation": "https://github.com/bigsmartben/spec-kit-arch/blob/main/README.md",
@@ -202,7 +202,7 @@
"speckit_version": ">=0.8.10.dev0"
},
"provides": {
"commands": 2,
"commands": 10,
"hooks": 0
},
"tags": [
@@ -215,7 +215,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-05-14T00:00:00Z",
"updated_at": "2026-05-15T00:00:00Z"
"updated_at": "2026-06-23T00:00:00Z"
},
"architect-preview": {
"name": "Architect Impact Previewer",
@@ -1001,13 +1001,47 @@
"created_at": "2026-04-08T00:00:00Z",
"updated_at": "2026-04-08T00:00:00Z"
},
"discovery": {
"name": "Spec Kit Discovery Extension",
"id": "discovery",
"description": "Run technical discovery commands for feasibility, technology selection, scenario-specific technical decisions, legacy codebase assessment, implementation understanding, and proof-of-concept validation.",
"author": "bigsmartben",
"version": "0.2.0",
"download_url": "https://github.com/bigsmartben/spec-kit-discovery/archive/refs/tags/v0.2.0.zip",
"repository": "https://github.com/bigsmartben/spec-kit-discovery",
"homepage": "https://github.com/bigsmartben/spec-kit-discovery",
"documentation": "https://github.com/bigsmartben/spec-kit-discovery/blob/main/docs/usage.md",
"changelog": "https://github.com/bigsmartben/spec-kit-discovery/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 6,
"hooks": 0
},
"tags": [
"discovery",
"workflow",
"validation",
"feasibility",
"decision"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-06-23T00:00:00Z",
"updated_at": "2026-06-23T00:00:00Z"
},
"docguard": {
"name": "DocGuard — CDD Enforcement",
"id": "docguard",
"description": "Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. One pinned runtime dependency; pure Node.js otherwise.",
"author": "raccioly",
"version": "0.27.0",
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.27.0/spec-kit-docguard-v0.27.0.zip",
"version": "0.28.0",
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.28.0/spec-kit-docguard-v0.28.0.zip",
"repository": "https://github.com/raccioly/docguard",
"homepage": "https://www.npmjs.com/package/docguard-cli",
"documentation": "https://github.com/raccioly/docguard/blob/main/extensions/spec-kit-docguard/README.md",
@@ -1043,7 +1077,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-03-13T00:00:00Z",
"updated_at": "2026-06-22T00:00:00Z"
"updated_at": "2026-06-23T00:00:00Z"
},
"doctor": {
"name": "Project Health Check",
@@ -1370,6 +1404,46 @@
"created_at": "2026-06-16T00:00:00Z",
"updated_at": "2026-06-16T00:00:00Z"
},
"intake": {
"name": "Intake",
"id": "intake",
"description": "Normalize PRD, design, and test-case evidence into SDD-ready intake artifacts.",
"author": "bigsmartben",
"version": "0.1.2",
"download_url": "https://github.com/bigsmartben/spec-kit-intake/archive/refs/tags/v0.1.2.zip",
"repository": "https://github.com/bigsmartben/spec-kit-intake",
"homepage": "https://github.com/bigsmartben/spec-kit-intake",
"documentation": "https://github.com/bigsmartben/spec-kit-intake/blob/main/README.md",
"changelog": "https://github.com/bigsmartben/spec-kit-intake/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "docs",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.8.10.dev0",
"tools": [
{
"name": "figma-mcp",
"required": false
}
]
},
"provides": {
"commands": 3,
"hooks": 1
},
"tags": [
"intake",
"sdd",
"requirements",
"validation",
"figma"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-06-23T00:00:00Z",
"updated_at": "2026-06-23T00:00:00Z"
},
"issue": {
"name": "GitHub Issues Integration 2",
"id": "issue",
@@ -2347,12 +2421,12 @@
"updated_at": "2026-03-18T00:00:00Z"
},
"preview": {
"name": "Interactive HTML Preview",
"name": "Spec Kit Preview",
"id": "preview",
"description": "Generate self-contained interactive HTML prototypes from Spec Kit artifacts",
"description": "Generate evidence-backed low, mid, or high fidelity previews from Spec Kit artifacts as Markdown or self-contained HTML",
"author": "bigsmartben",
"version": "1.0.0",
"download_url": "https://github.com/bigsmartben/spec-kit-preview/archive/refs/tags/v1.0.0.zip",
"version": "1.1.0",
"download_url": "https://github.com/bigsmartben/spec-kit-preview/archive/refs/tags/v1.1.0.zip",
"repository": "https://github.com/bigsmartben/spec-kit-preview",
"homepage": "https://github.com/bigsmartben/spec-kit-preview",
"documentation": "https://github.com/bigsmartben/spec-kit-preview/blob/main/README.md",
@@ -2364,20 +2438,21 @@
"speckit_version": ">=0.8.10.dev0"
},
"provides": {
"commands": 1,
"commands": 6,
"hooks": 0
},
"tags": [
"preview",
"prototype",
"html",
"markdown",
"ux"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-05-15T00:00:00Z",
"updated_at": "2026-05-15T00:00:00Z"
"updated_at": "2026-06-23T00:00:00Z"
},
"product": {
"name": "Product Spec Extension",

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-06-02T00:00:00Z",
"updated_at": "2026-06-23T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json",
"integrations": {
"claude": {
@@ -102,6 +102,15 @@
"repository": "https://github.com/github/spec-kit",
"tags": ["cli"]
},
"firebender": {
"id": "firebender",
"name": "Firebender",
"version": "1.0.0",
"description": "Firebender IDE integration for Android Studio / IntelliJ",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["ide"]
},
"forge": {
"id": "forge",
"name": "Forge",
@@ -246,6 +255,15 @@
"repository": "https://github.com/github/spec-kit",
"tags": ["cli"]
},
"omp": {
"id": "omp",
"name": "Oh My Pi",
"version": "1.0.0",
"description": "Oh My Pi (omp) terminal coding agent prompt-based integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["cli"]
},
"iflow": {
"id": "iflow",
"name": "iFlow CLI",

View File

@@ -185,6 +185,7 @@ Edit `presets/catalog.community.json` and add your preset.
"author": "Your Name",
"version": "1.0.0",
"download_url": "https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip",
"sha256": "OPTIONAL: SHA-256 hex digest of the archive above; verified before install",
"repository": "https://github.com/your-org/spec-kit-preset-your-preset",
"license": "MIT",
"requires": {

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.11.5"
version = "0.11.7"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
readme = "README.md"
requires-python = ">=3.11"
@@ -74,3 +74,13 @@ precision = 2
show_missing = true
skip_covered = false
[tool.ruff.lint]
# Lock in subprocess security posture: any reintroduction of shell=True
# (or os.system / popen2) must be acknowledged with an explicit `# noqa`
# pointing at the rule, making the deviation visible in review.
extend-select = [
"S602", # subprocess-popen-with-shell-equals-true
"S604", # call-with-shell-equals-true
"S605", # start-process-with-a-shell
]

View File

@@ -83,24 +83,24 @@ if ($PathsOnly) {
# Validate required directories and files
if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) {
Write-Output "ERROR: Feature directory not found: $($paths.FEATURE_DIR)"
[Console]::Error.WriteLine("ERROR: Feature directory not found: $($paths.FEATURE_DIR)")
$specifyCommand = Format-SpecKitCommand -CommandName 'specify' -RepoRoot $paths.REPO_ROOT
Write-Output "Run $specifyCommand first to create the feature structure."
[Console]::Error.WriteLine("Run $specifyCommand first to create the feature structure.")
exit 1
}
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
Write-Output "ERROR: plan.md not found in $($paths.FEATURE_DIR)"
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
$planCommand = Format-SpecKitCommand -CommandName 'plan' -RepoRoot $paths.REPO_ROOT
Write-Output "Run $planCommand first to create the implementation plan."
[Console]::Error.WriteLine("Run $planCommand first to create the implementation plan.")
exit 1
}
# Check for tasks.md if required
if ($RequireTasks -and -not (Test-Path $paths.TASKS -PathType Leaf)) {
Write-Output "ERROR: tasks.md not found in $($paths.FEATURE_DIR)"
[Console]::Error.WriteLine("ERROR: tasks.md not found in $($paths.FEATURE_DIR)")
$tasksCommand = Format-SpecKitCommand -CommandName 'tasks' -RepoRoot $paths.REPO_ROOT
Write-Output "Run $tasksCommand first to create the task list."
[Console]::Error.WriteLine("Run $tasksCommand first to create the task list.")
exit 1
}

View File

@@ -111,8 +111,11 @@ function Get-BranchName {
# Keep words that are length >= 3 OR appear as uppercase in original (likely acronyms)
if ($word.Length -ge 3) {
$meaningfulWords += $word
} elseif ($Description -match "\b$($word.ToUpper())\b") {
# Keep short words if they appear as uppercase in original (likely acronyms)
} elseif ($Description -cmatch "\b$($word.ToUpper())\b") {
# Keep short words only if they appear as uppercase in original (likely
# acronyms). Use -cmatch so the comparison is case-sensitive, matching the
# bash script's case-sensitive grep; -match would be case-insensitive and
# would keep every short word.
$meaningfulWords += $word
}
}

View File

@@ -318,6 +318,12 @@ No implementation code shall be written before:
This completely inverts traditional AI code generation. Instead of generating code and hoping it works, the LLM must first generate comprehensive tests that define behavior, get them approved, and only then generate implementation.
#### Articles IV, V & VI: Project-Defined Governance
Articles IV, V, and VI are intentionally defined by each project's constitution rather than prescribed by Spec Kit. The constitution template provides placeholder slots and example concerns such as integration testing, observability, versioning, and breaking changes, but teams replace those placeholders with the principles that match their system and organization.
This keeps the nine-article structure stable while allowing each project to encode its own non-negotiable standards. For one project, Article IV might govern security and access boundaries; for another, it might define integration test requirements. The `/speckit.analyze` command evaluates the concrete constitution in the project, so these project-defined articles participate in compliance checks just like the built-in examples.
#### Articles VII & VIII: Simplicity and Anti-Abstraction
These paired articles combat over-engineering:

View File

@@ -65,14 +65,31 @@ def dump_frontmatter(data: dict[str, Any]) -> str:
return yaml.safe_dump(data, sort_keys=False, allow_unicode=True).strip()
def run_command(cmd: list[str], check_return: bool = True, capture: bool = False, shell: bool = False) -> str | None:
"""Run a shell command and optionally capture output."""
def run_command(
cmd: list[str],
check_return: bool = True,
capture: bool = False,
shell: bool = False,
) -> str | None:
"""Run a command without invoking a shell and optionally capture output.
The ``shell`` parameter is kept in the signature so existing keyword
callers (and the re-export from ``specify_cli``) don't raise ``TypeError``,
but only the default ``shell=False`` is honoured. ``shell=True`` is
rejected with ``ValueError`` rather than silently ignored, so the
unsupported mode fails loudly instead of running with a different meaning.
"""
if shell:
raise ValueError(
"run_command() does not support shell=True; pass argv as a list"
)
try:
if capture:
result = subprocess.run(cmd, check=check_return, capture_output=True, text=True, shell=shell)
result = subprocess.run(cmd, check=check_return, capture_output=True, text=True)
return result.stdout.strip()
else:
subprocess.run(cmd, check=check_return, shell=shell)
subprocess.run(cmd, check=check_return)
return None
except subprocess.CalledProcessError as e:
if check_return:

View File

@@ -37,6 +37,8 @@ def _build_agent_configs() -> dict[str, Any]:
# when register_commands() resolves __SPECKIT_COMMAND_*__ tokens.
if "invoke_separator" not in config:
config["invoke_separator"] = integration.invoke_separator
if integration.dev_no_symlink:
config["dev_no_symlink"] = True
configs[key] = config
return configs
@@ -234,9 +236,14 @@ class CommandRegistrar:
toml_lines.append(f"# Source: {source_id}")
toml_lines.append("")
# Keep TOML output valid even when body contains triple-quote delimiters.
# Prefer multiline forms, then fall back to escaped basic string.
if '"""' not in body:
# Keep TOML output valid even when body contains triple-quote delimiters
# or backslashes. Prefer multiline forms, then fall back to escaped basic
# string. A multiline *basic* string ("""...""") processes backslash escape
# sequences, so a body containing a backslash (e.g. a Windows path
# ``C:\\Users\\...`` whose ``\\U`` reads as an invalid unicode escape) would
# produce unparseable TOML — route those to the *literal* form ('''...'''),
# which does not process escapes, or to the escaped basic string.
if '"""' not in body and "\\" not in body:
toml_lines.append('prompt = """')
toml_lines.append(body)
toml_lines.append('"""')
@@ -714,6 +721,7 @@ class CommandRegistrar:
output_name,
agent_config["extension"],
link_outputs,
agent_config,
)
if agent_name == "copilot":
@@ -788,6 +796,7 @@ class CommandRegistrar:
alias_output_name,
agent_config["extension"],
link_outputs,
agent_config,
)
if agent_name == "copilot":
self.write_copilot_prompt(project_root, alias)
@@ -804,9 +813,12 @@ class CommandRegistrar:
output_name: str,
extension: str,
link_outputs: bool,
agent_config: dict[str, Any] | None = None,
) -> None:
"""Write a rendered agent artifact, optionally as a dev-mode symlink."""
if not link_outputs:
if not link_outputs or (agent_config or {}).get("dev_no_symlink"):
if dest_file.is_symlink():
dest_file.unlink()
dest_file.write_text(content, encoding="utf-8")
return
@@ -927,6 +939,16 @@ class CommandRegistrar:
self._active_skills_agent(project_root)
if create_missing_active_skills_dir else None
)
active_skills_dir: Optional[Path] = None
if active_skills_agent:
active_skills_config = self.AGENT_CONFIGS.get(active_skills_agent)
if (
active_skills_config
and active_skills_config.get("extension") == "/SKILL.md"
):
active_skills_dir = self._resolve_agent_dir(
active_skills_agent, active_skills_config, project_root,
)
active_created_skills_dir: Optional[Path] = None
for agent_name, agent_config in self.AGENT_CONFIGS.items():
active_skills_output = (
@@ -958,6 +980,14 @@ class CommandRegistrar:
agent_dir = self._resolve_agent_dir(
agent_name, agent_config, project_root,
)
shares_active_skills_dir = (
active_skills_dir is not None
and agent_name != active_skills_agent
and agent_config.get("extension") == "/SKILL.md"
and self._same_lexical_path(agent_dir, active_skills_dir)
)
if shares_active_skills_dir:
continue
agent_dir_existed = agent_dir.is_dir()
register_missing_active_skills_agent = (

View File

@@ -31,6 +31,7 @@ from .._invocation_style import is_dollar_skills_agent, is_slash_skills_agent
from .._utils import dump_frontmatter, relative_extension_path_violation
from ..catalogs import CatalogEntry as BaseCatalogEntry
from ..catalogs import CatalogStackBase
from ..shared_infra import verify_archive_sha256
_FALLBACK_CORE_COMMAND_NAMES = frozenset(
{
@@ -997,6 +998,7 @@ class ExtensionManager:
if not isinstance(selected_ai, str) or not selected_ai:
return []
registrar = CommandRegistrar()
agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {})
integration = get_integration(selected_ai)
for cmd_info in manifest.commands:
@@ -1030,15 +1032,16 @@ class ExtensionManager:
skill_file = skill_subdir / "SKILL.md"
cache_root = extension_dir / ".specify-dev" / "extension-skills"
cache_file = cache_root / skill_name / "SKILL.md"
use_dev_symlink = link_outputs and not agent_config.get("dev_no_symlink")
CommandRegistrar._ensure_inside(cache_file, cache_root)
if skill_file.exists() or skill_file.is_symlink():
is_expected_dev_symlink = self._is_expected_dev_symlink(
skill_file, cache_file
)
# Do not overwrite user-customized skills, but allow dev-mode
# symlinks that point back to this extension's generated cache
# to be refreshed on a subsequent dev install.
if not (
link_outputs
and self._is_expected_dev_symlink(skill_file, cache_file)
):
if not is_expected_dev_symlink:
continue
# Create skill directory; track whether we created it so we can clean
@@ -1093,7 +1096,7 @@ class ExtensionManager:
):
skill_content = integration.post_process_skill_content(skill_content)
if link_outputs:
if use_dev_symlink:
try:
cache_file.parent.mkdir(parents=True, exist_ok=True)
cache_file.write_text(skill_content, encoding="utf-8")
@@ -1106,6 +1109,8 @@ class ExtensionManager:
skill_file.unlink()
skill_file.write_text(skill_content, encoding="utf-8")
else:
if skill_file.is_symlink():
skill_file.unlink()
skill_file.write_text(skill_content, encoding="utf-8")
written.append(skill_name)
@@ -2617,6 +2622,10 @@ class ExtensionCatalog(CatalogStackBase):
) as response:
zip_data = response.read()
verify_archive_sha256(
zip_data, ext_info.get("sha256"), extension_id, ExtensionError
)
zip_path.write_bytes(zip_data)
return zip_path

View File

@@ -58,6 +58,7 @@ def _register_builtins() -> None:
from .copilot import CopilotIntegration
from .cursor_agent import CursorAgentIntegration
from .devin import DevinIntegration
from .firebender import FirebenderIntegration
from .forge import ForgeIntegration
from .gemini import GeminiIntegration
from .generic import GenericIntegration
@@ -69,6 +70,7 @@ def _register_builtins() -> None:
from .kimi import KimiIntegration
from .kiro_cli import KiroCliIntegration
from .lingma import LingmaIntegration
from .omp import OmpIntegration
from .opencode import OpencodeIntegration
from .pi import PiIntegration
from .qodercli import QodercliIntegration
@@ -95,6 +97,7 @@ def _register_builtins() -> None:
_register(CopilotIntegration())
_register(CursorAgentIntegration())
_register(DevinIntegration())
_register(FirebenderIntegration())
_register(ForgeIntegration())
_register(GeminiIntegration())
_register(GenericIntegration())
@@ -106,6 +109,7 @@ def _register_builtins() -> None:
_register(KimiIntegration())
_register(KiroCliIntegration())
_register(LingmaIntegration())
_register(OmpIntegration())
_register(OpencodeIntegration())
_register(PiIntegration())
_register(QodercliIntegration())

View File

@@ -119,6 +119,9 @@ class IntegrationBase(ABC):
invoke_separator: str = "."
"""Separator used in slash-command invocations (``"."`` → ``/speckit.plan``)."""
dev_no_symlink: bool = False
"""Whether dev-mode registration should write files instead of symlinks."""
multi_install_safe: bool = False
"""Whether this integration is declared safe to install alongside others.

View File

@@ -27,6 +27,7 @@ class CodexIntegration(SkillsIntegration):
"extension": "/SKILL.md",
}
context_file = "AGENTS.md"
dev_no_symlink = True
multi_install_safe = True
def build_exec_args(

View File

@@ -0,0 +1,33 @@
"""Firebender IDE integration.
Firebender (https://firebender.com/) is an AI coding agent for Android Studio
and IntelliJ. It reads project-local custom slash commands from
``.firebender/commands/*.mdc`` and project rules from ``.firebender/rules/*.mdc``,
so Spec Kit installs its command templates as ``.mdc`` command files and writes
the managed context section into a ``.firebender/rules/`` rule file.
"""
from ..base import MarkdownIntegration
class FirebenderIntegration(MarkdownIntegration):
key = "firebender"
config = {
"name": "Firebender",
"folder": ".firebender/",
"commands_subdir": "commands",
"install_url": "https://firebender.com/",
"requires_cli": False,
}
registrar_config = {
"dir": ".firebender/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".mdc",
}
context_file = ".firebender/rules/specify-rules.mdc"
multi_install_safe = True
def command_filename(self, template_name: str) -> str:
"""Firebender reads custom slash commands from ``.firebender/commands/*.mdc``."""
return f"speckit.{template_name}.mdc"

View File

@@ -232,6 +232,30 @@ class IntegrationManifest:
# transition. ``discard`` is a no-op when the key is absent.
self._recovered_files.discard(normalized)
def remove(self, rel_path: str | Path) -> bool:
"""Drop *rel_path* from the tracked file set and any recovered marker.
Operates purely on the manifest's recorded key; it does NOT touch the
file on disk. Returns ``True`` if an entry was present and removed.
Used to keep the manifest consistent after a caller deletes a stale
managed file that the current install no longer ships.
Input is normalized through the same lexical pipeline as
``record_existing`` / ``is_recovered``: absolute paths and paths
containing ``..`` segments are rejected (return ``False``) — such paths
can never be canonical manifest keys, so there is nothing to remove.
"""
rel = Path(rel_path)
if rel.is_absolute() or ".." in rel.parts:
return False
try:
abs_path = _validate_rel_path(rel, self.project_root)
normalized = abs_path.relative_to(self.project_root).as_posix()
except ValueError:
return False
self._recovered_files.discard(normalized)
return self._files.pop(normalized, None) is not None
# -- Querying ---------------------------------------------------------
@property

View File

@@ -0,0 +1,45 @@
"""Oh My Pi (omp) coding agent integration."""
from __future__ import annotations
from ..base import MarkdownIntegration
class OmpIntegration(MarkdownIntegration):
key = "omp"
config = {
"name": "Oh My Pi",
"folder": ".omp/",
"commands_subdir": "commands",
"install_url": "https://www.npmjs.com/package/@oh-my-pi/pi-coding-agent",
"requires_cli": True,
}
registrar_config = {
"dir": ".omp/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "AGENTS.md"
def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
# Diverges from MarkdownIntegration.build_exec_args because OMP's
# CLI parser treats `-p`/`--print` as a boolean (one-shot mode) and
# consumes the prompt as a positional argument — see args.ts in
# can1357/oh-my-pi. JSON output is selected via `--mode json`.
if not self.config or not self.config.get("requires_cli"):
return None
args = [self._resolve_executable(), "--print"]
self._apply_extra_args_env_var(args)
if model:
args.extend(["--model", model])
if output_json:
args.extend(["--mode", "json"])
args.append(prompt)
return args

View File

@@ -31,6 +31,7 @@ from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priorit
from .._init_options import is_ai_skills_enabled
from ..integrations.base import IntegrationBase
from .._utils import dump_frontmatter
from ..shared_infra import verify_archive_sha256
def _substitute_core_template(
@@ -2505,6 +2506,10 @@ class PresetCatalog:
with self._open_url(download_url, timeout=60, extra_headers=extra_headers) as response:
zip_data = response.read()
verify_archive_sha256(
zip_data, pack_info.get("sha256"), pack_id, PresetError
)
zip_path.write_bytes(zip_data)
return zip_path

View File

@@ -2,6 +2,9 @@
from __future__ import annotations
import hashlib
import hmac
import logging
import os
import re
import tempfile
@@ -11,6 +14,74 @@ from typing import Any
from .integrations.base import IntegrationBase
from .integrations.manifest import IntegrationManifest
logger = logging.getLogger(__name__)
# Matches a SHA-256 digest in its normalized form: exactly 64 hexadecimal
# characters. Callers lowercase the declared value before matching (see
# ``expected_hex = raw.lower()`` below), so an uppercase digest is accepted and
# normalized rather than rejected.
_SHA256_HEX_RE = re.compile(r"^[0-9a-f]{64}$")
def verify_archive_sha256(
data: bytes,
expected: str | None,
name: str,
error_cls: type[Exception],
) -> None:
"""Verify downloaded archive bytes against a catalog-declared SHA-256.
Catalog entries may pin the expected digest of their release archive in a
``sha256`` field (optionally prefixed with ``"sha256:"``). When present, the
downloaded bytes must match before they are written to disk and installed,
so a corrupted or tampered archive is rejected even though the transport was
HTTPS. Entries without a declared digest are accepted unchanged, keeping the
check backwards compatible.
Args:
data: The raw downloaded archive bytes.
expected: The catalog-declared SHA-256 hex digest, or ``None``.
name: The extension/preset id, used in the error message.
error_cls: Exception type to raise on mismatch (e.g. ``ExtensionError``).
Raises:
error_cls: If ``expected`` is provided and is not a well-formed
SHA-256 hex digest, or does not match ``data``.
"""
# Skip only when no digest is declared at all (``None``). A declared but
# empty/blank value (e.g. ``sha256: ""``) is an authoring error, not an
# opt-out: let it fall through to the format check below so it is rejected
# rather than silently disabling verification.
if expected is None:
logger.debug(
"No sha256 declared for %r; archive integrity was not verified.",
name,
)
return
# Strip *only* a literal ``sha256:`` algorithm prefix (case-insensitive).
# Any other prefix is part of the value and must not be silently dropped,
# otherwise a malformed or wrong-algorithm digest (e.g. ``md5:...``) would
# be quietly accepted as if it were a valid SHA-256.
raw = str(expected).strip()
if raw[:7].lower() == "sha256:":
raw = raw[7:].strip()
expected_hex = raw.lower()
if not _SHA256_HEX_RE.match(expected_hex):
raise error_cls(
f"Invalid sha256 declared for {name!r}: expected 64 hexadecimal "
f"characters (optionally prefixed with 'sha256:'), got "
f"{expected!r}."
)
actual_hex = hashlib.sha256(data).hexdigest()
# Constant-time comparison: both sides are fixed-length hex digests, so use
# ``hmac.compare_digest`` to avoid leaking information through timing.
if not hmac.compare_digest(actual_hex, expected_hex):
raise error_cls(
f"Integrity check failed for {name!r}: the catalog declares "
f"sha256 {expected_hex}, but the downloaded archive is "
f"{actual_hex}. The archive may be corrupted or tampered with."
)
class SymlinkedSharedPathError(ValueError):
"""Raised when a shared infrastructure path or ancestor is a symlink.
@@ -304,7 +375,7 @@ def install_shared_infra(
customization warning to tell the user which flag would overwrite their
customizations.
"""
from .integrations.manifest import _sha256
from .integrations.manifest import _sha256, _validate_rel_path
manifest = load_speckit_manifest(project_path, version=version, console=console)
prior_hashes = dict(manifest.files)
@@ -325,6 +396,11 @@ def install_shared_infra(
symlinked_files: list[str] = []
planned_copies: list[tuple[Path, str, bytes, int]] = []
planned_templates: list[tuple[Path, str, str]] = []
# Track every shared path the current bundle produces so we can detect
# manifest entries the core no longer ships (stale-script cleanup, #3076).
seen_rels: set[str] = set()
scripts_scanned = False
variant_dir = "bash" if script_type == "sh" else "powershell"
def _decide_overwrite(rel: str, dst: Path) -> tuple[bool, str | None]:
"""Return (write, bucket) where bucket is 'skip', 'preserved', or None."""
@@ -379,7 +455,6 @@ def install_shared_infra(
if scripts_src.is_dir():
dest_scripts = project_path / ".specify" / "scripts"
if _ensure_or_bucket_dir(dest_scripts):
variant_dir = "bash" if script_type == "sh" else "powershell"
variant_src = scripts_src / variant_dir
if variant_src.is_dir():
dest_variant = dest_scripts / variant_dir
@@ -387,10 +462,18 @@ def install_shared_infra(
for src_path in variant_src.rglob("*"):
if not src_path.is_file():
continue
# Mark scanned only once a real source file is seen. An
# empty (or symlink-skipped) variant keeps this False, so
# stale-cleanup is skipped — otherwise it would treat every
# tracked script as obsolete and delete it. (The safety
# hinge is this flag, not ``seen_rels``, which also holds
# template paths populated later.)
scripts_scanned = True
rel_path = src_path.relative_to(variant_src)
dst_path = dest_variant / rel_path
rel = dst_path.relative_to(project_path).as_posix()
seen_rels.add(rel)
if not _safe_dest_or_bucket(dst_path, rel, parent_must_exist=False):
continue
write, bucket = _decide_overwrite(rel, dst_path)
@@ -442,6 +525,7 @@ def install_shared_infra(
dst = dest_templates / src.name
rel = dst.relative_to(project_path).as_posix()
seen_rels.add(rel)
if not _safe_dest_or_bucket(dst, rel):
continue
write, bucket = _decide_overwrite(rel, dst)
@@ -521,5 +605,63 @@ def install_shared_infra(
if refresh_hint:
console.print(refresh_hint)
# Remove stale managed scripts: paths a previous install recorded that the
# current core no longer ships — e.g. the legacy
# ``scripts/<variant>/update-agent-context.sh`` superseded by the bundled
# agent-context extension. Left behind, such an orphan can crash when it
# sources a refreshed ``common.sh`` (#3076). Only run when the script source
# was actually scanned (so a missing/empty source never triggers mass
# deletion), scoped to the active variant, and only for *managed* copies —
# a user-customized file (hash diverges), a symlink, or a recovered entry is
# preserved by ``_is_managed``.
if scripts_scanned:
stale_removed: list[str] = []
script_prefix = f".specify/scripts/{variant_dir}/"
for rel in list(prior_hashes):
if rel in seen_rels or not rel.startswith(script_prefix):
continue
# Guard corrupted/hand-edited manifest keys BEFORE any filesystem
# access: absolute, ``..``, or (on Windows) drive-relative keys such
# as ``C:tmp`` are not ``is_absolute()`` yet discard the project root
# when joined. The lexical check is a fast reject; ``_validate_rel_path``
# resolves the join and confirms containment, catching the rest. A key
# that still escapes is *skipped*, never turned into an install-time
# hard failure. Mirrors IntegrationManifest.is_recovered / remove.
rel_path = Path(rel)
if rel_path.is_absolute() or ".." in rel_path.parts:
continue
try:
_validate_rel_path(rel_path, project_path)
except ValueError:
continue
dst = project_path / rel_path
# Already gone from disk but still tracked: drop the orphaned manifest
# entry so the manifest stays consistent (nothing to unlink).
if not dst.exists() and not dst.is_symlink():
manifest.remove(rel)
continue
if not _is_managed(rel, dst):
continue # user-modified / symlink / recovered → preserve
# Never unlink through a symlinked ancestor (writes/deletes could
# escape the project root). The safe-destination check buckets such
# paths under ``symlinked_files`` and we leave them in place.
if not _safe_dest_or_bucket(dst, rel):
continue
try:
dst.unlink()
except OSError as exc:
console.print(f"[yellow]⚠[/yellow] could not remove stale {rel}: {exc}")
continue
manifest.remove(rel)
stale_removed.append(rel)
if stale_removed:
console.print(
f"[yellow]⚠[/yellow] Removed {len(stale_removed)} obsolete shared "
"script(s) left by a previous install:"
)
for path in stale_removed:
console.print(f" {path}")
manifest.save()
return True

View File

@@ -52,9 +52,18 @@ class WorkflowDefinition:
if not isinstance(self.default_options, dict):
self.default_options = {}
# Requirements (declared but not yet enforced at runtime;
# enforcement is a planned enhancement)
self.requires: dict[str, Any] = data.get("requires", {})
# Advisory pre-conditions (spec-kit version / integrations a workflow
# expects). Validated by ``validate_workflow`` (recognized keys only;
# see ``_RECOGNIZED_REQUIRES_KEYS``) but NOT enforced at run time — they
# are not a security boundary. In particular there is no
# ``requires.permissions`` capability gate: shell steps always run with
# the user's privileges.
#
# Holds the raw parsed value, so before ``validate_workflow`` runs it may
# be a non-mapping (``None`` for a bare ``requires:``, a list for
# ``requires: []``, etc.); typed ``Any`` rather than ``dict[str, Any]``
# to avoid implying it is always a mapping at this point.
self.requires: Any = data.get("requires", {})
# Inputs
self.inputs: dict[str, Any] = data.get("inputs", {})
@@ -87,6 +96,15 @@ class WorkflowDefinition:
# ID format: lowercase alphanumeric with hyphens
_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$")
# Keys accepted under a workflow's ``requires`` block: the advisory
# pre-conditions documented for workflows (``speckit_version`` and
# ``integrations``). This is the *workflow* schema only — the bundle manifest's
# ``requires`` (see ``bundler/models/manifest.py``) is a separate schema that
# also carries ``tools``/``mcp``; those are not workflow ``requires`` keys.
# Any other key — notably ``permissions`` — is rejected by ``validate_workflow``
# so it is never mistaken for an enforced runtime control.
_RECOGNIZED_REQUIRES_KEYS = frozenset({"speckit_version", "integrations"})
# Valid step types (matching STEP_REGISTRY keys)
def _get_valid_step_types() -> set[str]:
"""Return valid step types from the registry, with a built-in fallback."""
@@ -177,6 +195,36 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
f"Input {input_name!r} has invalid default: {exc}"
)
# -- Requires ---------------------------------------------------------
# ``requires`` declares advisory pre-conditions (the spec-kit version and
# integrations a workflow expects). Only a fixed set of keys is recognized;
# reject anything else so authoring typos surface here instead of being
# silently ignored at runtime. In particular ``requires.permissions`` is
# rejected explicitly: it reads like a runtime capability gate, but no such
# gate exists — a ``shell`` step always runs with the user's privileges, so
# declaring it would give a false sense of sandboxing.
#
# Mirror ``inputs`` validation: an omitted block defaults to ``{}`` and is
# valid, but any present-but-non-mapping value — ``requires:`` (YAML null),
# ``requires: []`` or ``requires: ''`` — is an authoring error and must
# surface here rather than be silently ignored at runtime.
if not isinstance(definition.requires, dict):
errors.append("'requires' must be a mapping (or omitted).")
else:
for key in definition.requires:
if key == "permissions":
errors.append(
"'requires.permissions' is not a recognized or "
"enforced capability gate — shell steps always run "
"with the user's privileges. Remove it and gate "
"sensitive steps with a 'gate' step instead."
)
elif key not in _RECOGNIZED_REQUIRES_KEYS:
errors.append(
f"Unknown 'requires' key {key!r}. Recognized keys: "
f"{', '.join(sorted(_RECOGNIZED_REQUIRES_KEYS))}."
)
# -- Steps ------------------------------------------------------------
if not isinstance(definition.steps, list):
errors.append("'steps' must be a list.")

View File

@@ -31,7 +31,7 @@ class ShellStep(StepBase):
# control commands; catalog-installed workflows should be reviewed
# before use (see PUBLISHING.md for security guidance).
try:
proc = subprocess.run(
proc = subprocess.run( # noqa: S602 -- intentional shell=True (see NOTE above)
run_cmd,
shell=True,
capture_output=True,

View File

@@ -263,6 +263,206 @@ class TestInitIntegrationFlag:
assert (scripts_dir / "setup-plan.sh").exists()
assert (templates_dir / "plan-template.md").exists()
def test_shared_infra_removes_stale_managed_script(self, tmp_path):
"""A managed script the core no longer ships (e.g. the legacy
update-agent-context.sh, superseded by the agent-context extension) is
removed, and the manifest stops tracking it (#3076)."""
from specify_cli import _install_shared_infra
from specify_cli.integrations.manifest import IntegrationManifest
project = tmp_path / "stale-test"
project.mkdir()
(project / ".specify").mkdir()
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
# Legacy orphan the current bundle no longer ships, recorded in the
# manifest as a managed file (hash matches on disk) — a pre-refactor install.
stale_rel = ".specify/scripts/bash/update-agent-context.sh"
(scripts_dir / "update-agent-context.sh").write_text("# legacy orphan\n", encoding="utf-8")
manifest = IntegrationManifest("speckit", project, version="test")
manifest.record_existing(stale_rel)
manifest.save()
_install_shared_infra(project, "sh", force=False)
# The orphan is gone and the manifest no longer tracks it.
assert not (scripts_dir / "update-agent-context.sh").exists()
refreshed = IntegrationManifest.load("speckit", project)
assert stale_rel not in refreshed.files
# Scripts the core DOES ship are installed and tracked.
assert (scripts_dir / "common.sh").exists()
assert ".specify/scripts/bash/common.sh" in refreshed.files
def test_shared_infra_preserves_modified_stale_script(self, tmp_path):
"""A user-modified stale script is preserved (hash diverges from the
managed baseline), never silently deleted (#3076)."""
from specify_cli import _install_shared_infra
from specify_cli.integrations.manifest import IntegrationManifest
project = tmp_path / "stale-modified"
project.mkdir()
(project / ".specify").mkdir()
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
stale = scripts_dir / "update-agent-context.sh"
stale.write_text("# original managed\n", encoding="utf-8")
manifest = IntegrationManifest("speckit", project, version="test")
manifest.record_existing(".specify/scripts/bash/update-agent-context.sh")
manifest.save()
# User customizes it after install → on-disk hash now diverges.
stale.write_text("# user customization\n", encoding="utf-8")
_install_shared_infra(project, "sh", force=False)
# Preserved: it is no longer a managed (hash-matching) copy.
assert stale.exists()
assert stale.read_text(encoding="utf-8") == "# user customization\n"
def test_shared_infra_prunes_orphan_manifest_entry_when_file_absent(self, tmp_path):
"""A stale manifest entry whose file is already gone from disk is pruned
so the manifest stays consistent, not left tracked forever (#3076 review)."""
from specify_cli import _install_shared_infra
from specify_cli.integrations.manifest import IntegrationManifest
project = tmp_path / "orphan-entry"
project.mkdir()
(project / ".specify").mkdir()
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
stale_rel = ".specify/scripts/bash/update-agent-context.sh"
stale = scripts_dir / "update-agent-context.sh"
stale.write_text("# legacy orphan\n", encoding="utf-8")
manifest = IntegrationManifest("speckit", project, version="test")
manifest.record_existing(stale_rel)
manifest.save()
# File removed out of band, but the manifest still tracks it.
stale.unlink()
_install_shared_infra(project, "sh", force=False)
refreshed = IntegrationManifest.load("speckit", project)
assert stale_rel not in refreshed.files
def test_shared_infra_empty_script_source_keeps_tracked_scripts(self, tmp_path, monkeypatch):
"""If the bundle's script source dir exists but is empty, stale-cleanup
must NOT run (no source files seen → can't tell what's obsolete): a
previously-tracked script is preserved, never mass-deleted (#3076 review)."""
from specify_cli import _install_shared_infra, shared_infra
from specify_cli.integrations.manifest import IntegrationManifest
# Point the script source at an empty ``bash/`` directory.
empty_src = tmp_path / "empty-bundle" / "scripts"
(empty_src / "bash").mkdir(parents=True)
monkeypatch.setattr(shared_infra, "shared_scripts_source", lambda **kw: empty_src)
project = tmp_path / "empty-source"
project.mkdir()
(project / ".specify").mkdir()
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
tracked_rel = ".specify/scripts/bash/common.sh"
(scripts_dir / "common.sh").write_text("# tracked\n", encoding="utf-8")
manifest = IntegrationManifest("speckit", project, version="test")
manifest.record_existing(tracked_rel)
manifest.save()
_install_shared_infra(project, "sh", force=False)
# Empty source → scripts_scanned stays False → nothing deleted.
assert (scripts_dir / "common.sh").exists()
refreshed = IntegrationManifest.load("speckit", project)
assert tracked_rel in refreshed.files
def test_shared_infra_stale_cleanup_ignores_unsafe_manifest_keys(self, tmp_path):
"""A corrupted/hand-edited manifest key with a ``..`` segment is skipped
before any filesystem access — its traversal target is never deleted
(#3076 review, containment guard)."""
import hashlib
import json
from specify_cli import _install_shared_infra
project = tmp_path / "unsafe-key"
project.mkdir()
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
manifest_dir = project / ".specify" / "integrations"
manifest_dir.mkdir(parents=True)
# A file the traversal key would resolve to (outside scripts/bash/).
victim = project / ".specify" / "scripts" / "keep-me.sh"
victim_bytes = b"# do not touch\n"
victim.write_bytes(victim_bytes)
# Hand-crafted manifest: a key under the script prefix but with a ``..``
# segment, with the *matching* hash so that — absent the containment guard
# — stale-cleanup would consider it managed and unlink the target.
traversal_key = ".specify/scripts/bash/../keep-me.sh"
(manifest_dir / "speckit.manifest.json").write_text(
json.dumps({
"integration": "speckit",
"version": "test",
"files": {traversal_key: hashlib.sha256(victim_bytes).hexdigest()},
}),
encoding="utf-8",
)
_install_shared_infra(project, "sh", force=False)
# The unsafe key was skipped; its target file is untouched.
assert victim.exists()
assert victim.read_bytes() == victim_bytes
def test_shared_infra_stale_cleanup_skips_escaping_key_without_failing(
self, tmp_path, monkeypatch
):
"""A key that passes the lexical guard but escapes containment — e.g. a
Windows drive-relative ``C:tmp`` that is not ``is_absolute()`` yet discards
the project root when joined — is skipped via ``_validate_rel_path``, never
unlinked, and never turned into an install-time hard failure (#3076 review
round 4). Simulated portably by forcing ``_validate_rel_path`` to reject the
managed key, since real drive-relative paths only escape on Windows."""
from specify_cli import _install_shared_infra
from specify_cli.integrations import manifest as manifest_mod
from specify_cli.integrations.manifest import IntegrationManifest
project = tmp_path / "escaping-key"
project.mkdir()
(project / ".specify").mkdir()
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
# A managed stale orphan that would normally be removed.
stale_rel = ".specify/scripts/bash/update-agent-context.sh"
stale = scripts_dir / "update-agent-context.sh"
stale.write_text("# legacy orphan\n", encoding="utf-8")
manifest = IntegrationManifest("speckit", project, version="test")
manifest.record_existing(stale_rel)
manifest.save()
# Force the containment check to reject this key, as it would for a
# drive-relative escape on Windows. The cleanup must skip it gracefully.
real_validate = manifest_mod._validate_rel_path
def fake_validate(rel, root):
if str(rel).endswith("update-agent-context.sh"):
raise ValueError("simulated drive-relative escape")
return real_validate(rel, root)
monkeypatch.setattr(manifest_mod, "_validate_rel_path", fake_validate)
# Must not raise (no install-time hard failure from a corrupted key).
_install_shared_infra(project, "sh", force=False)
# The escaping key was skipped, so its file is left untouched...
assert stale.exists()
assert stale.read_text(encoding="utf-8") == "# legacy orphan\n"
# ...yet the install otherwise completed: real scripts are installed.
assert (scripts_dir / "common.sh").exists()
def test_shared_infra_skip_warning_displayed(self, tmp_path, capsys):
"""Console warning is displayed when files are skipped."""
from specify_cli import _install_shared_infra

View File

@@ -0,0 +1,45 @@
"""Tests for FirebenderIntegration."""
from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest
from .test_integration_base_markdown import MarkdownIntegrationTests
class TestFirebenderIntegration(MarkdownIntegrationTests):
KEY = "firebender"
FOLDER = ".firebender/"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".firebender/commands"
CONTEXT_FILE = ".firebender/rules/specify-rules.mdc"
# Firebender reads custom slash commands from ``.firebender/commands/*.mdc``,
# so this integration uses the ``.mdc`` extension instead of the ``.md``
# default the base mixin assumes. Override the two extension-specific tests.
def test_registrar_config(self):
i = get_integration(self.KEY)
assert i.registrar_config["dir"] == self.REGISTRAR_DIR
assert i.registrar_config["format"] == "markdown"
assert i.registrar_config["args"] == "$ARGUMENTS"
assert i.registrar_config["extension"] == ".mdc"
def test_setup_creates_files(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
assert len(created) > 0
cmd_files = [f for f in created if "scripts" not in f.parts]
for f in cmd_files:
assert f.exists()
assert f.name.startswith("speckit.")
assert f.name.endswith(".mdc")
def _expected_files(self, script_variant: str) -> list[str]:
# Firebender emits ``.mdc`` command files, so remap the base mixin's
# ``.md`` expectations for files under this integration's command dir.
cmd_dir = get_integration(self.KEY).registrar_config["dir"]
prefix = cmd_dir + "/"
return sorted(
f[:-3] + ".mdc" if f.startswith(prefix) and f.endswith(".md") else f
for f in super()._expected_files(script_variant)
)

View File

@@ -0,0 +1,31 @@
"""Tests for OmpIntegration."""
from specify_cli.integrations import get_integration
from .test_integration_base_markdown import MarkdownIntegrationTests
class TestOmpIntegration(MarkdownIntegrationTests):
KEY = "omp"
FOLDER = ".omp/"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".omp/commands"
CONTEXT_FILE = "AGENTS.md"
def test_build_exec_args_uses_omp_json_mode(self):
i = get_integration(self.KEY)
args = i.build_exec_args(
"/speckit.specify Build auth",
model="gpt-5",
)
assert args == [
"omp",
"--print",
"--model",
"gpt-5",
"--mode",
"json",
"/speckit.specify Build auth",
]

View File

@@ -116,6 +116,34 @@ class TestManifestPathTraversal:
assert len(removed) == 1
assert removed[0].name == "safe.txt"
def test_remove_drops_entry_and_is_noop_second_time(self, tmp_path):
(tmp_path / "f.txt").write_text("x", encoding="utf-8")
m = IntegrationManifest("test", tmp_path)
m.record_existing("f.txt")
assert "f.txt" in m.files
assert m.remove("f.txt") is True
assert "f.txt" not in m.files
assert m.remove("f.txt") is False # already gone → no-op
def test_remove_rejects_absolute_path(self, tmp_path):
# Matches record_existing/is_recovered: an absolute key can never be a
# canonical manifest key, so remove() rejects it lexically and leaves
# the tracked entry untouched.
(tmp_path / "f.txt").write_text("x", encoding="utf-8")
m = IntegrationManifest("test", tmp_path)
m.record_existing("f.txt")
import sys
abs_input = "C:\\tmp\\f.txt" if sys.platform == "win32" else "/tmp/f.txt"
assert m.remove(abs_input) is False
assert "f.txt" in m.files
def test_remove_rejects_parent_traversal(self, tmp_path):
(tmp_path / "f.txt").write_text("x", encoding="utf-8")
m = IntegrationManifest("test", tmp_path)
m.record_existing("f.txt")
assert m.remove("../f.txt") is False
assert "f.txt" in m.files
class TestManifestCheckModified:
def test_unmodified_file(self, tmp_path):

View File

@@ -23,7 +23,7 @@ ALL_INTEGRATION_KEYS = [
# Stage 3 — standard markdown integrations
"claude", "qwen", "opencode", "junie", "kilocode", "auggie",
"roo", "rovodev", "codebuddy", "qodercli", "amp", "shai", "bob", "trae",
"pi", "iflow", "kiro-cli", "windsurf", "vibe", "cursor-agent",
"pi", "iflow", "kiro-cli", "windsurf", "vibe", "cursor-agent", "firebender",
# Stage 4 — TOML integrations
"gemini", "tabnine",
# Stage 5 — skills, generic & option-driven integrations

View File

@@ -1,15 +1,159 @@
"""Consistency checks for agent configuration across runtime surfaces."""
import re
from pathlib import Path
import yaml
from specify_cli import AGENT_CONFIG
from specify_cli.extensions import CommandRegistrar
REPO_ROOT = Path(__file__).resolve().parent.parent
ISSUE_TEMPLATE_AGENT_KEYS = [
"amp",
"agy",
"auggie",
"claude",
"cline",
"codebuddy",
"codex",
"cursor-agent",
"devin",
"firebender",
"forge",
"gemini",
"copilot",
"goose",
"hermes",
"bob",
"iflow",
"junie",
"kilocode",
"kimi",
"kiro-cli",
"lingma",
"vibe",
"omp",
"opencode",
"pi",
"qodercli",
"qwen",
"roo",
"rovodev",
"shai",
"tabnine",
"trae",
"windsurf",
"zcode",
"zed",
]
def _issue_template(path: str) -> dict:
return yaml.safe_load((REPO_ROOT / path).read_text(encoding="utf-8"))
def _body_item_by_id(template: dict, item_id: str) -> dict:
for item in template["body"]:
if item.get("id") == item_id:
return item
raise AssertionError(f"Expected issue template body item {item_id!r}")
def _dropdown_options(path: str, item_id: str) -> list[str]:
item = _body_item_by_id(_issue_template(path), item_id)
return item["attributes"]["options"]
def _normalized_markdown(text: str) -> str:
return " ".join(text.split())
def _markdown_value_containing(path: str, marker: str) -> str:
template = _issue_template(path)
normalized_marker = _normalized_markdown(marker)
for item in template["body"]:
if item.get("type") != "markdown":
continue
value = item["attributes"]["value"]
if normalized_marker in _normalized_markdown(value):
return value
raise AssertionError(f"Expected issue template markdown containing {marker!r}")
def _markdown_paragraph_containing(path: str, marker: str) -> str:
value = _markdown_value_containing(path, marker)
normalized_marker = _normalized_markdown(marker)
for paragraph in re.split(r"\n\s*\n", value):
if normalized_marker in _normalized_markdown(paragraph):
return paragraph
raise AssertionError(f"Expected issue template paragraph containing {marker!r}")
def _supported_agent_names_from_agent_request_template() -> list[str]:
marker = "**Currently supported agents**:"
paragraph = _markdown_paragraph_containing(
".github/ISSUE_TEMPLATE/agent_request.yml",
marker,
)
supported_agents_text = _normalized_markdown(paragraph).split(marker, 1)[1].strip()
return [agent.strip() for agent in supported_agents_text.split(",")]
class TestAgentConfigConsistency:
"""Ensure kiro-cli migration stays synchronized across key surfaces."""
"""Ensure agent configuration stays synchronized across key surfaces."""
def test_issue_template_agent_lists_match_runtime_integrations(self):
"""GitHub issue templates should list all concrete built-in agents."""
concrete_agent_keys = set(AGENT_CONFIG) - {"generic"}
issue_template_agent_keys = set(ISSUE_TEMPLATE_AGENT_KEYS)
missing_agent_keys = sorted(concrete_agent_keys - issue_template_agent_keys)
unexpected_agent_keys = sorted(issue_template_agent_keys - concrete_agent_keys)
duplicate_agent_keys = sorted(
key
for key in issue_template_agent_keys
if ISSUE_TEMPLATE_AGENT_KEYS.count(key) > 1
)
assert not missing_agent_keys, (
"Issue template agent list is missing AGENT_CONFIG keys: "
f"{missing_agent_keys}"
)
assert not unexpected_agent_keys, (
"Issue template agent list includes unknown AGENT_CONFIG keys: "
f"{unexpected_agent_keys}"
)
assert not duplicate_agent_keys, (
"Issue template agent list contains duplicate keys: "
f"{duplicate_agent_keys}"
)
issue_template_agent_names = [
AGENT_CONFIG[key]["name"] for key in ISSUE_TEMPLATE_AGENT_KEYS
]
assert "Generic (bring your own agent)" not in issue_template_agent_names
bug_options = _dropdown_options(
".github/ISSUE_TEMPLATE/bug_report.yml",
"ai-agent",
)
assert bug_options == issue_template_agent_names + ["Not applicable"]
feature_options = _dropdown_options(
".github/ISSUE_TEMPLATE/feature_request.yml",
"ai-agent",
)
assert feature_options == [
"All agents",
*issue_template_agent_names,
"Not applicable",
]
assert (
_supported_agent_names_from_agent_request_template()
== issue_template_agent_names
)
def test_runtime_config_uses_kiro_cli_and_removes_q(self):
"""AGENT_CONFIG should include kiro-cli and exclude legacy q."""
@@ -217,6 +361,12 @@ class TestAgentConfigConsistency:
"expected '-' (propagated from SkillsIntegration.invoke_separator)"
)
def test_codex_dev_no_symlink_policy_in_agent_config(self):
"""Codex dev installs must expose the no-symlink policy as metadata."""
cfg = CommandRegistrar.AGENT_CONFIGS
assert cfg["codex"].get("dev_no_symlink") is True
def test_skills_agent_command_token_resolves_with_hyphen(self, tmp_path):
"""__SPECKIT_COMMAND_*__ tokens in extension commands resolve to /speckit-<cmd>
when registered for a skills-based agent (e.g. claude).

View File

@@ -143,7 +143,11 @@ def test_paths_only_text_mode_on_non_spec_branch(prereq_repo: Path) -> None:
@requires_bash
def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
"""Without --paths-only, feature directory validation must still fail on main."""
"""Without --paths-only, feature directory validation must still fail on main.
The error must go to stderr and stdout must stay clean, so a caller that
parses stdout as JSON is not handed the error string instead (#3122).
"""
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
result = subprocess.run(
["bash", str(script), "--json"],
@@ -155,6 +159,8 @@ def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
)
assert result.returncode != 0
assert "Feature directory not found" in result.stderr
assert "Feature directory not found" not in result.stdout
assert result.stdout.strip() == ""
# ── PowerShell tests ──────────────────────────────────────────────────────
@@ -213,7 +219,11 @@ def test_ps_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_ps_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
"""Without -PathsOnly, feature directory validation must still fail on main."""
"""Without -PathsOnly, feature directory validation must still fail on main.
The error must land on stderr only, leaving stdout clean for -Json
callers that parse it as JSON (#3122).
"""
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
result = subprocess.run(
@@ -225,5 +235,51 @@ def test_ps_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
env=_clean_env(),
)
assert result.returncode != 0
combined = result.stdout + result.stderr
assert "Feature directory not found" in combined
assert "Feature directory not found" in result.stderr
assert "Feature directory not found" not in result.stdout
assert result.stdout.strip() == ""
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_ps_missing_plan_error_goes_to_stderr(prereq_repo: Path) -> None:
"""A missing plan.md must report on stderr, not stdout (#3122)."""
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
_write_feature_json(prereq_repo)
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=prereq_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode != 0
assert "plan.md not found" in result.stderr
assert "plan.md not found" not in result.stdout
assert result.stdout.strip() == ""
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_ps_missing_tasks_error_goes_to_stderr(prereq_repo: Path) -> None:
"""With -RequireTasks, a missing tasks.md must report on stderr only (#3122)."""
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
_write_feature_json(prereq_repo)
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json", "-RequireTasks"],
cwd=prereq_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode != 0
assert "tasks.md not found" in result.stderr
assert "tasks.md not found" not in result.stdout
assert result.stdout.strip() == ""

View File

@@ -573,6 +573,84 @@ class TestExtensionSkillRegistration:
assert "speckit-test-ext-hello" in written
assert "Run this updated hello." in skill_file.read_text(encoding="utf-8")
def test_codex_dev_skill_registration_replaces_existing_dev_symlink(
self, project_dir, extension_dir, temp_dir
):
"""Codex dev skill registration should migrate prior dev symlinks to files."""
if not _can_create_symlink(temp_dir):
pytest.skip("Current platform/user cannot create symlinks")
_create_init_options(project_dir, ai="codex", ai_skills=True)
skills_dir = _create_skills_dir(project_dir, ai="codex")
manager = ExtensionManager(project_dir)
manifest = ExtensionManifest(extension_dir / "extension.yml")
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
skill_file.parent.mkdir(parents=True, exist_ok=True)
cache_file = (
extension_dir
/ ".specify-dev"
/ "extension-skills"
/ "speckit-test-ext-hello"
/ "SKILL.md"
)
cache_file.parent.mkdir(parents=True)
cache_file.write_text("old linked content", encoding="utf-8")
os.symlink(os.path.relpath(cache_file, skill_file.parent), skill_file)
written = manager._register_extension_skills(
manifest,
extension_dir,
link_outputs=True,
)
assert "speckit-test-ext-hello" in written
assert skill_file.exists()
assert not skill_file.is_symlink()
assert "Run this to say hello." in skill_file.read_text(encoding="utf-8")
assert cache_file.read_text(encoding="utf-8") == "old linked content"
def test_codex_dev_skill_registration_preserves_unrelated_symlink(
self, project_dir, extension_dir, temp_dir
):
"""Codex dev registration should not overwrite user-owned symlinks."""
if not _can_create_symlink(temp_dir):
pytest.skip("Current platform/user cannot create symlinks")
_create_init_options(project_dir, ai="codex", ai_skills=True)
skills_dir = _create_skills_dir(project_dir, ai="codex")
manager = ExtensionManager(project_dir)
manifest = ExtensionManifest(extension_dir / "extension.yml")
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
skill_file.parent.mkdir(parents=True, exist_ok=True)
unrelated_cache_file = (
temp_dir
/ "other-extension"
/ ".specify-dev"
/ "extension-skills"
/ "speckit-test-ext-hello"
/ "SKILL.md"
)
unrelated_cache_file.parent.mkdir(parents=True)
unrelated_cache_file.write_text("user-owned linked content", encoding="utf-8")
os.symlink(
os.path.relpath(unrelated_cache_file, skill_file.parent), skill_file
)
written = manager._register_extension_skills(
manifest,
extension_dir,
link_outputs=True,
)
assert "speckit-test-ext-hello" not in written
assert skill_file.is_symlink()
assert skill_file.resolve(strict=True) == unrelated_cache_file.resolve()
assert unrelated_cache_file.read_text(encoding="utf-8") == (
"user-owned linked content"
)
def test_dev_skill_registration_falls_back_to_copy_when_symlink_fails(
self, skills_project, extension_dir, monkeypatch
):

View File

@@ -1669,6 +1669,47 @@ $ARGUMENTS
assert parsed["description"] == "first line\nsecond line\n"
def test_render_toml_command_preserves_backslashes_in_body(self):
"""A backslash in the body (e.g. a Windows path) must not break TOML.
A multiline basic string ("\"\"\"") processes backslash escapes, so
``C:\\Users`` (``\\U``) would render as invalid TOML; the body must
round-trip with backslashes intact.
"""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
registrar = AgentCommandRegistrar()
output = registrar.render_toml_command(
{"description": "x"},
r"Run C:\Users\dev\tool.exe then report.",
"extension:test-ext",
)
parsed = tomllib.loads(output) # must not raise
assert parsed["prompt"].strip() == r"Run C:\Users\dev\tool.exe then report."
def test_render_toml_command_handles_trailing_backslash(self):
"""A body ending in a backslash must round-trip without corruption."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
registrar = AgentCommandRegistrar()
output = registrar.render_toml_command(
{"description": "x"},
"path ends with sep\\",
"extension:test-ext",
)
parsed = tomllib.loads(output)
assert parsed["prompt"].strip() == "path ends with sep\\"
def test_render_toml_command_backslash_with_both_triple_quotes_escapes(self):
"""Body with a backslash and both triple-quote styles → escaped basic string."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
registrar = AgentCommandRegistrar()
body = "a \\ b\nc \"\"\" d\ne ''' f"
output = registrar.render_toml_command({"description": "x"}, body, "extension:test-ext")
parsed = tomllib.loads(output)
assert parsed["prompt"] == body
def test_register_commands_for_claude(self, extension_dir, project_dir):
"""Test registering commands for Claude agent."""
# Create .claude directory
@@ -2248,6 +2289,50 @@ Run {SCRIPT}
assert target.is_file()
assert "Extension: test-ext" in cmd_file.read_text(encoding="utf-8")
def test_dev_register_commands_replaces_codex_dev_symlink(
self, extension_dir, project_dir, temp_dir
):
"""Codex dev registration should replace prior symlinks with real files."""
if not can_create_symlink(temp_dir):
pytest.skip("Current platform/user cannot create symlinks")
skill_file = (
project_dir
/ ".agents"
/ "skills"
/ "speckit-test-ext-hello"
/ "SKILL.md"
)
skill_file.parent.mkdir(parents=True)
cache_file = (
extension_dir
/ ".specify-dev"
/ "agent-commands"
/ "codex"
/ "speckit-test-ext-hello"
/ "SKILL.md"
)
cache_file.parent.mkdir(parents=True)
cache_file.write_text("old linked content", encoding="utf-8")
os.symlink(os.path.relpath(cache_file, skill_file.parent), skill_file)
manifest = ExtensionManifest(extension_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_agent(
"codex",
manifest,
extension_dir,
project_dir,
link_outputs=True,
)
assert skill_file.exists()
assert not skill_file.is_symlink()
assert "name: speckit-test-ext-hello" in skill_file.read_text(
encoding="utf-8"
)
assert cache_file.read_text(encoding="utf-8") == "old linked content"
def test_dev_register_commands_falls_back_to_copy_when_symlink_fails(
self, extension_dir, project_dir, monkeypatch
):
@@ -3716,6 +3801,89 @@ class TestExtensionCatalog:
assert captured[1].get_header("Authorization") == "Bearer ghp_testtoken"
assert captured[1].get_header("Accept") == "application/octet-stream"
def _make_zip_bytes(self):
"""Build a minimal valid extension ZIP in memory for download tests."""
import zipfile
import io
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("extension.yml", "id: test-ext\nname: Test\nversion: 1.0.0\n")
return buf.getvalue()
def _mock_response(self, data):
"""Build a context-manager mock HTTP response returning ``data``."""
from unittest.mock import MagicMock
resp = MagicMock()
resp.read.return_value = data
# Configure the context-manager protocol explicitly so `with resp`
# yields `resp` itself, independent of how the protocol is invoked.
resp.__enter__.return_value = resp
resp.__exit__.return_value = False
return resp
def test_download_extension_accepts_matching_sha256(self, temp_dir):
"""A catalog ``sha256`` that matches the archive is accepted."""
import hashlib
from unittest.mock import patch
catalog = self._make_catalog(temp_dir)
zip_bytes = self._make_zip_bytes()
ext_info = {
"id": "test-ext",
"name": "Test Extension",
"version": "1.0.0",
"download_url": "https://example.com/test-ext.zip",
"sha256": hashlib.sha256(zip_bytes).hexdigest(),
}
with patch.object(catalog, "get_extension_info", return_value=ext_info), \
patch.object(catalog, "_open_url", return_value=self._mock_response(zip_bytes)):
zip_path = catalog.download_extension("test-ext", target_dir=temp_dir)
assert zip_path.read_bytes() == zip_bytes
def test_download_extension_rejects_sha256_mismatch(self, temp_dir):
"""A catalog ``sha256`` that does not match the downloaded archive
aborts the install — a tampered or swapped archive is rejected.
"""
from unittest.mock import patch
catalog = self._make_catalog(temp_dir)
zip_bytes = self._make_zip_bytes()
ext_info = {
"id": "test-ext",
"name": "Test Extension",
"version": "1.0.0",
"download_url": "https://example.com/test-ext.zip",
"sha256": "0" * 64, # deliberately wrong
}
with patch.object(catalog, "get_extension_info", return_value=ext_info), \
patch.object(catalog, "_open_url", return_value=self._mock_response(zip_bytes)):
with pytest.raises(ExtensionError, match="[Ii]ntegrity"):
catalog.download_extension("test-ext", target_dir=temp_dir)
def test_download_extension_without_sha256_still_succeeds(self, temp_dir):
"""Entries without ``sha256`` keep working (backwards compatible)."""
from unittest.mock import patch
catalog = self._make_catalog(temp_dir)
zip_bytes = self._make_zip_bytes()
ext_info = {
"id": "test-ext",
"name": "Test Extension",
"version": "1.0.0",
"download_url": "https://example.com/test-ext.zip",
}
with patch.object(catalog, "get_extension_info", return_value=ext_info), \
patch.object(catalog, "_open_url", return_value=self._mock_response(zip_bytes)):
zip_path = catalog.download_extension("test-ext", target_dir=temp_dir)
assert zip_path.read_bytes() == zip_bytes
def test_download_extension_accepts_direct_github_rest_asset_url(self, temp_dir, monkeypatch):
"""download_extension can use a GitHub REST release asset URL directly."""
from unittest.mock import patch, MagicMock
@@ -4874,6 +5042,93 @@ class TestExtensionAddCLI:
else:
assert not agent_file.is_symlink()
def test_add_dev_writes_codex_skills_as_files(self, extension_dir, project_dir):
"""Codex dev skills should be written as files so Codex can load them."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
init_options = project_dir / ".specify" / "init-options.json"
init_options.write_text(
json.dumps({"ai": "codex", "ai_skills": True}), encoding="utf-8"
)
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app,
["extension", "add", str(extension_dir), "--dev"],
catch_exceptions=True,
)
assert result.exit_code == 0, result.output
skill_file = (
project_dir
/ ".agents"
/ "skills"
/ "speckit-test-ext-hello"
/ "SKILL.md"
)
assert skill_file.exists()
assert not skill_file.is_symlink()
content = skill_file.read_text(encoding="utf-8")
assert "name: speckit-test-ext-hello" in content
assert "metadata:" in content
assert "source: test-ext:commands/hello.md" in content
def test_add_dev_replaces_existing_codex_skill_symlink(
self, extension_dir, project_dir, temp_dir
):
"""Codex dev installs should migrate expected dev symlinks to files."""
if not can_create_symlink(temp_dir):
pytest.skip("Current platform/user cannot create symlinks")
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
init_options = project_dir / ".specify" / "init-options.json"
init_options.write_text(
json.dumps({"ai": "codex", "ai_skills": True}), encoding="utf-8"
)
skill_file = (
project_dir
/ ".agents"
/ "skills"
/ "speckit-test-ext-hello"
/ "SKILL.md"
)
skill_file.parent.mkdir(parents=True)
cache_file = (
extension_dir
/ ".specify-dev"
/ "extension-skills"
/ "speckit-test-ext-hello"
/ "SKILL.md"
)
cache_file.parent.mkdir(parents=True)
cache_file.write_text("old linked content", encoding="utf-8")
os.symlink(os.path.relpath(cache_file, skill_file.parent), skill_file)
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app,
["extension", "add", str(extension_dir), "--dev"],
catch_exceptions=True,
)
assert result.exit_code == 0, result.output
assert skill_file.exists()
assert not skill_file.is_symlink()
content = skill_file.read_text(encoding="utf-8")
assert "name: speckit-test-ext-hello" in content
assert "source: test-ext:commands/hello.md" in content
assert cache_file.read_text(encoding="utf-8") == "old linked content"
def test_add_dev_falls_back_to_copy_when_windows_symlinks_unavailable(
self, extension_dir, project_dir, monkeypatch
):

View File

@@ -2019,6 +2019,90 @@ class TestPresetCatalog:
assert captured[1].get_header("Authorization") == "Bearer ghp_testtoken"
assert captured[1].get_header("Accept") == "application/octet-stream"
def _pack_zip_and_response(self):
"""Build a minimal preset ZIP and a context-manager mock response."""
from unittest.mock import MagicMock
import io
zip_buf = io.BytesIO()
with zipfile.ZipFile(zip_buf, "w") as zf:
zf.writestr("preset.yml", "id: test-pack\nname: Test\nversion: 1.0.0\n")
zip_bytes = zip_buf.getvalue()
resp = MagicMock()
resp.read.return_value = zip_bytes
# Configure the context-manager protocol explicitly so `with resp`
# yields `resp` itself, independent of how the protocol is invoked.
resp.__enter__.return_value = resp
resp.__exit__.return_value = False
return zip_bytes, resp
def test_download_pack_accepts_matching_sha256(self, project_dir):
"""A catalog ``sha256`` that matches the preset archive is accepted."""
import hashlib
from unittest.mock import patch
catalog = PresetCatalog(project_dir)
zip_bytes, resp = self._pack_zip_and_response()
pack_info = {
"id": "test-pack",
"name": "Test Pack",
"version": "1.0.0",
"download_url": "https://example.com/test-pack.zip",
"sha256": hashlib.sha256(zip_bytes).hexdigest(),
"_install_allowed": True,
}
with patch.object(catalog, "get_pack_info", return_value=pack_info), \
patch.object(catalog, "_open_url", return_value=resp):
zip_path = catalog.download_pack("test-pack", target_dir=project_dir)
assert zip_path.read_bytes() == zip_bytes
def test_download_pack_rejects_sha256_mismatch(self, project_dir):
"""A catalog ``sha256`` that does not match the archive aborts install."""
from unittest.mock import patch
catalog = PresetCatalog(project_dir)
_zip_bytes, resp = self._pack_zip_and_response()
pack_info = {
"id": "test-pack",
"name": "Test Pack",
"version": "1.0.0",
"download_url": "https://example.com/test-pack.zip",
"sha256": "0" * 64, # deliberately wrong
"_install_allowed": True,
}
with patch.object(catalog, "get_pack_info", return_value=pack_info), \
patch.object(catalog, "_open_url", return_value=resp):
with pytest.raises(PresetError, match="[Ii]ntegrity"):
catalog.download_pack("test-pack", target_dir=project_dir)
def test_download_pack_without_sha256_skips_verification(self, project_dir):
"""A catalog entry with no ``sha256`` keeps working: verification is
opt-in, so the backwards-compatible path (``pack_info.get("sha256")``
is ``None``) must download without aborting — mirrors the extensions
coverage so the helper never silently becomes mandatory for presets.
"""
from unittest.mock import patch
catalog = PresetCatalog(project_dir)
zip_bytes, resp = self._pack_zip_and_response()
pack_info = {
"id": "test-pack",
"name": "Test Pack",
"version": "1.0.0",
"download_url": "https://example.com/test-pack.zip",
"_install_allowed": True,
}
with patch.object(catalog, "get_pack_info", return_value=pack_info), \
patch.object(catalog, "_open_url", return_value=resp):
zip_path = catalog.download_pack("test-pack", target_dir=project_dir)
assert zip_path.read_bytes() == zip_bytes
def test_download_pack_accepts_direct_github_rest_asset_url(self, project_dir, monkeypatch):
"""download_pack can use a GitHub REST release asset URL directly."""
from unittest.mock import patch, MagicMock

View File

@@ -0,0 +1,101 @@
"""Unit tests for the shared archive-integrity helper.
These exercise ``verify_archive_sha256`` directly (independently of the
extension/preset download paths that call it) so the digest-matching,
mismatch, normalisation and "no digest declared" behaviours are pinned in
one place.
"""
from __future__ import annotations
import hashlib
import logging
import pytest
from specify_cli.shared_infra import verify_archive_sha256
class _BoomError(Exception):
"""Sentinel error type used to assert the helper raises ``error_cls``."""
def test_matching_digest_passes():
"""A digest that matches the data returns without raising."""
data = b"hello-archive"
digest = hashlib.sha256(data).hexdigest()
verify_archive_sha256(data, digest, "thing", _BoomError)
def test_mismatch_raises_error_cls():
"""A non-matching digest raises the caller-supplied error type."""
with pytest.raises(_BoomError, match="[Ii]ntegrity"):
verify_archive_sha256(b"data", "0" * 64, "thing", _BoomError)
def test_sha256_prefix_is_accepted():
"""A ``sha256:`` prefix on the expected digest is tolerated."""
data = b"prefixed"
digest = hashlib.sha256(data).hexdigest()
verify_archive_sha256(data, f"sha256:{digest}", "thing", _BoomError)
def test_comparison_is_case_insensitive():
"""An upper-cased expected digest still matches the lower-case actual."""
data = b"casing"
digest = hashlib.sha256(data).hexdigest().upper()
verify_archive_sha256(data, digest, "thing", _BoomError)
def test_malformed_digest_is_rejected():
"""A declared digest that is not 64 hex chars is rejected up front.
A too-short, too-long, or non-hex value is an authoring/catalog error and
must surface clearly instead of being treated as a digest that simply does
not match the archive.
"""
for bad in ("deadbeef", "z" * 64, "0" * 63, "0" * 65):
with pytest.raises(_BoomError, match="[Ii]nvalid sha256"):
verify_archive_sha256(b"data", bad, "thing", _BoomError)
def test_non_sha256_prefix_is_not_silently_stripped():
"""Only a literal ``sha256:`` prefix is stripped.
A different algorithm prefix (e.g. ``md5:``) must not be silently dropped
and accepted as if the remaining characters were a valid SHA-256 digest;
the value is rejected as malformed.
"""
data = b"prefixed"
digest = hashlib.sha256(data).hexdigest()
with pytest.raises(_BoomError, match="[Ii]nvalid sha256"):
verify_archive_sha256(data, f"md5:{digest}", "thing", _BoomError)
def test_absent_digest_skips_and_logs_debug(caplog):
"""When no digest is declared the helper returns and logs at DEBUG.
Installs stay backwards compatible (no error, no user-facing warning),
but the unverified download leaves an audit trail for operators who opt
into debug logging.
"""
with caplog.at_level(logging.DEBUG, logger="specify_cli.shared_infra"):
verify_archive_sha256(b"data", None, "thing", _BoomError)
assert any(
"not verified" in r.getMessage() and "thing" in r.getMessage()
for r in caplog.records
)
def test_blank_declared_digest_is_rejected():
"""A present-but-empty ``sha256`` is an authoring error, not an opt-out.
Catalog entries reach the helper via ``...get("sha256")``; a blank value
(``""``, whitespace, or a bare ``sha256:`` prefix) means the digest was
declared but left empty. It must surface as a malformed digest rather than
silently disabling the integrity check, which a bare ``if not expected``
guard would have done.
"""
for blank in ("", " ", "sha256:"):
with pytest.raises(_BoomError, match="[Ii]nvalid sha256"):
verify_archive_sha256(b"data", blank, "thing", _BoomError)

View File

@@ -869,6 +869,52 @@ class TestPowerShellDryRun:
assert "DRY_RUN" not in data, f"DRY_RUN should not be in normal JSON: {data}"
# ── Short-Word / Acronym Branch-Name Tests ──────────────────────────────────
def _branch_from_output(stdout: str) -> str | None:
for line in stdout.splitlines():
if line.startswith("BRANCH_NAME:"):
return line.split(":", 1)[1].strip()
return None
SHORT_WORD_CASES = [
# description, expected branch — "go" (lowercase short word) is dropped,
# "AI" (uppercase short word / acronym) is kept, "now" (>=3 chars) is kept.
("go AI now", "001-ai-now"),
# A short word that is lowercase everywhere is dropped entirely.
("go to the pub", "001-pub"),
]
@requires_bash
class TestShortWordRetentionBash:
"""A short word is kept only when it appears in uppercase (an acronym)."""
@pytest.mark.parametrize("description,expected", SHORT_WORD_CASES)
def test_short_word_retention(self, git_repo: Path, description: str, expected: str):
result = run_script(git_repo, "--dry-run", description)
assert result.returncode == 0, result.stderr
assert _branch_from_output(result.stdout) == expected
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not available")
class TestShortWordRetentionPowerShell:
"""PowerShell must match bash: a short word is kept only when uppercase.
Regression guard for the `-match` (case-insensitive) vs `-cmatch`
(case-sensitive) divergence — with `-match`, every short non-stop word
leaked into the branch name even when it was lowercase.
"""
@pytest.mark.parametrize("description,expected", SHORT_WORD_CASES)
def test_short_word_retention(self, ps_git_repo: Path, description: str, expected: str):
result = run_ps_script(ps_git_repo, "-DryRun", description)
assert result.returncode == 0, result.stderr
assert _branch_from_output(result.stdout) == expected
# ── GIT_BRANCH_NAME Override Tests ──────────────────────────────────────────

15
tests/test_utils.py Normal file
View File

@@ -0,0 +1,15 @@
"""Tests for specify_cli._utils.run_command."""
from __future__ import annotations
import inspect
import pytest
from specify_cli import run_command
def test_run_command_rejects_shell_execution_compatibly():
assert inspect.signature(run_command).parameters["shell"].default is False
with pytest.raises(ValueError, match="does not support shell=True"):
run_command(["echo", "blocked"], shell=True) # noqa: S604

View File

@@ -2115,6 +2115,148 @@ steps:
errors = validate_workflow(definition)
assert any("invalid type" in e.lower() for e in errors)
def test_requires_with_recognized_keys_is_valid(self):
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
definition = WorkflowDefinition.from_string("""
workflow:
id: "test"
name: "Test"
version: "1.0.0"
requires:
speckit_version: ">=0.7.2"
integrations:
any: ["claude", "gemini"]
steps:
- id: step-one
command: speckit.specify
""")
errors = validate_workflow(definition)
assert errors == []
def test_requires_must_be_mapping(self):
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
definition = WorkflowDefinition.from_string("""
workflow:
id: "test"
name: "Test"
version: "1.0.0"
requires: "claude"
steps:
- id: step-one
command: speckit.specify
""")
errors = validate_workflow(definition)
assert any("'requires' must be a mapping" in e for e in errors)
def test_requires_unknown_key_is_rejected(self):
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
definition = WorkflowDefinition.from_string("""
workflow:
id: "test"
name: "Test"
version: "1.0.0"
requires:
speckit_version: ">=0.7.2"
typo_key: true
steps:
- id: step-one
command: speckit.specify
""")
errors = validate_workflow(definition)
assert any("typo_key" in e and "requires" in e for e in errors)
def test_requires_permissions_is_rejected_as_not_enforced(self):
"""A `requires.permissions` block looks like a runtime capability gate
but no such gate exists — shell steps always run with the user's
privileges. Reject it explicitly so authors are not misled into
believing the declaration sandboxes execution.
"""
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
definition = WorkflowDefinition.from_string("""
workflow:
id: "test"
name: "Test"
version: "1.0.0"
requires:
permissions:
shell: true
steps:
- id: run
type: shell
run: "echo hi"
""")
errors = validate_workflow(definition)
# Assert on specific markers from the intended message (the offending
# key and the `gate` remediation) so the test fails if the validation
# path or wording drifts, rather than passing on any error that merely
# happens to contain "permissions" and "not".
assert any("requires.permissions" in e and "gate" in e for e in errors)
def test_requires_empty_sequence_is_rejected_as_non_mapping(self):
"""A non-mapping ``requires`` (e.g. an empty list) is an authoring
error. Mirroring ``inputs``, validation checks ``isinstance(..., dict)``
so ``requires: []`` surfaces instead of silently passing.
"""
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
definition = WorkflowDefinition.from_string("""
workflow:
id: "test"
name: "Test"
version: "1.0.0"
requires: []
steps:
- id: step-one
command: speckit.specify
""")
errors = validate_workflow(definition)
assert any("'requires' must be a mapping" in e for e in errors)
def test_requires_yaml_null_is_rejected_as_non_mapping(self):
"""A bare ``requires:`` parses as YAML null. Like ``inputs``, a present
block must be a mapping, so YAML null is rejected as an authoring error
rather than being silently treated as an omitted block. (A truly
omitted ``requires`` defaults to ``{}`` and stays valid.)
"""
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
definition = WorkflowDefinition.from_string("""
workflow:
id: "test"
name: "Test"
version: "1.0.0"
requires:
steps:
- id: step-one
command: speckit.specify
""")
errors = validate_workflow(definition)
assert any("'requires' must be a mapping" in e for e in errors)
def test_requires_omitted_is_valid(self):
"""A workflow with no ``requires`` block at all defaults to ``{}`` and
must validate cleanly — only a present-but-non-mapping value is an
error (guards against over-correcting YAML-null rejection into also
flagging the omitted case).
"""
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
definition = WorkflowDefinition.from_string("""
workflow:
id: "test"
name: "Test"
version: "1.0.0"
steps:
- id: step-one
command: speckit.specify
""")
errors = validate_workflow(definition)
assert not any("requires" in e for e in errors)
# ===== Workflow Engine Tests =====

View File

@@ -268,10 +268,22 @@ When releasing a new version:
### Shell Steps
- **Shell runs with the user's privileges** — a `shell` step executes a local command directly; there is no capability sandbox. `requires` is an advisory pre-condition block (recognised keys: `speckit_version`, `integrations`), **not** a runtime permission gate — there is no `requires.permissions`. Gate sensitive commands explicitly with a `gate` step.
- **Avoid destructive commands** — don't delete files or directories without explicit confirmation via a gate
- **Quote variables** — use proper quoting in shell commands to handle spaces
- **Check exit codes** — shell step failures stop the workflow; make sure commands are robust
#### Security: shell steps execute arbitrary code
Workflow `shell` steps execute their `run` field through `/bin/sh` (POSIX) or the platform shell. There is no sandbox between the step and the user's machine: a malicious or buggy `run` block can read environment variables, modify files outside the project, exfiltrate data, or escalate privileges.
Catalog-listed workflows are reviewed at submission time (see [Verification Process](#verification-process)), but you should still treat every install as code-execution from an untrusted source until you have read the `workflow.yml`:
- **Before installing a workflow**, fetch the raw YAML and audit every `shell` step's `run` field directly. `specify workflow info <name>` only shows metadata (name, version, inputs, step IDs/types) — not the shell content that would actually execute.
- **Prefer explicit commands over interpolation** in `run` blocks: `{{ inputs.something }}` substitutions should be quoted and constrained via `enum` so a malicious input can't inject shell syntax.
- **Limit privilege**: shell steps inherit the user's environment. Workflows that need elevated access (sudo, secrets, GitHub tokens) should call them out explicitly in the README so reviewers can spot the requirement.
- **Authors**: if your workflow has shell steps that look risky out of context (deletions, network calls, credential reads), document the rationale in your README. Maintainers will reject submissions whose shell steps can't be justified at review time.
### Integration Flexibility
- **Set `integration` at workflow level** — use the `workflow.integration` field as the default