Compare commits

..

11 Commits

Author SHA1 Message Date
github-actions[bot]
284e9a6a80 chore: bump version to 0.12.17 2026-07-16 12:27:27 +00:00
WOLIKIMCHENG
4fed84a08d fix(extensions): resolve __SPECKIT_COMMAND tokens in auto-registered skills (#3544)
* fix(extensions): resolve command ref tokens in extension skills

* fix(extensions): render skill command refs by invocation style

Resolve extension skill command-reference tokens with the active skill invocation style so Codex and ZCode use $speckit-* while slash-style agents keep their native forms. Preserve literal command-looking text.

* fix(extensions): resolve slash skill command refs from init options

---------

Co-authored-by: root <kinsonnee@gmail.com>
2026-07-16 07:21:14 -05:00
Noor ul ain
459f483f57 fix(workflows): fail if/switch steps on non-list branch instead of crashing (#3515)
* fix(workflows): fail if/switch steps on non-list branch instead of crashing

`IfThenStep.validate()` and `SwitchStep.validate()` already reject a
non-list branch (`then`/`else`, and `case`/`default`), but the engine's
`execute()` path does not auto-validate (see
`WorkflowEngine.load_workflow`, whose docstring notes the definition is
"not yet validated"). On an unvalidated run, the selected branch is fed
straight into `next_steps`, which `_execute_steps` iterates as step
mappings. A non-list branch — a single mapping or scalar authoring
mistake — was iterated element-wise (a dict yields its string keys, a
str its characters) and raised `AttributeError` on `.get()`, taking down
the whole run; the engine invokes `step_impl.execute()` with no
surrounding try/except.

Guard both `execute` paths to return a FAILED StepResult naming the type
error instead, mirroring the switch non-mapping `cases` and fan-out
non-list `items` handling. The switch guard is factored into a shared
`_non_list_branch_failure` helper covering both `case` and `default`
branches. A missing `else`/`default` still defaults to an empty list
(COMPLETED), unchanged; the guard fires only on an explicit non-list
value. The condition/expression is still evaluated first, so its result
is surfaced in the step output for downstream context.

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

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

* test(workflows): cover switch non-list branch execute paths

Copilot flagged the new switch branch guards as untested: coverage
stopped at a non-mapping `cases` container. Add SwitchStep.execute
tests for a matched case with a non-list body and a non-list default
(dict/str/int), asserting FAILED, the branch-specific error, empty
next_steps, and preserved expression_value. Also add explicit
`default: null` / `else: null` normalization tests so the
validator-approved empty-branch contract cannot regress.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-16 07:17:34 -05:00
Nate Chadwick
fd101d531e feat(integrations): add Grok Build skills-based integration (#3535)
* feat(integrations): add Grok Build skills-based integration

Add first-class support for xAI Grok Build via SkillsIntegration, installing
speckit skills under .grok/skills and wiring init/invocation/catalog surfaces.

Assisted-by: Grok Build (model: grok-4, supervised)

* test+docs: address Copilot review on Grok multi-install and next steps

Assert init next-steps guidance for Grok (.grok/skills, /speckit-*) and
clarify that multi-install safety is path/manifest isolation, not
agent-context defaults such as shared AGENTS.md.

* fix(integrations): Grok headless --always-approve and isolation paths

Document Grok multi-install isolation as .grok/skills and .grok/rules.
Override build_exec_args to pass --always-approve so non-interactive
dispatch is not blocked at tool permission gates.

* docs(integrations): list only managed .grok/skills for Grok isolation

Multi-install isolation documents Spec Kit-managed paths; Grok only
writes .grok/skills, so drop the read-only .grok/rules entry.

* fix(integrations): always-slash Grok hooks and refresh catalog date

Move grok to ALWAYS_SLASH_AGENTS so hooks never emit /speckit.plan when
ai_skills is missing/false. Update slash-format tests, persist ai_skills
on init, and bump catalog updated_at for the Grok entry.

---------

Co-authored-by: Nate Chadwick <1232206+natechadwick@users.noreply.github.com>
Co-authored-by: test <test@example.com>
2026-07-15 14:55:25 -05:00
Noor ul ain
a7f6fe8dd4 fix(extensions/git): reject negative -Number in create-new-feature-branch.ps1 (#3538)
The bash and Python twins validate --number against ^[0-9]+$ and reject a
negative value with 'Error: --number must be a non-negative integer'. The
PowerShell twin declares the parameter as [long]$Number, so PowerShell binds
'-5' as -5 instead of rejecting it. That value then formats via '{0:000}' to
'-005' and yields a branch name starting with a dash, which git refuses (refs
cannot begin with '-') — a confusing late failure instead of the twins' clear
early error.

Guard for $Number -lt 0 up front (before the description check, matching the
bash twin's parse-time validation order) and emit the identical error. An
explicit -Number 0 is still honored, preserving the #3412 fix.

Add matching negative-number parity tests to the bash and PowerShell
create-feature suites, mirroring the existing test_explicit_number_zero_is_honored
pair. Same PowerShell-parity bug class as #3412.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:48:24 -05:00
github-actions[bot]
f065e27478 test: cover preset constitution seeding through init CLI (#3297)
* Fix preset-constitution-not-installed: use PresetResolver in constitution setup

Apply the remediation from the bug assessment on issue #3272.

Changes:
1. Modify ensure_constitution_from_template (init.py) to resolve the
   constitution-template through the preset priority stack via
   PresetResolver, instead of hardcoding the core template path. This
   ensures a preset's replacement constitution-template is used when
   seeding .specify/memory/constitution.md.

2. Reorder init flow: move ensure_constitution_from_template to after
   the preset installation block so that 'specify init --preset' seeds
   the memory file from the already-resolved template stack, not from
   the generic template that existed before the preset arrived.

3. Add _maybe_reseed_constitution to PresetManager (presets/__init__.py):
   a post-install hook that re-seeds .specify/memory/constitution.md
   from the preset's constitution-template during 'specify preset add'
   on an existing project, but only when the memory file still contains
   generic placeholder tokens ([PROJECT_NAME] or [PRINCIPLE_1_NAME]).
   Legitimately authored constitutions (no placeholder tokens) are never
   overwritten.

4. Add regression tests covering both code paths (TestConstitutionReseedOnPresetInstall
   and TestEnsureConstitutionFromTemplate in tests/test_presets.py).

Refs #3272

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

* Harden preset constitution resolution

Use manifest-aware composed content, atomic safe writes, and conservative generic-template matching for constitution seeding and re-seeding.

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

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

Copilot-Session: 49891a32-bec4-462c-a7f2-6d6ec4eefcdb

* Limit preset CLI change to regression test

Remove accidental whole-file Ruff formatting introduced during conflict resolution so the PR contains only the intended end-to-end test.

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

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

Copilot-Session: 49891a32-bec4-462c-a7f2-6d6ec4eefcdb

* Make preset init test depend on init ordering

Disable preset-install lifecycle seeding in the regression test so it fails unless init materializes the constitution after registering the preset.

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

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

Copilot-Session: 49891a32-bec4-462c-a7f2-6d6ec4eefcdb

---------

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: Ben Buttigieg <70525+BenBtg@users.noreply.github.com>
2026-07-15 17:52:14 +01:00
Manfred Riem
2fb18c73cb fix(integration): preserve ai_skills on use for skills-mode Copilot (#3550) (#3551)
`specify integration use copilot` against a Copilot install configured with
`--integration-options "--skills"` dropped `"ai_skills": true` from
init-options.json and regenerated extension commands in the legacy
`.agent.md`/`.prompt.md` layout, contradicting `integration.json`'s stored
`parsed_options.skills: true`.

`_update_init_options_for_integration` only inspected `SkillsIntegration` /
the instance `_skills_mode` flag. On the `use` path no `setup()` runs, so the
freshly-resolved Copilot instance has `_skills_mode == False` and the stored
skills intent in `parsed_options` was ignored. Thread the resolved
`parsed_options` through and treat `parsed_options["skills"]` as skills mode.

Adds a regression test that resets the registry singleton's `_skills_mode` to
simulate a fresh process (in-process singleton reuse otherwise masks the bug).

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)


Copilot-Session: 06fb6ae9-f444-4dfd-ab3f-d0669c5d0604

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-07-15 11:18:54 -05:00
github-actions[bot]
5409670c13 [extension] Add Figma Starter extension to community catalog (#3547)
* Add Figma Starter extension to community catalog

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

Closes #3545

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

* fix: add missing python3 >=3.8 version constraint in figma-starter catalog entry

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

* fix(agent-context): reduce Python subprocess overhead in PS 5.1 YAML fallback

The PowerShell update-agent-context.ps1 script falls back to Python when
PS 5.1 (which lacks ConvertFrom-Yaml and cannot parse YAML as JSON) reads
the extension config.  It previously launched Python twice: once to verify
that Python 3 + PyYAML were available, and once to run a temp script file
that parsed the YAML and printed JSON.

On Windows CI each Python startup—plus potential Windows Defender scanning
of a freshly-created .tmp file—can take several seconds.  With two launches
plus PS 5.1's own startup time the subprocess.run(timeout=30) threshold in
test_powershell_script_discovers_nested_plan was regularly exceeded, causing
the CI job to fail.

Replace the two-phase approach with a single Python -c one-liner that
verifies PyYAML availability, parses the YAML file, and emits JSON in one
process.  This halves the number of Python launches and eliminates the temp
file entirely, keeping total execution time well under 30 s.

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

* revert(agent-context): restore update-agent-context.ps1 to pre-optimization state

The runtime optimization (one-liner Python fallback, stderr suppression) was
unrelated to the Figma Starter catalog addition (issue #3545) and removed the
actionable PyYAML/parse diagnostic messages. Revert the file so the PR only
contains the catalog-entry and documentation changes.

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

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-15 10:58:49 -05:00
github-actions[bot]
a4aa4f6701 [extension] Add Spec-Kit BDD extension to community catalog (#3548)
* Add Spec-Kit BDD extension to community catalog

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

Closes #3546

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

* fix(catalog): add optional BDD framework tools to bdd extension entry

Add the six optional tool dependencies submitted with the bdd extension
to the requires.tools field so they appear in CLI extension details:
pytest-bdd, behave, @cucumber/cucumber, cucumber, io.cucumber, SpecFlow

Assisted-by: GitHub Copilot (model: claude-sonnet-4.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>
2026-07-15 09:44:23 -05:00
github-actions[bot]
7a99c4a230 [extension] Update Quality Gates (Enforcement Layer) extension to v0.3.2 (#3542)
* Update Quality Gates (Enforcement Layer) extension to v0.3.2

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

Closes #3541

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

* fix: limit catalog changes to gates entry only, revert unrelated re-serialization

Reverts the wholesale Unicode-escape and tools-array reformatting that
was unintentionally introduced across catalog.community.json. Only the
gates-specific updates remain:
- description updated to v0.3.2 wording
- version: 0.1.0 → 0.3.2
- download_url updated to gates-0.3.2.zip
- git tool required: true → false
- commands: 5 → 8, hooks: 1 → 2
- updated_at: 2026-07-13 → 2026-07-15
- changelog field added

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

* fix: move changelog field above license in gates entry

Move changelog URL field to sit after documentation and before license,
matching the catalog's standard field ordering. Remove the dangling
changelog at the bottom of the entry so updated_at is the final field
with no trailing comma.

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

* fix: shorten gates catalog description

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>
2026-07-15 09:29:15 -05:00
Manfred Riem
fbc59d278e chore: release 0.12.16, begin 0.12.17.dev0 development (#3549)
* chore: bump version to 0.12.16

* chore: begin 0.12.17.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-15 09:25:55 -05:00
28 changed files with 896 additions and 18 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**: Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed
**Currently supported agents**: Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Grok Build, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed
- type: input
id: agent-name

View File

@@ -76,6 +76,7 @@ body:
- Gemini CLI
- GitHub Copilot
- Goose
- Grok Build
- Hermes Agent
- IBM Bob
- Junie

View File

@@ -70,6 +70,7 @@ body:
- Gemini CLI
- GitHub Copilot
- Goose
- Grok Build
- Hermes Agent
- IBM Bob
- Junie

3
.gitignore vendored
View File

@@ -53,9 +53,10 @@ docs/dev
# The following directories/file are intentionally ignored so that they are not accidentally
# committed to the repository. They contain the scaffolding `specify init --integration copilot`
# does and they are meant for dogfooding Spec Kit during its own feature development.
# (or other agents) does and they are meant for dogfooding Spec Kit during its own feature development.
.github/agents/
.github/prompts/
.github/copilot-instructions.md
.grok/
.specify/
specs/

View File

@@ -2,6 +2,21 @@
<!-- insert new changelog below this comment -->
## [0.12.17] - 2026-07-16
### Changed
- fix(extensions): resolve __SPECKIT_COMMAND tokens in auto-registered skills (#3544)
- fix(workflows): fail if/switch steps on non-list branch instead of crashing (#3515)
- feat(integrations): add Grok Build skills-based integration (#3535)
- fix(extensions/git): reject negative -Number in create-new-feature-branch.ps1 (#3538)
- test: cover preset constitution seeding through init CLI (#3297)
- fix(integration): preserve ai_skills on `use` for skills-mode Copilot (#3550) (#3551)
- [extension] Add Figma Starter extension to community catalog (#3547)
- [extension] Add Spec-Kit BDD extension to community catalog (#3548)
- [extension] Update Quality Gates (Enforcement Layer) extension to v0.3.2 (#3542)
- chore: release 0.12.16, begin 0.12.17.dev0 development (#3549)
## [0.12.16] - 2026-07-15
### Changed

View File

@@ -54,6 +54,7 @@ The following community-contributed extensions are available in [`catalog.commun
| DocGuard — CDD Enforcement | Doc-integrity engine with MCP server, SARIF output, and zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, spec-kit hooks. Pure Node.js. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
| EARS Requirements Syntax | Author, lint, and convert requirements using EARS - the five industry-standard sentence patterns for unambiguous, testable requirements | `docs` | Read+Write | [spec-kit-ears](https://github.com/dhruv-15-03/spec-kit-ears) |
| Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) |
| Figma Starter | Turns a Figma section's screens into per-screen spec.md files, an app-level user-stories.md, and a build-order.md, then hands off to /speckit.specify | `integration` | Read+Write | [spec-kit-figma-starter](https://github.com/wavemaker/spec-kit-figma-starter) |
| Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) |
| FixIt Extension | Spec-aware bug fixing — maps bugs to spec artifacts, proposes a plan, applies minimal changes | `code` | Read+Write | [spec-kit-fixit](https://github.com/speckit-community/spec-kit-fixit) |
| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |
@@ -100,7 +101,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) |
| Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) |
| QA Testing Extension | Systematic QA testing with browser-driven or CLI-based validation of acceptance criteria from spec | `code` | Read-only | [spec-kit-qa](https://github.com/arunt14/spec-kit-qa) |
| Quality Gates (Enforcement Layer) | Deterministic quality enforcement for Spec Kit projects at three boundaries — agent hooks, git pre-commit, CI — with one policy file and provable enforcement (attestations, canaries, verified parity). | `process` | Read+Write | [spec-gates](https://github.com/schwichtgit/spec-gates) |
| Quality Gates (Enforcement Layer) | Deterministic quality enforcement for Spec Kit across agent hooks, git checks, and CI pipelines with one policy file and one verify entrypoint for identical results at every boundary. | `process` | Read+Write | [spec-gates](https://github.com/schwichtgit/spec-gates) |
| RAG Azure Builder | Spec Kit extension for onboarding and operating an Azure RAG stack with guided workflows. | `process` | Read+Write | [spec-kit-extension-rag-azure-builder](https://github.com/Sertxito/spec-kit-extension-rag-azure-builder) |
| Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss-Projects/spec-kit-ralph) |
| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) |
@@ -134,6 +135,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) |
| Spec Trace | Build a requirement → test traceability matrix from spec.md and the test suite — surface untested requirements and orphan tests | `code` | Read+Write | [spec-kit-trace](https://github.com/Quratulain-bilal/spec-kit-trace) |
| Spec Validate | Comprehension validation, review gating, and approval state for spec-kit artifacts — staged quizzes, peer review SLA, and a hard gate before /speckit.implement | `process` | Read+Write | [spec-kit-spec-validate](https://github.com/aeltayeb/spec-kit-spec-validate) |
| Spec-Kit BDD | ATDD/BDD extension: convert specs to Gherkin scenarios, scaffold step definitions, and verify acceptance test coverage | `process` | Read+Write | [spec-kit-bdd](https://github.com/RSginer/spec-kit-bdd) |
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure | `process` | Read+Write | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
| SpecKit Companion | Live spec-driven progress — lifecycle capture, status, resume, and a turbo pipeline profile | `visibility` | Read+Write | [speckit-companion](https://github.com/alfredoperez/speckit-companion) |
| SpecTest | Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements | `code` | Read+Write | [spec-kit-spectest](https://github.com/Quratulain-bilal/spec-kit-spectest) |

View File

@@ -20,6 +20,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | |
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | Defaults to legacy markdown mode: `.agent.md` command files under `.github/agents/`, companion `.prompt.md` files under `.github/prompts/`, and a `.vscode/settings.json` merge. Pass `--integration-options="--skills"` to scaffold skills as `speckit-<command>/SKILL.md` under `.github/skills/` instead. Legacy markdown mode is deprecated and will stop being the default in a future release. |
| [Goose](https://goose-docs.ai/) | `goose` | Uses YAML recipe format in `.goose/recipes/` |
| [Grok Build](https://docs.x.ai/build/overview) | `grok` | Skills-based integration; installs skills into `.grok/skills` and invokes them as `/speckit-<command>` |
| [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes` | Skills-based integration; installs skills globally into `~/.hermes/skills/` |
| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent |
| [Junie](https://junie.jetbrains.com/) | `junie` | |
@@ -249,7 +250,11 @@ Spec Kit tracks one default integration in `.specify/integration.json` with `def
### Which integrations are multi-install safe?
An integration is multi-install safe when it uses isolated agent directories, a dedicated context file that does not collide with another safe integration, stable command invocation settings, and a separate install manifest. Shared Spec Kit templates remain aligned to the single default integration.
An integration is multi-install safe when it uses a static, unique agent root and command directory, stable command invocation settings, and a separate install manifest whose managed files do not overlap another safe integration. Registry tests enforce those path and manifest invariants. Shared Spec Kit templates remain aligned to the single default integration.
The Isolation column below lists paths Spec Kit manages for that integration (skills/commands roots and any integration-owned rule files). It is not a full inventory of every file an agent may read.
**Agent-context defaults are separate.** The optional agent-context extension maps each integration to a default context file in `extensions/agent-context/agent-context-defaults.json`. Those defaults are independent of multi-install safety: several agents may share a root file such as `AGENTS.md` when the extension is enabled. Multi-install safety does not require a unique context file per safe integration.
The currently declared multi-install safe integrations are:
@@ -263,6 +268,7 @@ The currently declared multi-install safe integrations are:
| `cursor-agent` | `.cursor/skills`, `.cursor/rules/specify-rules.mdc` |
| `firebender` | `.firebender/commands`, `.firebender/rules/specify-rules.mdc` |
| `gemini` | `.gemini/commands`, `GEMINI.md` |
| `grok` | `.grok/skills` |
| `junie` | `.junie/commands`, `.junie/AGENTS.md` |
| `kilocode` | `.kilocode/workflows`, `.kilocode/rules/specify-rules.md` |
| `qodercli` | `.qoder/commands`, `QODER.md` |
@@ -272,7 +278,7 @@ The currently declared multi-install safe integrations are:
| `trae` | `.trae/skills`, `.trae/rules/project_rules.md` |
| `zcode` | `.zcode/skills`, `ZCODE.md` |
Integrations that share a context file or command directory with another integration, require dynamic install paths such as `--commands-dir`, or merge shared tool settings are not declared safe by default. They can still be installed alongside another integration with `--force`.
Integrations that share a command directory with another integration, require dynamic install paths such as `--commands-dir`, or merge shared tool settings are not declared safe by default. They can still be installed alongside another integration with `--force`.
### What happens to my changes when I uninstall or switch?

View File

@@ -17,6 +17,7 @@
"gemini": "GEMINI.md",
"generic": "AGENTS.md",
"goose": "AGENTS.md",
"grok": "AGENTS.md",
"hermes": "AGENTS.md",
"junie": ".junie/AGENTS.md",
"kilocode": ".kilocode/rules/specify-rules.md",

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-15T00:00:00Z",
"updated_at": "2026-07-15T14:10:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -395,6 +395,66 @@
"created_at": "2026-03-03T00:00:00Z",
"updated_at": "2026-03-03T00:00:00Z"
},
"bdd": {
"name": "Spec-Kit BDD",
"id": "bdd",
"description": "ATDD/BDD extension: convert specs to Gherkin scenarios, scaffold step definitions, and verify acceptance test coverage.",
"author": "RSginer",
"version": "1.0.2",
"download_url": "https://github.com/RSginer/spec-kit-bdd/archive/refs/tags/v1.0.2.zip",
"repository": "https://github.com/RSginer/spec-kit-bdd",
"homepage": "https://github.com/RSginer/spec-kit-bdd",
"documentation": "https://github.com/RSginer/spec-kit-bdd/blob/main/docs/usage.md",
"changelog": "https://github.com/RSginer/spec-kit-bdd/releases",
"license": "MIT",
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.2.0",
"tools": [
{
"name": "pytest-bdd",
"required": false
},
{
"name": "behave",
"required": false
},
{
"name": "@cucumber/cucumber",
"required": false
},
{
"name": "cucumber",
"required": false
},
{
"name": "io.cucumber",
"required": false
},
{
"name": "SpecFlow",
"required": false
}
]
},
"provides": {
"commands": 3,
"hooks": 2
},
"tags": [
"bdd",
"gherkin",
"atdd",
"acceptance-testing",
"tdd"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-15T00:00:00Z",
"updated_at": "2026-07-15T00:00:00Z"
},
"blueprint": {
"name": "Blueprint",
"id": "blueprint",
@@ -1295,6 +1355,43 @@
"created_at": "2026-07-08T00:00:00Z",
"updated_at": "2026-07-08T00:00:00Z"
},
"figma-starter": {
"name": "Figma Starter",
"id": "figma-starter",
"description": "Turns a Figma section's screens into per-screen spec.md files, an app-level user-stories.md, and a build-order.md, then hands off to /speckit.specify.",
"author": "WaveMaker",
"version": "1.0.0",
"download_url": "https://github.com/wavemaker/spec-kit-figma-starter/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/wavemaker/spec-kit-figma-starter",
"homepage": "https://github.com/wavemaker/spec-kit-figma-starter",
"documentation": "https://github.com/wavemaker/spec-kit-figma-starter/blob/main/README.md",
"changelog": "https://github.com/wavemaker/spec-kit-figma-starter/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "integration",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.1.0",
"tools": [
{ "name": "python3", "version": ">=3.8", "required": true }
]
},
"provides": {
"commands": 1,
"hooks": 1
},
"tags": [
"figma",
"design",
"design-to-spec",
"ui",
"frontend"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-15T00:00:00Z",
"updated_at": "2026-07-15T00:00:00Z"
},
"fix-findings": {
"name": "Fix Findings",
"id": "fix-findings",
@@ -1438,13 +1535,14 @@
"gates": {
"name": "Quality Gates (Enforcement Layer)",
"id": "gates",
"description": "Deterministic quality enforcement for Spec Kit projects at three boundaries — agent hooks, git pre-commit, CI — with one policy file and provable enforcement (attestations, canaries, verified parity).",
"description": "Deterministic quality enforcement for Spec Kit across agent hooks, git checks, and CI pipelines with one policy file and one verify entrypoint for identical results at every boundary.",
"author": "schwichtgit",
"version": "0.1.0",
"download_url": "https://github.com/schwichtgit/spec-gates/releases/download/v0.1.0/gates-0.1.0.zip",
"version": "0.3.2",
"download_url": "https://github.com/schwichtgit/spec-gates/releases/download/v0.3.2/gates-0.3.2.zip",
"repository": "https://github.com/schwichtgit/spec-gates",
"homepage": "https://github.com/schwichtgit/spec-gates",
"documentation": "https://github.com/schwichtgit/spec-gates/blob/main/docs/how-it-works.md",
"changelog": "https://github.com/schwichtgit/spec-gates/releases",
"license": "MIT",
"category": "process",
"effect": "read-write",
@@ -1457,7 +1555,7 @@
},
{
"name": "git",
"required": true
"required": false
},
{
"name": "node",
@@ -1470,8 +1568,8 @@
]
},
"provides": {
"commands": 5,
"hooks": 1
"commands": 8,
"hooks": 2
},
"tags": [
"quality",
@@ -1484,7 +1582,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-07-09T00:00:00Z",
"updated_at": "2026-07-13T00:00:00Z"
"updated_at": "2026-07-15T00:00:00Z"
},
"github-issues": {
"name": "GitHub Issues Integration 1",

View File

@@ -41,6 +41,16 @@ if ($Help) {
exit 0
}
# -Number is [long], so PowerShell binds "-5" as -5 rather than rejecting it
# the way the bash/Python twins do (`^[0-9]+$`). A negative value would format
# via '{0:000}' to e.g. "-005" and produce a branch name starting with "-",
# which git refuses (refs cannot begin with a dash). Reject it here, before the
# description check, matching the bash twin's parse-time validation order.
if ($Number -lt 0) {
Write-Error 'Error: --number must be a non-negative integer'
exit 1
}
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
Write-Error "Usage: ./create-new-feature-branch.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
exit 1

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-06-23T00:00:00Z",
"updated_at": "2026-07-15T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json",
"integrations": {
"claude": {
@@ -282,6 +282,15 @@
"repository": "https://github.com/github/spec-kit",
"tags": ["cli"]
},
"grok": {
"id": "grok",
"name": "Grok Build",
"version": "1.0.0",
"description": "xAI Grok Build CLI skills-based integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["cli", "skills", "xai"]
},
"hermes": {
"id": "hermes",
"name": "Hermes Agent",

View File

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

View File

@@ -12,7 +12,7 @@ from __future__ import annotations
DOLLAR_SKILLS_AGENTS: frozenset[str] = frozenset({"codex", "zcode"})
# Agents that always render /speckit-<name>, regardless of ai_skills.
ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset({"devin", "trae", "zed"})
ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset({"devin", "grok", "trae", "zed"})
# Agents that render /speckit-<name> only when ai_skills is enabled.
CONDITIONAL_SLASH_AGENTS: frozenset[str] = frozenset(

View File

@@ -701,6 +701,7 @@ def register(app: typer.Typer) -> None:
copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration
devin_skill_mode = selected_ai == "devin"
zed_skill_mode = selected_ai == "zed" and _is_skills_integration
grok_skill_mode = selected_ai == "grok" and _is_skills_integration
cline_skill_mode = selected_ai == "cline"
native_skill_mode = (
codex_skill_mode
@@ -713,6 +714,7 @@ def register(app: typer.Typer) -> None:
or copilot_skill_mode
or devin_skill_mode
or zed_skill_mode
or grok_skill_mode
)
if codex_skill_mode:
@@ -745,6 +747,11 @@ def register(app: typer.Typer) -> None:
f"{step_num}. Start Zed in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]"
)
step_num += 1
if grok_skill_mode:
steps_lines.append(
f"{step_num}. Start Grok Build in this project directory; spec-kit skills were installed to [cyan].grok/skills[/cyan]"
)
step_num += 1
usage_label = "skills" if native_skill_mode else "slash commands"
from .._invocation_style import (

View File

@@ -1004,6 +1004,7 @@ class ExtensionManager:
from .. import load_init_options
from ..agents import CommandRegistrar
from ..integrations import get_integration
from ..integrations.base import IntegrationBase
written: List[str] = []
opts = load_init_options(self.project_root)
@@ -1015,6 +1016,30 @@ class ExtensionManager:
registrar = CommandRegistrar()
agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {})
integration = get_integration(selected_ai)
ai_skills_enabled = is_ai_skills_enabled(opts)
def _resolve_command_ref_tokens(body: str) -> str:
"""Resolve explicit command-ref tokens with the active skill style."""
def _replacement(match: re.Match[str]) -> str:
command_name = "speckit." + match.group(1).lower().replace("_", ".")
if is_dollar_skills_agent(selected_ai, ai_skills_enabled):
return "$" + command_name.replace("speckit.", "speckit-").replace(
".", "-"
)
if is_slash_skills_agent(selected_ai, ai_skills_enabled):
return "/" + command_name.replace("speckit.", "speckit-").replace(
".", "-"
)
if integration is not None:
return integration.build_command_invocation(command_name)
return IntegrationBase.resolve_command_refs(
match.group(0), agent_config.get("invoke_separator", ".")
)
return re.sub(
r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__", _replacement, body
)
for cmd_info in manifest.commands:
cmd_name = cmd_info["name"]
@@ -1086,6 +1111,7 @@ class ExtensionManager:
body = registrar.resolve_skill_placeholders(
selected_ai, frontmatter, body, self.project_root, extension_id=manifest.id
)
body = _resolve_command_ref_tokens(body)
original_desc = frontmatter.get("description", "")
description = original_desc or f"Extension command: {cmd_name}"

View File

@@ -63,6 +63,7 @@ def _register_builtins() -> None:
from .gemini import GeminiIntegration
from .generic import GenericIntegration
from .goose import GooseIntegration
from .grok import GrokIntegration
from .hermes import HermesIntegration
from .junie import JunieIntegration
from .kilocode import KilocodeIntegration
@@ -99,6 +100,7 @@ def _register_builtins() -> None:
_register(GeminiIntegration())
_register(GenericIntegration())
_register(GooseIntegration())
_register(GrokIntegration())
_register(HermesIntegration())
_register(JunieIntegration())
_register(KilocodeIntegration())

View File

@@ -260,6 +260,7 @@ def _update_init_options_for_integration(
project_root: Path,
integration: Any,
script_type: str | None = None,
parsed_options: dict[str, Any] | None = None,
) -> None:
"""Update init-options.json to reflect *integration* as the active one.
@@ -278,7 +279,17 @@ def _update_init_options_for_integration(
opts["speckit_version"] = _get_speckit_version()
if script_type:
opts["script"] = script_type
if isinstance(integration, SkillsIntegration) or getattr(integration, "_skills_mode", False):
# Skills mode is either intrinsic (SkillsIntegration), set on the instance
# during setup() (_skills_mode), or requested via parsed options (e.g.
# Copilot's --skills, persisted as parsed_options["skills"]). The latter is
# the only signal available on the `use` path, where no setup() runs and a
# fresh integration instance has _skills_mode == False (issue #3550).
skills_mode = (
isinstance(integration, SkillsIntegration)
or getattr(integration, "_skills_mode", False)
or bool((parsed_options or {}).get("skills"))
)
if skills_mode:
opts["ai_skills"] = True
else:
opts.pop("ai_skills", None)
@@ -334,7 +345,9 @@ def _set_default_integration(
) from exc
_write_integration_json(project_root, key, installed_keys, settings)
_update_init_options_for_integration(project_root, integration, script_type=resolved_script)
_update_init_options_for_integration(
project_root, integration, script_type=resolved_script, parsed_options=parsed_options
)
def _set_default_integration_or_exit(*args: Any, **kwargs: Any) -> None:

View File

@@ -0,0 +1,60 @@
"""Grok Build integration — skills-based agent.
Grok Build discovers project skills from ``.grok/skills/speckit-<name>/SKILL.md``
(and also scans ``.agents/skills/``). Spec Kit installs into the native
``.grok/skills`` tree so skills take highest local priority.
"""
from __future__ import annotations
from ..base import SkillsIntegration
class GrokIntegration(SkillsIntegration):
"""Integration for xAI Grok Build CLI."""
key = "grok"
config = {
"name": "Grok Build",
"folder": ".grok/",
"commands_subdir": "skills",
"install_url": "https://docs.x.ai/build/overview",
"requires_cli": True,
}
registrar_config = {
"dir": ".grok/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
multi_install_safe = True
def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
"""Build CLI arguments for non-interactive ``grok`` execution.
Mandatory headless flag:
* ``--always-approve`` — auto-approve tool executions so workflow
dispatch and ``dispatch_command()`` are not blocked at permission
gates (same role as Cursor's ``--force`` / Copilot's ``--yolo``).
"""
if not self.config or not self.config.get("requires_cli"):
return None
args = [
self._resolve_executable(),
"-p",
prompt,
"--always-approve",
]
self._apply_extra_args_env_var(args)
if model:
args.extend(["--model", model])
if output_json:
args.extend(["--output-format", "json"])
return args

View File

@@ -22,10 +22,33 @@ class IfThenStep(StepBase):
result = evaluate_condition(condition, context)
if result:
branch_name = "then"
branch = config.get("then", [])
else:
branch_name = "else"
branch = config.get("else", [])
# The engine does not auto-validate step config (see
# ``WorkflowEngine.load_workflow``), and it feeds ``next_steps`` straight
# into ``_execute_steps`` which iterates them as step mappings. A
# non-list branch (a single mapping or scalar authoring mistake) would
# otherwise be iterated element-wise — a dict yields its string keys, a
# str its characters — and crash the whole run with AttributeError on
# ``.get()``. ``validate`` already rejects a non-list branch; fail this
# step loudly on an unvalidated run instead, mirroring the switch/fan-out
# steps. A missing ``else`` defaults to ``[]`` and stays valid.
if branch is None and branch_name == "else":
branch = []
elif not isinstance(branch, list):
return StepResult(
status=StepStatus.FAILED,
output={"condition_result": result},
error=(
f"If step {config.get('id', '?')!r}: {branch_name!r} must be "
f"a list of steps, got {type(branch).__name__}."
),
)
return StepResult(
status=StepStatus.COMPLETED,
output={"condition_result": result},

View File

@@ -42,6 +42,10 @@ class SwitchStep(StepBase):
)
for case_key, case_steps in cases.items():
if str(case_key) == str_value:
if not isinstance(case_steps, list):
return self._non_list_branch_failure(
config, f"case {str(case_key)!r}", case_steps, value
)
return StepResult(
status=StepStatus.COMPLETED,
output={"matched_case": str(case_key), "expression_value": value},
@@ -50,12 +54,41 @@ class SwitchStep(StepBase):
# Default fallback
default_steps = config.get("default", [])
if default_steps is None:
default_steps = []
elif not isinstance(default_steps, list):
return self._non_list_branch_failure(
config, "'default'", default_steps, value
)
return StepResult(
status=StepStatus.COMPLETED,
output={"matched_case": "__default__", "expression_value": value},
next_steps=default_steps,
)
@staticmethod
def _non_list_branch_failure(
config: dict[str, Any], branch_label: str, branch: Any, value: Any
) -> StepResult:
"""Fail the step for a non-list branch instead of crashing the run.
``validate`` rejects a non-list case/default branch, but the engine does
not auto-validate and feeds ``next_steps`` straight into
``_execute_steps``, which iterates them as step mappings. A non-list
branch would be iterated element-wise (a dict yields its keys, a str its
characters) and crash the whole run with AttributeError on ``.get()``.
Fail this step loudly on an unvalidated run instead, mirroring the
non-mapping ``cases`` guard above.
"""
return StepResult(
status=StepStatus.FAILED,
output={"matched_case": None, "expression_value": value},
error=(
f"Switch step {config.get('id', '?')!r}: {branch_label} must be "
f"a list of steps, got {type(branch).__name__}."
),
)
def validate(self, config: dict[str, Any]) -> list[str]:
errors = super().validate(config)
if "expression" not in config:

View File

@@ -653,6 +653,19 @@ class TestCreateFeatureBash:
assert data["BRANCH_NAME"] == "000-zero"
assert data["FEATURE_NUM"] == "000"
def test_negative_number_rejected(self, tmp_path: Path):
"""A negative --number is rejected. Pins the canonical behavior the
PowerShell twin must mirror; a negative value would otherwise format to
e.g. '-005' and produce a branch name starting with '-', which git
refuses (refs cannot begin with a dash)."""
project = _setup_project(tmp_path)
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "--dry-run", "--number", "-5", "--short-name", "neg", "Negative feature",
)
assert result.returncode != 0
assert "--number must be a non-negative integer" in result.stderr
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
class TestCreateFeaturePowerShell:
@@ -974,6 +987,21 @@ class TestCreateFeaturePowerShell:
assert data["BRANCH_NAME"] == "000-zero"
assert data["FEATURE_NUM"] == "000"
def test_negative_number_rejected(self, tmp_path: Path):
"""A negative -Number is rejected, matching the bash/Python twins'
'--number must be a non-negative integer'. Regression guard: -Number is
[long], so PowerShell binds '-5' as -5 rather than rejecting it the way
the twins' `^[0-9]+$` check does; the value would then format via
'{0:000}' to '-005' and yield a branch name starting with '-', which
git refuses (refs cannot begin with a dash)."""
project = _setup_project(tmp_path)
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "-DryRun", "-Number", "-5", "-ShortName", "neg", "Negative feature",
)
assert result.returncode != 0
assert "--number must be a non-negative integer" in result.stderr
# ── auto-commit.sh Tests ─────────────────────────────────────────────────────

View File

@@ -224,6 +224,66 @@ class TestInitIntegrationFlag:
assert "Continuing without the optional preset" in normalized
assert "Project ready" in normalized
def test_init_with_local_preset_seeds_manifest_constitution(
self, tmp_path, monkeypatch
):
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.presets import PresetManager
monkeypatch.setattr(
PresetManager,
"_seed_constitution_from_preset",
lambda *_args, **_kwargs: None,
)
preset_dir = tmp_path / "constitution-preset"
(preset_dir / "organization").mkdir(parents=True)
preset_content = "# Ratified Organization Constitution\n"
(preset_dir / "organization" / "ratified.md").write_text(preset_content)
(preset_dir / "preset.yml").write_text(
yaml.safe_dump({
"schema_version": "1.0",
"preset": {
"id": "constitution-preset",
"name": "Constitution Preset",
"version": "1.0.0",
"description": "Provides a ratified constitution",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [{
"type": "template",
"name": "constitution-template",
"file": "organization/ratified.md",
"strategy": "replace",
}]
},
})
)
project = tmp_path / "init-with-preset"
result = CliRunner().invoke(
app,
[
"init",
str(project),
"--integration",
"copilot",
"--script",
"sh",
"--ignore-agent-tools",
"--preset",
str(preset_dir),
],
catch_exceptions=False,
)
assert result.exit_code == 0, result.output
assert (
project / ".specify" / "memory" / "constitution.md"
).read_text() == preset_content
def test_integration_claude_here_preserves_preexisting_commands(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app

View File

@@ -0,0 +1,187 @@
"""Tests for GrokIntegration."""
import json
import pytest
from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest
from .test_integration_base_skills import SkillsIntegrationTests
class TestGrokIntegration(SkillsIntegrationTests):
KEY = "grok"
FOLDER = ".grok/"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".grok/skills"
def test_options_include_skills_flag(self):
"""Not applicable — Grok Build is always skills-based."""
pytest.skip("Grok Build is always skills-based and does not expose a --skills option")
def test_options_do_not_include_skills_flag(self):
i = get_integration(self.KEY)
assert i is not None
opts = i.options()
skills_opts = [o for o in opts if o.name == "--skills"]
assert len(skills_opts) == 0
def test_requires_cli_is_true(self):
i = get_integration(self.KEY)
assert i is not None
assert i.config["requires_cli"] is True
assert i.config["name"] == "Grok Build"
assert i.multi_install_safe is True
class TestGrokInitFlow:
"""--integration grok creates expected files."""
def test_integration_grok_creates_skills(self, tmp_path):
"""--integration grok should create skills in .grok/skills."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
target = tmp_path / "test-proj"
result = runner.invoke(
app,
[
"init",
str(target),
"--integration",
"grok",
"--ignore-agent-tools",
"--script",
"sh",
],
catch_exceptions=False,
)
assert result.exit_code == 0, f"init --integration grok failed: {result.output}"
assert (target / ".grok" / "skills" / "speckit-plan" / "SKILL.md").exists()
assert (target / ".grok" / "skills" / "speckit-specify" / "SKILL.md").exists()
def test_plan_skill_has_no_context_placeholder(self, tmp_path):
"""Core skills must not carry a context-file placeholder."""
target = tmp_path / "test-proj"
target.mkdir()
integration = get_integration("grok")
manifest = IntegrationManifest("grok", target)
integration.setup(target, manifest, script_type="sh")
plan_skill = target / ".grok" / "skills" / "speckit-plan" / "SKILL.md"
content = plan_skill.read_text(encoding="utf-8")
assert "__CONTEXT_FILE__" not in content
def test_build_exec_args_uses_headless_prompt_flag(self):
integration = get_integration("grok")
args = integration.build_exec_args("hello", model="grok-build", output_json=True)
assert args is not None
assert args[0] == "grok" or args[0].endswith("/grok")
assert "-p" in args
assert "hello" in args
assert "--always-approve" in args
assert "--model" in args
assert "grok-build" in args
assert "--output-format" in args
assert "json" in args
class TestGrokNextSteps:
"""CLI output tests for Grok next-steps display."""
def test_init_next_steps_show_grok_skill_guidance(self, tmp_path):
"""init --integration grok should guide users to .grok/skills and /speckit-*."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
target = tmp_path / "grok-next-steps"
result = runner.invoke(
app,
[
"init",
str(target),
"--integration",
"grok",
"--ignore-agent-tools",
"--script",
"sh",
],
catch_exceptions=False,
)
assert result.exit_code == 0, f"init --integration grok failed: {result.output}"
assert "Start Grok Build" in result.output, (
f"Expected Grok start guidance in next steps but got:\n{result.output}"
)
assert ".grok/skills" in result.output, (
f"Expected .grok/skills install path in next steps but got:\n{result.output}"
)
assert "/speckit-plan" in result.output, (
f"Expected /speckit-plan in next steps but got:\n{result.output}"
)
assert "/speckit.plan" not in result.output, (
f"Should not show /speckit.plan for Grok skills mode:\n{result.output}"
)
class TestGrokInitOptions:
"""Init-options persistence for always-skills Grok."""
def test_init_persists_ai_skills_for_grok(self, tmp_path, monkeypatch):
"""specify init --integration grok must persist ai_skills: true,
so HookExecutor renders slash-skill invocations without manual
init-options manipulation.
"""
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.extensions import HookExecutor
project = tmp_path / "grok-init-test"
project.mkdir()
monkeypatch.chdir(project)
runner = CliRunner()
result = runner.invoke(
app,
[
"init",
"--here",
"--integration",
"grok",
"--script",
"sh",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
assert result.exit_code == 0, f"init failed: {result.output}"
opts_path = project / ".specify" / "init-options.json"
assert opts_path.exists()
opts = json.loads(opts_path.read_text(encoding="utf-8"))
assert opts.get("ai") == "grok"
assert opts.get("ai_skills") is True, (
f"init must persist ai_skills=true for Grok, got: {opts.get('ai_skills')}"
)
hook_executor = HookExecutor(project)
message = hook_executor.format_hook_message(
"before_plan",
[
{
"extension": "test-ext",
"command": "speckit.plan",
"optional": False,
}
],
)
assert "Executing: `/speckit-plan`" in message, (
"Hook rendering must produce /speckit-plan for Grok without hint injection"
)
assert "EXECUTE_COMMAND_INVOCATION: /speckit-plan" in message

View File

@@ -1566,6 +1566,43 @@ class TestIntegrationUse:
assert opts["integration"] == "codex"
assert opts["ai"] == "codex"
def test_use_preserves_copilot_skills_mode(self, tmp_path):
"""`use` on a skills-mode Copilot keeps ``ai_skills`` (issue #3550).
Re-selecting the same skills-mode Copilot must not drop ``ai_skills``
from init-options.json nor regenerate extension commands in the legacy
``.agent.md``/``.prompt.md`` layout.
"""
project = _init_project(tmp_path, "copilot", integration_options="--skills")
opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8"))
assert opts.get("ai_skills") is True, "precondition: init recorded skills mode"
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
# Simulate a fresh process: `use` in real life runs in its own process
# where the registry's Copilot instance has _skills_mode == False (it is
# only set during setup()). In-process test invocations otherwise reuse
# the singleton left in skills mode by init, masking the bug (#3550).
from specify_cli.integrations import get_integration
get_integration("copilot")._skills_mode = False
result = _run_in_project(project, ["integration", "use", "copilot"])
assert result.exit_code == 0, result.output
opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8"))
assert opts.get("ai_skills") is True, "ai_skills must survive `use copilot`"
# No legacy command-layout files should be regenerated for the
# skills-mode agent.
assert not (project / ".github" / "agents" / "speckit.git.feature.agent.md").exists()
assert not (project / ".github" / "prompts" / "speckit.git.feature.prompt.md").exists()
assert (
project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md"
).exists()
def test_use_requires_installed_integration(self, tmp_path):
project = _init_project(tmp_path, "claude")
old_cwd = os.getcwd()

View File

@@ -141,6 +141,8 @@ class TestSlashSkillsSets:
# ALWAYS_SLASH_AGENTS — unconditional on ai_skills
("devin", True, "/speckit-plan"),
("devin", False, "/speckit-plan"),
("grok", True, "/speckit-plan"),
("grok", False, "/speckit-plan"),
("trae", True, "/speckit-plan"),
("trae", False, "/speckit-plan"),
("zed", True, "/speckit-plan"),

View File

@@ -25,6 +25,7 @@ ISSUE_TEMPLATE_AGENT_KEYS = [
"gemini",
"copilot",
"goose",
"grok",
"hermes",
"bob",
"junie",

View File

@@ -970,6 +970,113 @@ class TestExtensionSkillRegistration:
assert "Read agents/control" not in content
assert "and knowledge-base/" not in content
@pytest.mark.parametrize(
("ai", "expected_invocation"),
[
("claude", "/speckit-plan"),
("copilot", "/speckit-plan"),
("codex", "$speckit-plan"),
("kimi", "/skill:speckit-plan"),
("zcode", "$speckit-plan"),
],
)
def test_skill_registration_resolves_command_ref_tokens(
self, project_dir, temp_dir, ai, expected_invocation
):
"""Auto-registered skills should resolve explicit command ref tokens."""
_create_init_options(project_dir, ai=ai, ai_skills=True)
skills_dir = _create_skills_dir(project_dir, ai=ai)
ext_dir = temp_dir / "command-ref-ext"
ext_dir.mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "command-ref-ext",
"name": "Command Ref Extension",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.command-ref-ext.run",
"file": "commands/run.md",
"description": "Run command",
}
]
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.safe_dump(manifest_data, f)
(ext_dir / "commands").mkdir()
(ext_dir / "commands" / "run.md").write_text(
"---\n"
"description: Run command\n"
"---\n\n"
"Use __SPECKIT_COMMAND_PLAN__ before proceeding.\n"
)
manager = ExtensionManager(project_dir)
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
content = (skills_dir / "speckit-command-ref-ext-run" / "SKILL.md").read_text()
assert "__SPECKIT_COMMAND_PLAN__" not in content
assert expected_invocation in content
def test_skill_registration_does_not_rewrite_literal_speckit_text(
self, project_dir, temp_dir
):
"""Auto-registered skills should leave literal speckit text untouched."""
_create_init_options(project_dir, ai="codex", ai_skills=True)
skills_dir = _create_skills_dir(project_dir, ai="codex")
ext_dir = temp_dir / "literal-ref-ext"
ext_dir.mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "literal-ref-ext",
"name": "Literal Ref Extension",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.literal-ref-ext.run",
"file": "commands/run.md",
"description": "Run command",
}
]
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.safe_dump(manifest_data, f)
(ext_dir / "commands").mkdir()
(ext_dir / "commands" / "run.md").write_text(
"---\n"
"description: Run command\n"
"---\n\n"
"Literal slash form: /speckit.foo.bar\n"
"Literal skill form: /speckit-plan\n"
"Literal bare form: speckit.foo.bar\n"
)
manager = ExtensionManager(project_dir)
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
content = (skills_dir / "speckit-literal-ref-ext-run" / "SKILL.md").read_text()
assert "/speckit.foo.bar" in content
assert "/speckit-plan" in content
assert "speckit.foo.bar" in content
assert "/speckit-foo-bar" not in content
assert "$speckit-plan" not in content
def test_missing_command_file_skipped(self, skills_project, temp_dir):
"""Commands with missing source files should be skipped gracefully."""
project_dir, skills_dir = skills_project

View File

@@ -2112,6 +2112,69 @@ class TestIfThenStep:
errors = step.validate({"id": "test", "then": []})
assert any("missing 'condition'" in e for e in errors)
@pytest.mark.parametrize("bad_branch", [{"id": "x"}, "oops", 5])
def test_execute_non_list_then_fails_loudly(self, bad_branch):
"""A non-list ``then`` must fail the step, not crash the run.
``validate`` rejects a non-list ``then``, but the engine does not
auto-validate (see ``WorkflowEngine.load_workflow``) and feeds
``next_steps`` straight into ``_execute_steps``, which iterates them as
step mappings. Before the guard, a non-list ``then`` (a single mapping
or scalar authoring mistake) was iterated element-wise and raised
AttributeError on ``.get()``, taking down the whole run. Mirrors the
switch/fan-out non-list handling.
"""
from specify_cli.workflows.steps.if_then import IfThenStep
from specify_cli.workflows.base import StepContext, StepStatus
step = IfThenStep()
ctx = StepContext(inputs={})
result = step.execute(
{"id": "branch", "condition": "true", "then": bad_branch}, ctx
)
assert result.status == StepStatus.FAILED
assert "'then' must be a list of steps" in (result.error or "")
assert result.next_steps == []
@pytest.mark.parametrize("bad_branch", [{"id": "x"}, "oops", 5])
def test_execute_non_list_else_fails_loudly(self, bad_branch):
"""A non-list ``else`` selected at runtime must fail the step, not crash.
Same asymmetry as ``then``: the ``else`` branch is only reached when the
condition is false, so a non-list ``else`` reaches ``next_steps`` and
would crash the engine's step iteration on an unvalidated run.
"""
from specify_cli.workflows.steps.if_then import IfThenStep
from specify_cli.workflows.base import StepContext, StepStatus
step = IfThenStep()
ctx = StepContext(inputs={})
result = step.execute(
{"id": "branch", "condition": "false", "then": [], "else": bad_branch},
ctx,
)
assert result.status == StepStatus.FAILED
assert "'else' must be a list of steps" in (result.error or "")
assert result.next_steps == []
def test_execute_none_else_stays_empty(self):
"""An explicit ``else: null`` selected at runtime stays an empty branch.
``validate`` deliberately accepts ``else: None``; the execute guard must
normalize it to an empty branch (COMPLETED) rather than failing a
validator-approved workflow when the condition is false.
"""
from specify_cli.workflows.steps.if_then import IfThenStep
from specify_cli.workflows.base import StepContext, StepStatus
step = IfThenStep()
ctx = StepContext(inputs={})
result = step.execute(
{"id": "branch", "condition": "false", "then": [], "else": None}, ctx
)
assert result.status == StepStatus.COMPLETED
assert result.next_steps == []
@pytest.mark.parametrize("bad_else", [False, 0, "", {}, 42])
def test_validate_rejects_non_list_else(self, bad_else):
"""A non-list 'else' must be rejected even when it is falsy.
@@ -2245,6 +2308,91 @@ class TestSwitchStep:
# expression is still evaluated, so its value is surfaced for context.
assert result.output["expression_value"] == "approve"
@pytest.mark.parametrize("bad_branch", [{"id": "x"}, "oops", 5])
def test_execute_non_list_matched_case_fails_loudly(self, bad_branch):
"""A matched case with a non-list body must fail the step, not crash.
``validate`` rejects a non-list case body, but the engine does not
auto-validate (see ``WorkflowEngine.load_workflow``) and feeds the
selected branch straight into ``_execute_steps``, which iterates it as
step mappings. A non-list body (a single mapping or scalar authoring
mistake) would be iterated element-wise and raise AttributeError on
``.get()``, taking down the whole run. Mirrors the non-mapping
``cases`` guard.
"""
from specify_cli.workflows.steps.switch import SwitchStep
from specify_cli.workflows.base import StepContext, StepStatus
step = SwitchStep()
ctx = StepContext(steps={"review": {"output": {"choice": "approve"}}})
result = step.execute(
{
"id": "route",
"expression": "{{ steps.review.output.choice }}",
"cases": {"approve": bad_branch},
},
ctx,
)
assert result.status == StepStatus.FAILED
assert "case 'approve' must be a list of steps" in (result.error or "")
assert result.next_steps == []
# expression is still evaluated, so its value is surfaced for context.
assert result.output["expression_value"] == "approve"
@pytest.mark.parametrize("bad_branch", [{"id": "x"}, "oops", 5])
def test_execute_non_list_default_fails_loudly(self, bad_branch):
"""A non-list ``default`` reached at runtime must fail, not crash.
Same asymmetry as the case body: ``default`` is only selected when no
case matches, so a non-list ``default`` reaches ``next_steps`` and would
crash the engine's step iteration on an unvalidated run.
"""
from specify_cli.workflows.steps.switch import SwitchStep
from specify_cli.workflows.base import StepContext, StepStatus
step = SwitchStep()
ctx = StepContext(steps={"review": {"output": {"choice": "other"}}})
result = step.execute(
{
"id": "route",
"expression": "{{ steps.review.output.choice }}",
"cases": {"approve": [{"id": "plan", "command": "speckit.plan"}]},
"default": bad_branch,
},
ctx,
)
assert result.status == StepStatus.FAILED
assert "'default' must be a list of steps" in (result.error or "")
assert result.next_steps == []
# expression is still evaluated, so its value is surfaced for context.
assert result.output["expression_value"] == "other"
@pytest.mark.parametrize("ok_default", [None, [], [{"id": "x", "command": "/y"}]])
def test_execute_none_default_stays_empty(self, ok_default):
"""An explicit ``default: null`` or a list default stays valid.
``validate`` deliberately accepts ``default: None``; the execute guard
must normalize it to an empty branch (COMPLETED) rather than failing a
validator-approved workflow.
"""
from specify_cli.workflows.steps.switch import SwitchStep
from specify_cli.workflows.base import StepContext, StepStatus
step = SwitchStep()
ctx = StepContext(steps={"review": {"output": {"choice": "other"}}})
result = step.execute(
{
"id": "route",
"expression": "{{ steps.review.output.choice }}",
"cases": {"approve": [{"id": "plan", "command": "speckit.plan"}]},
"default": ok_default,
},
ctx,
)
assert result.status == StepStatus.COMPLETED
assert result.output["matched_case"] == "__default__"
assert result.next_steps == (ok_default or [])
def test_validate_missing_expression(self):
from specify_cli.workflows.steps.switch import SwitchStep