Compare commits

...

21 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
Noor ul ain
c1722a425e fix(workflows): raise a clear error, not a cryptic crash, on non-string filter args (#3522)
The `map`, `join`, and `contains` expression filters assumed their
argument was a string. A non-string argument — an authoring mistake such
as `| map(5)`, `| join(5)`, or `| contains(5)` — reached an operation
that only strings support and raised a cryptic exception that escaped the
evaluator entirely:

  * `map(5)`      -> `attr.split(".")`  -> AttributeError
  * `join(5)`     -> `separator.join(...)` -> AttributeError
  * `contains(5)` on a string value -> `x in str` -> TypeError

The engine wraps neither expression evaluation nor `step_impl.execute()`
in a try/except, so each of these took down the whole run with a message
that names none of the real problem.

Validate the argument type up front and raise a `ValueError` naming the
filter and the offending type instead, mirroring the strict argument
handling already in `from_json`. `contains` guards only the string-value
branch: for a list value, membership of any element type is legitimate
(`5 in [1, 2, 5]`), so that branch is intentionally left unguarded.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 08:29:44 -05:00
Roland Huss
6688b447b7 feat(workflows): expose workflow source directory to steps (#3469)
* feat(workflows): expose workflow source directory to steps (#3467)

Propagate WorkflowDefinition.source_path to steps via
{{ context.workflow_dir }} in template expressions and
SPECKIT_WORKFLOW_DIR env var for shell steps. The original
source directory is persisted in state.json so resume
restores the correct value instead of the run-directory copy path.

Closes #3467

Assisted-By: 🤖 Claude Code

* fix: apply bot review suggestions (#2)

Applied fixes from bot review comments:
- Comment #3563319058: prevent stale SPECKIT_WORKFLOW_DIR leak from parent env
- Comment #3563319094: use cross-platform Python one-liner instead of printenv
- Comment #3563319103: add monkeypatch.delenv for deterministic env var test
- Comment #3563319116: same env leak fix as #3563319058

Assisted-By: 🤖 Claude Code

* fix: use YAML single-quotes and forward-slash paths for Windows CI (#2)

sys.executable on Windows returns backslash paths (D:\a\...) which YAML
double-quoted strings interpret as escape sequences. Switch to
single-quoted YAML strings and normalize paths with replace("\\", "/").

Assisted-By: 🤖 Claude Code

* fix: resolve workflow_dir to absolute path and add installed-by-ID test (#3469)

Applied fixes from bot review comments:
- Comment #3563382853: resolve source_path before taking parent to ensure absolute paths
- Comment #3563382864: add test for installed-by-ID workflow_dir semantics

Assisted-By: 🤖 Claude Code

* docs: document context.workflow_dir and SPECKIT_WORKFLOW_DIR

Add reference documentation for the new workflow_dir runtime value in
both workflows/README.md and docs/reference/workflows.md so workflow
authors can discover the feature and its semantics.

Assisted-By: 🤖 Claude Code

* fix: clarify installed workflow_dir is an absolute path (#3469)

The documentation for context.workflow_dir described the installed-by-ID
case as ".specify/workflows/<id>/" which appears relative, contradicting
the "resolved absolute path" semantics. Clarified that it is the absolute
path to the installation directory.

Assisted-By: 🤖 Claude Code

* fix: apply bot review suggestions (#3469)

Applied fixes from bot review comments:
- Comment #3580005128: Quote sys.executable in shell step env var test
- Comment #3580005174: Quote sys.executable in no-env-var test

Assisted-By: 🤖 Claude Code

* fix: apply bot review suggestions (#3469)

Applied fixes from bot review comments:
- Comment #3587146944: Quote interpolated workflow_dir path in example

Assisted-By: 🤖 Claude Code
2026-07-15 08:09:35 -05:00
Ali jawwad
fb076a38b8 fix(workflows): fan-out max_concurrency .inf falls back to sequential, not crash (#3521)
_run_fan_out coerces max_concurrency with int() inside except (TypeError,
ValueError). int(float('inf')) raises OverflowError, which is not in that tuple,
so a YAML 'max_concurrency: .inf' crashed the whole run with an uncaught
OverflowError instead of the documented 'cannot be coerced -> sequential'
fallback. Add OverflowError to the except tuple (nan already coerced via
ValueError).

Extends the existing invalid-value parametrization with float('inf')/nan (fails
before on inf: OverflowError; passes after: sequential, all items in order).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 08:06:03 -05:00
github-actions[bot]
1e84ee2713 Update Coding Standards Drift Control extension to v0.4.0 (#3540)
Update coding-standards-drift-control extension submitted by @benizzio:
- extensions/catalog.community.json (version, download_url, updated_at)
- docs/community/extensions.md community extensions table (no changes needed)

Closes #3534

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-07-15 07:29:35 -05:00
Ben Buttigieg
353851e966 fix(presets): seed constitution from preset constitution-template (#3272) (#3276)
* fix(presets): seed constitution from preset constitution-template (#3272)

The constitution is the only template materialized to a live file
(.specify/memory/constitution.md) rather than resolved on demand, yet
ensure_constitution_from_template hardcoded a copy from the core template
and ignored PresetResolver. Combined with init seeding the constitution
before preset installation, a preset's constitution-template (e.g.
strategy: replace with a ratified constitution) could never go live.

Changes:
- ensure_constitution_from_template now resolves constitution-template
  through PresetResolver, so a preset/override/extension wins and core is
  the fallback.
- init seeds the constitution after preset installation so init --preset
  uses the resolved stack.
- install_from_directory re-seeds memory/constitution.md from the resolved
  preset template, guarded to only act when the memory file is missing or
  still contains generic placeholder tokens — authored constitutions are
  never overwritten. Covers preset add and install_from_zip.
- Tests for preset seeding, placeholder re-seed, authored-constitution
  preservation, override resolution, and resolver-aware init seeding.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(presets): compose constitution-template when seeding memory

Take on review feedback from Copilot and gglachant:
- constitution seeding previously copied the top layer file path verbatim
  even when the winning layer used a composing strategy
  (prepend/append/wrap), which could leave {CORE_TEMPLATE} unresolved.
- both seeding paths now inspect resolver layers and only copy verbatim for
  replace; non-replace strategies materialize composed content via
  PresetResolver.resolve_content().
- add regression tests for wrap strategy composition in both
  PresetManager seeding and ensure_constitution_from_template.
- add a drift-guard test pinning _CONSTITUTION_PLACEHOLDER_TOKENS to the
  placeholders in templates/constitution-template.md.

Assisted-by: GitHub Copilot (model: GPT-5.3-Codex, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* refactor(presets): unify constitution template materialization

Address latest Copilot feedback on the constitution seeding path:
- moved resolver/layer I/O behind the existing-memory fast path in init
- corrected tracker output for composed materialization
- deduplicated materialization logic shared by init and preset install seeding
  into presets._materialize_constitution_template()

Behavior is unchanged for replace strategies (copy verbatim) and remains
composed for prepend/append/wrap via resolve_content().

Assisted-by: GitHub Copilot (model: GPT-5.3-Codex, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(init): restore shutil import

The constitution materialization refactor removed the module import, but init
still uses shutil.rmtree when cleaning up a failed new-project initialization.
Restore the import so the required ruff check passes.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(presets): harden constitution materialization

Address the outstanding review batch for preset constitution seeding:
- use checked atomic writes and reject symlinked memory paths
- replace placeholder heuristics with hash/source provenance
- rematerialize unchanged generated constitutions by resolver priority
- preserve authored or edited constitutions, including placeholder mentions
- warn non-fatally when post-install materialization cannot complete
- retain exact core-template comparison for legacy projects without provenance

Add focused provenance, priority, symlink, and failure-path coverage, and
update integration inventories for the generated provenance sidecar.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1b2c095d-b45c-4d52-8d56-bd6121d96ab6

* fix(presets): reconcile constitution after removal

When the removed preset supplied constitution-template, rematerialize the
winning remaining resolver layer only if provenance proves the live file is
still generated and unchanged. Preserve edited constitutions and report
post-removal reconciliation failures as non-fatal warnings.

Add coverage for restoring the core layer, falling back from a removed
higher-priority preset, and preserving edited generated content.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1b2c095d-b45c-4d52-8d56-bd6121d96ab6

* fix(presets): tighten legacy constitution provenance

Trust only the immutable bundled/source constitution template when migrating
legacy projects without provenance. Do not infer core provenance from mutable
project templates or preset source labels, including IDs beginning with core.

Also detect convention-based constitution-template files before preset removal
so unchanged generated constitutions reconcile to the next resolver layer.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1b2c095d-b45c-4d52-8d56-bd6121d96ab6

* fix(presets): preserve files with invalid provenance

Use immutable-core legacy migration only when the provenance sidecar is absent.
If a sidecar is malformed or its hash does not match the live constitution,
treat the file as edited and preserve it.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1b2c095d-b45c-4d52-8d56-bd6121d96ab6

* fix(presets): reconcile constitution on stack changes

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

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

Copilot-Session: 1b2c095d-b45c-4d52-8d56-bd6121d96ab6

* fix(presets): guard constitution reconciliation edges

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

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

Copilot-Session: 1b2c095d-b45c-4d52-8d56-bd6121d96ab6

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-07-15 11:42:50 +01:00
Manfred Riem
ad601e5d52 docs: add PyPI as second supported install route (#3425) (#3516)
* docs: add PyPI as second supported install route (#3425)

The specify-cli package is now officially published to PyPI via the
publish-pypi.yml trusted-publishing workflow. Document PyPI as a
supported install route alongside the GitHub source install:

- Revise the outdated "not affiliated" warning in installation.md to
  reflect that specify-cli on PyPI is an official, maintained channel.
- Add an "Install from PyPI" section and list PyPI under alternative
  package managers.
- Add a dedicated docs/install/pypi.md guide (install, pin version,
  verify, upgrade, uninstall).
- Add the PyPI guide to the docs TOC.
- Mention the PyPI route in the README quick start.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* docs: refine PyPI install guidance from review (#3516)

Address review feedback for the PyPI install documentation:

- Reword the verification guidance so `specify version` is described as a
  local version/runtime check rather than proof of package provenance.
- Clarify that upgrading a pinned `uv tool` install to the newest PyPI
  release requires an unpinned reinstall command.
- Note that `specify self upgrade` rebuilds `uv tool` and `pipx`
  installs from the GitHub source release URL rather than preserving a
  PyPI-based installation.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* docs: clarify PyPI verification and upgrade guidance

Assisted-by: GitHub Copilot (model: MAI-Code-1-Flash, autonomous)

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

* docs: point PyPI provenance check to source metadata

Address review feedback: version/list commands do not reveal install
provenance. Direct readers to the source metadata their package manager
records (pipx list --json, PEP 610 direct_url.json) to confirm whether an
install came from PyPI or a Git URL, and note pip show cannot see
uv/pipx-managed environments.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-07-14 16:05:15 -05:00
Noor ul ain
77ebd5fcea fix(workflows): fail while/do-while steps on non-list steps instead of crashing (#3519)
`WhileStep.validate()` and `DoWhileStep.validate()` already reject a
non-list `steps` body, 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 body
is returned as `next_steps`, and the engine feeds it straight into
`_execute_steps`, which iterates it as step mappings. A non-list `steps`
— 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 if/switch non-list-branch and fan-out
non-list `items` handling. The do-while body always dispatches on the
first call, so its guard is unconditional; the while body only
dispatches when the condition is truthy, so its guard fires only then —
a false condition leaves a non-list `steps` benign and the step
completes, unchanged. 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>
2026-07-14 15:16:36 -05:00
github-actions[bot]
faeb956664 Add PatchWarden Evidence Pack extension to community catalog (#3514)
Add patchwarden-evidence extension submitted by @jiezeng2004-design to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #3512

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-07-14 10:08:42 -05:00
Marsel Safin
91839fba50 feat(extensions): port git extension scripts to Python (#3400)
* feat(extensions): port git extension scripts to Python

Ports git-common, initialize-repo, auto-commit, and
create-new-feature-branch to extensions/git/scripts/python/,
mirroring the bash/PowerShell twins. Parity tests run each bash
script and its Python twin in identical projects and compare
output, exit codes, and resulting git state.

Fixes #3282

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

* fix: match bash error message for whitespace-only descriptions

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

* Handle unreadable git-config.yml and assert stderr parity

An unreadable config file raised OSError with a full traceback from
_parse_auto_commit_config. Treat it like a missing config: auto-commit
stays disabled. Covered by a chmod-000 test (skipped on non-POSIX and
as root).

_assert_parity now also compares stderr so warning or usage-text
regressions between the bash and Python twins fail the suite. All
existing parity tests pass with the stricter assertion.

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

* fix(extensions/git): pass script path to core.get_repo_root for cwd-outside-repo callers

Without script_file, core.get_repo_root() falls back to Path.cwd() when
SPECIFY_INIT_DIR is unset and no .specify root is found upward — the
bash twin instead falls back to the script's install location
(.specify/scripts/...). Pass script_file so both twins resolve the
same repo_root; TypeError fallback keeps older cores working.

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

* test: exercise SPECIFY_INIT_DIR from outside the project

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

* fix(extensions/git): handle UnicodeDecodeError and USER/USERNAME fallback

- Catch (OSError, UnicodeDecodeError) when reading git-config.yml in
  create_new_feature_branch.py, initialize_repo.py, and auto_commit.py
  so invalid UTF-8 config falls back to defaults instead of crashing
  with a traceback.
- Fall back to USERNAME (then "unknown") when USER is unset when
  deriving the branch author token, matching the PowerShell twin's
  Windows-friendly fallback chain.

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

* fix(extensions/git): platform-aware persist hint and stronger SPECIFY_INIT_DIR test

- Add a shared _persist_hint() helper in create_new_feature_branch.py
  and use it for both the JSON-mode stderr hint and the human-readable
  stdout hint, so there is a single place emitting the SPECIFY_FEATURE
  persistence guidance. On Windows (os.name == "nt") it prints
  PowerShell $env:VAR = "..." syntax; elsewhere it keeps the existing
  POSIX export VAR=... syntax (parity with the bash twin).
- Rework test_specify_init_dir_resolves_target_project so SPECIFY_INIT_DIR
  is the only thing that can produce the observed result: the script now
  runs from a separate host_proj (no existing specs, so script/cwd-based
  discovery would yield 001) while SPECIFY_INIT_DIR points at a different
  target_proj that already has an existing spec (007-existing, so the
  override must yield 008). The old version pointed SPECIFY_INIT_DIR at
  the same project the script was installed in, so it passed even if the
  env var were ignored.

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

* fix(extensions): tolerate missing Git executable

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

* fix(extensions): quote PowerShell persist hint

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

* fix(git): match bash persist hint escaping

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

* fix(git): ignore unterminated config record

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

* test(git): handle Windows persist hint parity

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

* fix(init): install Python shared scripts

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

* test(git): normalize Windows persistence hints

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-14 09:56:10 -05:00
Manfred Riem
ab82571999 chore: release 0.12.15, begin 0.12.16.dev0 development (#3513)
* chore: bump version to 0.12.15

* chore: begin 0.12.16.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-14 09:50:31 -05:00
55 changed files with 4219 additions and 51 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,51 @@
<!-- 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
- fix(workflows): raise a clear error, not a cryptic crash, on non-string filter args (#3522)
- feat(workflows): expose workflow source directory to steps (#3469)
- fix(workflows): fan-out max_concurrency .inf falls back to sequential, not crash (#3521)
- Update Coding Standards Drift Control extension to v0.4.0 (#3540)
- fix(presets): seed constitution from preset constitution-template (#3272) (#3276)
- docs: add PyPI as second supported install route (#3425) (#3516)
- fix(workflows): fail while/do-while steps on non-list steps instead of crashing (#3519)
- Add PatchWarden Evidence Pack extension to community catalog (#3514)
- feat(extensions): port git extension scripts to Python (#3400)
- chore: release 0.12.15, begin 0.12.16.dev0 development (#3513)
## [0.12.15] - 2026-07-14
### Changed
- Update Autonomous Run Governance preset to v0.1.4 (#3511)
- fix(workflows): raise catalog error, not raw ValueError, on a malformed catalog URL (#3484)
- fix(workflows): evaluate 'in'/'not in' safely on a non-iterable right operand (#3447) (#3468)
- fix: add trailing newline to init-options.json output (#3509)
- feat(workflows): align workflow CLI with extension command surface (#3419)
- fix(extensions): stop env-var config leaking across prefix-colliding extension IDs (#3497)
- fix(integrations): escape control characters in goose recipe YAML renderer (#3384)
- [extension] Update DocGuard — CDD Enforcement extension to v0.32.0 (#3489)
- [extension] Add Multi-Repo Branch Sync extension to community catalog (#3411)
- chore: release 0.12.14, begin 0.12.15.dev0 development (#3506)
## [0.12.14] - 2026-07-13
### Changed

View File

@@ -51,6 +51,12 @@ Requires **[uv](https://docs.astral.sh/uv/)** ([install uv](./docs/install/uv.md
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z
```
Prefer installing from PyPI? The `specify-cli` package is also published there:
```bash
uv tool install specify-cli
```
See the [Installation Guide](./docs/installation.md) for alternative methods, verification, upgrade, and troubleshooting.
### 2. Initialize a project

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) |
@@ -91,6 +92,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) |
| Orchestration Task Context Management | Adds subagent work-unit orchestration to generated Spec Kit task files | `process` | Read+Write | [spec-kit-orchestration-task-context-management](https://github.com/benizzio/spec-kit-orchestration-task-context-management) |
| OWASP LLM Threat Model | OWASP Top 10 for LLM Applications 2025 threat analysis on agent artifacts | `code` | Read-only | [spec-kit-threatmodel](https://github.com/NaviaSamal/spec-kit-threatmodel) |
| PatchWarden Evidence Pack | Map Spec Kit tasks into a guarded PatchWarden Goal and export bounded, traceable evidence for an accepted lineage. | `process` | Read+Write | [spec-kit-patchwarden](https://github.com/jiezeng2004-design/spec-kit-patchwarden) |
| Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) |
| PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) |
| Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) |
@@ -99,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) |
@@ -133,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) |

83
docs/install/pypi.md Normal file
View File

@@ -0,0 +1,83 @@
# Installing from PyPI
Spec Kit is published to PyPI as [`specify-cli`](https://pypi.org/project/specify-cli/), maintained by the Spec Kit maintainers. Installing from PyPI is the second supported install route alongside installing from the [GitHub source](../installation.md#install-from-source--persistent-installation-recommended). Use whichever fits your workflow — both provide the same `specify` CLI.
> [!NOTE]
> The PyPI release version tracks the GitHub release tags (for example, PyPI `0.12.11` corresponds to the `v0.12.11` tag). `specify version` is only a local version/runtime sanity check — it reports the installed version but not where the `specify` executable came from, so it cannot distinguish a PyPI install from a Git install. To confirm the install source, inspect the source metadata your package manager records: `pipx list --json` reports the exact install specification for each tool, and for uv/pip installs you can check the package's [PEP 610](https://peps.python.org/pep-0610/) `direct_url.json` inside its `*.dist-info` directory (a Git or URL install records the repository/archive URL there, while a plain PyPI index install does not create that file). Note that `pip show specify-cli` only prints package metadata and will not see uv/pipx-managed environments from the host interpreter.
## Install Specify CLI
Use whichever Python tool you already have:
```bash
# Using uv (recommended)
uv tool install specify-cli
# Or using pipx
pipx install specify-cli
# Or using pip
pip install specify-cli
```
### Install a specific release
Pin an exact version for reproducible installs (check [PyPI](https://pypi.org/project/specify-cli/#history) or [Releases](https://github.com/github/spec-kit/releases) for available versions):
```bash
# Using uv
uv tool install specify-cli==0.12.11
# Or using pipx
pipx install specify-cli==0.12.11
# Or using pip
pip install specify-cli==0.12.11
```
## Verify
```bash
specify version
```
## Initialize a project
```bash
specify init <PROJECT_NAME> --integration copilot
```
## Upgrade
Upgrade by reinstalling the package through the same tool you used for the original install. If you originally pinned a version, note that `uv tool upgrade` preserves that pin; to move to the newest PyPI release, use an unpinned install command so you do not keep the existing version pin:
```bash
# Using uv
uv tool install --force specify-cli
# Or using pipx
pipx install --force specify-cli
# Or using pip
pip install --upgrade specify-cli
```
> [!NOTE]
> `specify self upgrade` currently rebuilds `uv tool` and `pipx` installs from the GitHub source release URL rather than preserving a PyPI-based installation. If you want to stay on the PyPI route, use the package-manager commands above. A plain `pip install specify-cli` is treated as an unmanaged install — upgrade it with `pip install --upgrade specify-cli`. See the [Upgrade Guide](../upgrade.md) for details.
## Uninstall
```bash
# Using uv
uv tool uninstall specify-cli
# Or using pipx
pipx uninstall specify-cli
# Or using pip
pip uninstall specify-cli
```
## Next steps
Head to the [Quick Start](../quickstart.md) to initialize your first project.

View File

@@ -11,9 +11,14 @@
## Installation
> [!IMPORTANT]
> The only official, maintained packages for Spec Kit come from the [github/spec-kit](https://github.com/github/spec-kit) GitHub repository. Any packages with the same name available on PyPI (e.g. `specify-cli` on pypi.org) are **not** affiliated with this project and are not maintained by the Spec Kit maintainers. For normal installs, use the GitHub-based commands shown below. For offline or air-gapped environments, locally built wheels created from this repository are also valid.
> Spec Kit is distributed through two official channels, both published and maintained by the Spec Kit maintainers: the [github/spec-kit](https://github.com/github/spec-kit) GitHub repository (source installs) and the [`specify-cli`](https://pypi.org/project/specify-cli/) package on [PyPI](https://pypi.org/project/specify-cli/). Either route is supported for normal installs — use the commands shown below. After installing, run `specify version` as a local version/runtime sanity check. It confirms that the `specify` command is available and reports its version, but it does not prove whether the executable came from PyPI or GitHub. For offline or air-gapped environments, locally built wheels created from this repository are also valid.
### Persistent Installation (Recommended)
Spec Kit supports two install routes:
1. **Install from source (GitHub)** — the recommended route, pinned to a release tag.
2. **Install from PyPI** — install the published `specify-cli` package with your usual Python tooling.
### Install from Source — Persistent Installation (Recommended)
Install once and use everywhere. Replace `vX.Y.Z` with a release tag from [Releases](https://github.com/github/spec-kit/releases) — keep the leading `v` (for example, `v0.12.11`, not `0.12.11`):
@@ -30,12 +35,30 @@ Then initialize a project:
specify init <PROJECT_NAME> --integration copilot
```
### Install from PyPI
Spec Kit is also published to PyPI as [`specify-cli`](https://pypi.org/project/specify-cli/), so you can install it with your preferred Python package manager without referencing the Git URL:
```bash
# Using uv (recommended)
uv tool install specify-cli
# Or using pipx
pipx install specify-cli
# Or using pip
pip install specify-cli
```
To install a specific release, pin the version — for example `uv tool install specify-cli==0.12.11`. See the [PyPI installation guide](install/pypi.md) for details, including how to upgrade.
### One-time Usage
Run directly without installing — see the [One-time usage (uvx)](install/one-time.md) guide.
### Alternative Package Managers
- **PyPI** — see the [PyPI installation guide](install/pypi.md)
- **pipx** — see the [pipx installation guide](install/pipx.md)
- **Enterprise / Air-Gapped** — see the [air-gapped installation guide](install/air-gapped.md)
@@ -81,13 +104,13 @@ specify init <project_name> --integration claude --ignore-agent-tools
## Verification
After installation, run the following command to confirm the correct version is installed:
After installation, run the following command as a local version/runtime check:
```bash
specify version
```
This helps verify you are running the official Spec Kit build from GitHub, not an unrelated package with the same name.
This confirms that the `specify` command is available and reporting the expected version. It does not prove whether that executable came from PyPI or GitHub.
**Stay current:** Run `specify self check` periodically to learn whether a newer release is available — it is read-only and never modifies your installation. When you are ready to upgrade, follow the [Upgrade Guide](./upgrade.md).

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

@@ -305,6 +305,8 @@ Steps can reference inputs and previous step outputs using `{{ expression }}` sy
| `inputs.spec` | Workflow input values |
| `steps.specify.output.file` | Output from a previous step |
| `item` | Current item in a fan-out iteration |
| `context.run_id` | Current workflow run ID |
| `context.workflow_dir` | Resolved absolute path to the workflow source directory. Empty string for string-loaded workflows. |
Available filters: `default`, `join`, `contains`, `map`, `from_json`.
@@ -316,6 +318,14 @@ args: "{{ inputs.spec }}"
message: "{{ status | default('pending') }}"
```
## Shell Step Environment Variables
Shell steps automatically receive the following environment variables:
| Variable | Description |
| -------- | ----------- |
| `SPECKIT_WORKFLOW_DIR` | Resolved absolute path to the workflow source directory (same value as `{{ context.workflow_dir }}`). Not set when the workflow has no source path. |
## Input Types
| Type | Coercion |

View File

@@ -13,6 +13,8 @@
href: upgrade.md
- name: Install uv
href: install/uv.md
- name: Install from PyPI
href: install/pypi.md
- name: Install with pipx
href: install/pipx.md
- name: One-time Usage (uvx)

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-13T00: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",
@@ -809,8 +869,8 @@
"id": "coding-standards-drift-control",
"description": "Generate coding-standards drift reports and remediation tasks for active Spec Kit features",
"author": "Igor Benicio de Mesquita",
"version": "0.3.1",
"download_url": "https://github.com/benizzio/spec-kit-coding-standards-drift-control/archive/refs/tags/v0.3.1.zip",
"version": "0.4.0",
"download_url": "https://github.com/benizzio/spec-kit-coding-standards-drift-control/archive/refs/tags/v0.4.0.zip",
"repository": "https://github.com/benizzio/spec-kit-coding-standards-drift-control",
"homepage": "https://github.com/benizzio/spec-kit-coding-standards-drift-control",
"documentation": "https://github.com/benizzio/spec-kit-coding-standards-drift-control#readme",
@@ -835,7 +895,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-06-11T00:00:00Z",
"updated_at": "2026-06-11T00:00:00Z"
"updated_at": "2026-07-15T00:00:00Z"
},
"companion": {
"name": "SpecKit Companion",
@@ -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",
@@ -2677,6 +2775,46 @@
"created_at": "2026-04-24T14:00:00Z",
"updated_at": "2026-04-24T14:00:00Z"
},
"patchwarden-evidence": {
"name": "PatchWarden Evidence Pack",
"id": "patchwarden-evidence",
"description": "Map Spec Kit tasks into a guarded PatchWarden Goal and export bounded, traceable evidence for an accepted lineage.",
"author": "Zengjie",
"version": "1.0.1",
"download_url": "https://github.com/jiezeng2004-design/spec-kit-patchwarden/archive/refs/tags/v1.0.1.zip",
"repository": "https://github.com/jiezeng2004-design/spec-kit-patchwarden",
"homepage": "https://github.com/jiezeng2004-design/spec-kit-patchwarden",
"documentation": "https://github.com/jiezeng2004-design/spec-kit-patchwarden/blob/main/README.md",
"changelog": "https://github.com/jiezeng2004-design/spec-kit-patchwarden/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.1.0",
"tools": [
{
"name": "patchwarden",
"version": ">=1.5.1",
"required": true
}
]
},
"provides": {
"commands": 2,
"hooks": 2
},
"tags": [
"verification",
"evidence",
"traceability",
"security"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-14T00:00:00Z",
"updated_at": "2026-07-14T00:00:00Z"
},
"plan-review-gate": {
"name": "Plan Review Gate",
"id": "plan-review-gate",

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

@@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""Git extension: auto_commit.py
Automatically commit changes after a Spec Kit command completes.
Python port of ``auto-commit.sh`` / ``auto-commit.ps1``.
Checks per-command config keys in git-config.yml before committing.
Usage: auto_commit.py <event_name>
e.g.: auto_commit.py after_specify
"""
from __future__ import annotations
import re
import shutil
import subprocess
import sys
from pathlib import Path
def _find_project_root(start: Path) -> Path | None:
current = start
while True:
if (current / ".specify").is_dir() or (current / ".git").exists():
return current
if current.parent == current:
return None
current = current.parent
def _value_after_colon(line: str) -> str:
return re.sub(r"^[^:]*:\s*", "", line)
def _strip_quotes(value: str) -> str:
"""Strip one leading quote and all trailing quotes, mirroring the bash sed."""
value = re.sub(r"^[\"']", "", value)
return re.sub(r"[\"']*$", "", value)
def _parse_auto_commit_config(
config_file: Path, event_name: str
) -> tuple[bool, str]:
"""Parse the auto_commit section for this event, mirroring the bash line parser.
Returns (enabled, commit_msg). Looks for auto_commit.<event_name>.enabled
and .message, with auto_commit.default as fallback.
"""
enabled = False
commit_msg = ""
default_enabled = False
in_auto_commit = False
in_event = False
try:
content = config_file.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
# Unreadable or non-UTF-8 config is treated like a missing one:
# auto-commit stays disabled instead of crashing with a traceback.
return False, ""
for record in content.splitlines(keepends=True):
if not record.endswith("\n"):
break
line = record[:-1]
if line.startswith("auto_commit:"):
in_auto_commit = True
in_event = False
continue
# Exit auto_commit section on next top-level key
if in_auto_commit and re.match(r"^[a-z]", line):
break
if not in_auto_commit:
continue
if re.match(r"^\s+default:\s", line):
value = re.sub(r"\s", "", _value_after_colon(line)).lower()
if value == "true":
default_enabled = True
if re.match(rf"^\s+{re.escape(event_name)}:", line):
in_event = True
continue
if in_event:
# Exit on next sibling key (same indent level as event name)
if re.match(r"^\s{2}[a-z]", line) and not re.match(r"^\s{4}", line):
in_event = False
continue
if re.search(r"\s+enabled:", line):
value = re.sub(r"\s", "", _value_after_colon(line)).lower()
if value == "true":
enabled = True
elif value == "false":
enabled = False
if re.search(r"\s+message:", line):
commit_msg = _strip_quotes(_value_after_colon(line))
# If event-specific key not found, use default — but only if the event
# section didn't exist at all (an explicit false must win).
if not enabled and default_enabled:
if not re.search(rf"^\s*{re.escape(event_name)}:", content, re.MULTILINE):
enabled = True
return enabled, commit_msg
def main(argv: list[str]) -> int:
event_name = argv[0] if argv else ""
if not event_name:
print(f"Usage: {Path(sys.argv[0]).name} <event_name>", file=sys.stderr)
return 1
script_dir = Path(__file__).resolve().parent
repo_root = _find_project_root(script_dir) or Path.cwd()
if shutil.which("git") is None:
print("[specify] Warning: Git not found; skipped auto-commit", file=sys.stderr)
return 0
probe = subprocess.run(
["git", "rev-parse", "--is-inside-work-tree"],
cwd=repo_root,
capture_output=True,
text=True,
)
if probe.returncode != 0:
print(
"[specify] Warning: Not a Git repository; skipped auto-commit",
file=sys.stderr,
)
return 0
config_file = repo_root / ".specify" / "extensions" / "git" / "git-config.yml"
if not config_file.is_file():
# No config file — auto-commit disabled by default
return 0
enabled, commit_msg = _parse_auto_commit_config(config_file, event_name)
if not enabled:
return 0
# Check if there are changes to commit
def _quiet(*args: str) -> bool:
return (
subprocess.run(
["git", *args], cwd=repo_root, capture_output=True, text=True
).returncode
== 0
)
untracked = subprocess.run(
["git", "ls-files", "--others", "--exclude-standard"],
cwd=repo_root,
capture_output=True,
text=True,
).stdout.strip()
if _quiet("diff", "--quiet", "HEAD") and _quiet("diff", "--cached", "--quiet") and not untracked:
print(f"[specify] No changes to commit after {event_name}", file=sys.stderr)
return 0
# Derive a human-readable command name from the event
# e.g., after_specify -> specify, before_plan -> plan
command_name = re.sub(r"^(after_|before_)", "", event_name)
phase = "before" if event_name.startswith("before_") else "after"
if not commit_msg:
commit_msg = f"[Spec Kit] Auto-commit {phase} {command_name}"
steps = [
(["git", "add", "."], "git add"),
(["git", "commit", "-q", "-m", commit_msg], "git commit"),
]
for cmd, label in steps:
result = subprocess.run(cmd, cwd=repo_root, capture_output=True, text=True)
if result.returncode != 0:
output = (result.stdout + result.stderr).strip()
print(f"[specify] Error: {label} failed: {output}", file=sys.stderr)
return 1
print(f"[OK] Changes committed {phase} {command_name}", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View File

@@ -0,0 +1,634 @@
#!/usr/bin/env python3
"""Git extension: create_new_feature_branch.py
Creates a git feature branch only. The feature directory and spec file are
created by the core create-new-feature script. Python port of
``create-new-feature-branch.sh`` / ``create-new-feature-branch.ps1``.
Loads the core Python helpers from the project's installed scripts when
available, falling back to the minimal git helpers next to this script.
"""
from __future__ import annotations
import importlib.util
import json
import os
import re
import shutil
import subprocess
import sys
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
MAX_BRANCH_LENGTH = 244 # GitHub enforces a 244-byte limit on branch names
USAGE = (
"Usage: create_new_feature_branch.py [--json] [--dry-run] "
"[--allow-existing-branch] [--short-name <name>] [--number N] "
"[--timestamp] <feature_description>"
)
HELP_TEXT = f"""{USAGE}
Options:
--json Output in JSON format
--dry-run Compute branch name without creating the branch
--allow-existing-branch Switch to branch if it already exists instead of failing
--short-name <name> Provide a custom short name (2-4 words) for the branch
--number N Specify branch number manually (overrides auto-detection)
--timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering
--help, -h Show this help message
Environment variables:
GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation
Configuration:
branch_template Optional git-config.yml template with {{author}}, {{app}}, {{number}}, {{slug}}
branch_prefix Optional shorthand namespace expanded before {{number}}-{{slug}}
Examples:
create_new_feature_branch.py 'Add user authentication system' --short-name 'user-auth'
create_new_feature_branch.py 'Implement OAuth2 integration for API' --number 5
create_new_feature_branch.py --timestamp --short-name 'user-auth' 'Add user authentication'
GIT_BRANCH_NAME=my-branch create_new_feature_branch.py 'feature description'
"""
STOP_WORDS = frozenset(
"i a an the to for of in on at by with from is are was were be been being "
"have has had do does did will would should could can may might must shall "
"this that these those my your our their want need add get set".split()
)
def _err(message: str) -> None:
print(message, file=sys.stderr)
def _persist_hint(var_name: str, value: str) -> str:
"""Shell-appropriate guidance for persisting an env var in the caller's shell."""
if os.name == "nt":
escaped_value = value.replace("'", "''")
return f"$env:{var_name} = '{escaped_value}'"
escaped_value = re.sub(r"([^\w@%+=:,./-])", r"\\\1", value)
return f"export {var_name}={escaped_value}"
@dataclass
class Args:
json_mode: bool = False
dry_run: bool = False
allow_existing: bool = False
short_name: str = ""
branch_number: str = ""
use_timestamp: bool = False
description_parts: list[str] = field(default_factory=list)
def parse_args(argv: list[str]) -> Args:
args = Args()
i = 0
while i < len(argv):
arg = argv[i]
if arg == "--json":
args.json_mode = True
elif arg == "--dry-run":
args.dry_run = True
elif arg == "--allow-existing-branch":
args.allow_existing = True
elif arg == "--short-name":
if i + 1 >= len(argv) or argv[i + 1].startswith("--"):
_err("Error: --short-name requires a value")
raise SystemExit(1)
i += 1
args.short_name = argv[i]
elif arg == "--number":
if i + 1 >= len(argv) or argv[i + 1].startswith("--"):
_err("Error: --number requires a value")
raise SystemExit(1)
i += 1
args.branch_number = argv[i]
if not re.fullmatch(r"[0-9]+", args.branch_number):
_err("Error: --number must be a non-negative integer")
raise SystemExit(1)
elif arg == "--timestamp":
args.use_timestamp = True
elif arg in ("--help", "-h"):
print(HELP_TEXT)
raise SystemExit(0)
else:
args.description_parts.append(arg)
i += 1
return args
# ── Core helpers loading ─────────────────────────────────────────────────────
def _find_project_root(start: Path) -> Path | None:
current = start
while True:
if (current / ".specify").is_dir() or (current / ".git").exists():
return current
if current.parent == current:
return None
current = current.parent
def _load_core_common(project_root: Path | None):
"""Load the core common.py from the project's installed scripts.
Search locations in priority order, mirroring the bash script:
1. .specify/scripts/python/common.py (installed project)
2. scripts/python/common.py (source checkout fallback)
Returns the loaded module or None.
"""
if project_root is None:
return None
for relative in (".specify/scripts/python/common.py", "scripts/python/common.py"):
candidate = project_root / relative
if candidate.is_file():
spec = importlib.util.spec_from_file_location("speckit_core_common", candidate)
if spec is None or spec.loader is None:
continue
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
return None
def _local_has_git(repo_root: Path) -> bool:
git_marker = repo_root / ".git"
if not (git_marker.is_dir() or git_marker.is_file()):
return False
if shutil.which("git") is None:
return False
return (
subprocess.run(
["git", "-C", str(repo_root), "rev-parse", "--is-inside-work-tree"],
capture_output=True,
text=True,
).returncode
== 0
)
# ── Numbering ────────────────────────────────────────────────────────────────
def get_highest_from_specs(specs_dir: Path) -> int:
highest = 0
if specs_dir.is_dir():
for entry in specs_dir.iterdir():
if not entry.is_dir():
continue
name = entry.name
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
if re.match(r"^[0-9]{3,}-", name) and not re.match(
r"^[0-9]{8}-[0-9]{6}-", name
):
number = int(re.match(r"^[0-9]+", name).group(0))
highest = max(highest, number)
return highest
def _extract_highest_number(names: list[str], scope_prefix: str) -> int:
"""Extract the highest sequential feature number from a list of ref names."""
highest = 0
for name in names:
if not name:
continue
if scope_prefix:
if not name.startswith(scope_prefix):
continue
name = name[len(scope_prefix) :]
name = name.rsplit("/", 1)[-1]
if (
re.match(r"^[0-9]{3,}-", name)
and not re.match(r"^[0-9]{8}-[0-9]{6}-", name)
and not re.match(r"^[0-9]{7}-[0-9]{6}-", name)
and not re.fullmatch(r"[0-9]{7,8}-[0-9]{6}", name)
):
match = re.match(r"^([0-9]{3,})-", name)
number = int(match.group(1)) if match else 0
highest = max(highest, number)
return highest
def _git_lines(repo_root: Path, *args: str, env_extra: dict | None = None) -> list[str]:
if shutil.which("git") is None:
return []
env = {**os.environ, **(env_extra or {})}
result = subprocess.run(
["git", *args], cwd=repo_root, capture_output=True, text=True, env=env
)
if result.returncode != 0:
return []
return result.stdout.splitlines()
def get_highest_from_branches(repo_root: Path, scope_prefix: str) -> int:
names = []
for line in _git_lines(repo_root, "branch", "-a"):
line = re.sub(r"^[+*]\s+", "", line)
line = line.lstrip()
line = re.sub(r"^remotes/[^/]*/", "", line)
names.append(line)
return _extract_highest_number(names, scope_prefix)
def get_highest_from_remote_refs(repo_root: Path, scope_prefix: str) -> int:
"""Highest number from remote branches without fetching (side-effect-free)."""
highest = 0
for remote in _git_lines(repo_root, "remote"):
refs = _git_lines(
repo_root,
"ls-remote",
"--heads",
remote,
env_extra={"GIT_TERMINAL_PROMPT": "0"},
)
names = [re.sub(r".*refs/heads/", "", ref) for ref in refs]
highest = max(highest, _extract_highest_number(names, scope_prefix))
return highest
def check_existing_branches(
repo_root: Path, specs_dir: Path, skip_fetch: bool, scope_prefix: str
) -> int:
"""Check existing branches and return the next available number."""
if skip_fetch:
highest_branch = max(
get_highest_from_remote_refs(repo_root, scope_prefix),
get_highest_from_branches(repo_root, scope_prefix),
)
else:
subprocess.run(
["git", "fetch", "--all", "--prune"],
cwd=repo_root,
capture_output=True,
text=True,
)
highest_branch = get_highest_from_branches(repo_root, scope_prefix)
return max(highest_branch, get_highest_from_specs(specs_dir)) + 1
# ── Branch naming ────────────────────────────────────────────────────────────
def clean_branch_name(name: str) -> str:
name = re.sub(r"[^a-z0-9]", "-", name.lower())
name = re.sub(r"-+", "-", name)
return name.strip("-")
def generate_branch_name(description: str) -> str:
"""Generate a branch suffix from the description with stop word filtering."""
clean_name = re.sub(r"[^a-z0-9]", " ", description.lower())
meaningful_words = []
for word in clean_name.split():
if word in STOP_WORDS:
continue
if len(word) >= 3:
meaningful_words.append(word)
# Keep short words only when they appear uppercased in the original
# description (acronyms like "API" or "DB").
elif re.search(rf"\b{re.escape(word.upper())}\b", description):
meaningful_words.append(word)
if meaningful_words:
max_words = 4 if len(meaningful_words) == 4 else 3
return "-".join(meaningful_words[:max_words])
cleaned = clean_branch_name(description)
return "-".join([part for part in cleaned.split("-") if part][:3])
def branch_token(value: str, fallback: str) -> str:
cleaned = clean_branch_name(value)
return cleaned if cleaned else fallback
def get_author_token(repo_root: Path) -> str:
author = ""
if shutil.which("git") is not None:
lines = _git_lines(repo_root, "config", "user.name")
author = lines[0] if lines else ""
if not author:
lines = _git_lines(repo_root, "config", "user.email")
email = lines[0] if lines else ""
author = email.split("@")[0]
if not author:
author = os.environ.get("USER") or os.environ.get("USERNAME") or "unknown"
return branch_token(author, "unknown")
def get_app_token(repo_root: Path) -> str:
return branch_token(repo_root.name, "app")
def read_git_config_value(config_file: Path, key: str) -> str:
if not config_file.is_file():
return ""
try:
lines = config_file.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeDecodeError):
return ""
for line in lines:
if re.match(rf"^\s*{re.escape(key)}:", line):
value = re.sub(rf"^\s*{re.escape(key)}:\s*", "", line)
value = re.sub(r"\s+#.*$", "", value)
value = value.strip()
value = re.sub(r'^"|"$', "", value)
value = re.sub(r"^'|'$", "", value)
return value
return ""
def resolve_branch_template(config_file: Path) -> str:
template = read_git_config_value(config_file, "branch_template")
if template:
return template
prefix = read_git_config_value(config_file, "branch_prefix")
if not prefix:
return ""
if prefix.endswith("/"):
return f"{prefix}{{number}}-{{slug}}"
return f"{prefix}/{{number}}-{{slug}}"
def validate_branch_template(template: str) -> None:
if not template:
return
if "{number}" not in template:
_err(
"Error: branch_template must include the {number} token so generated "
"branches remain valid feature branches."
)
raise SystemExit(1)
slug_index = template.find("{slug}")
if slug_index != -1 and "{number}" in template[slug_index:]:
_err(
"Error: branch_template must not place {slug} before {number}; "
"use {slug} only in the final feature segment."
)
raise SystemExit(1)
feature_segment = template.rsplit("/", 1)[-1]
if not feature_segment.startswith("{number}-"):
_err(
"Error: branch_template must put {number}- at the start of the final "
"path segment so generated branches remain valid feature branches."
)
raise SystemExit(1)
def render_branch_template(
template: str, feature_num: str, branch_suffix: str, author_token: str, app_token: str
) -> str:
rendered = template
rendered = rendered.replace("{author}", author_token)
rendered = rendered.replace("{app}", app_token)
rendered = rendered.replace("{number}", feature_num)
rendered = rendered.replace("{slug}", branch_suffix)
return rendered
def extract_feature_num_from_branch(branch_name: str) -> str:
feature_segment = branch_name.rsplit("/", 1)[-1]
match = re.match(r"^[0-9]{8}-[0-9]{6}-", feature_segment)
if match:
return match.group(0).rstrip("-")
match = re.match(r"^[0-9]+-", feature_segment)
if match:
return match.group(0).rstrip("-")
return branch_name
def _byte_length(value: str) -> int:
return len(value.encode("utf-8"))
# ── Main ─────────────────────────────────────────────────────────────────────
def main(argv: list[str]) -> int:
args = parse_args(argv)
feature_description = " ".join(args.description_parts)
if not feature_description:
_err(USAGE)
return 1
feature_description = feature_description.strip()
if not feature_description:
_err("Error: Feature description cannot be empty or contain only whitespace")
return 1
project_root = _find_project_root(SCRIPT_DIR)
core = _load_core_common(project_root)
# SPECIFY_INIT_DIR is resolved (and validated) by the core resolver. If the
# core helpers were not found, refuse rather than silently falling back to
# the wrong root.
if os.environ.get("SPECIFY_INIT_DIR") and (
core is None or not hasattr(core, "resolve_specify_init_dir")
):
_err(
"Error: SPECIFY_INIT_DIR requires updated Spec Kit core scripts "
"(common.py with resolve_specify_init_dir), which were not found."
)
return 1
if core is not None and hasattr(core, "get_repo_root"):
# Pass script path so cwd-outside-repo callers land on the same
# fallback the bash twin does. Older cores don't accept the kwarg —
# fall back to the no-arg call for compatibility.
try:
repo_root = core.get_repo_root(script_file=Path(__file__))
except TypeError:
repo_root = core.get_repo_root()
else:
toplevel = _git_lines(Path.cwd(), "rev-parse", "--show-toplevel")
if toplevel:
repo_root = Path(toplevel[0])
elif project_root is not None:
repo_root = project_root
else:
_err("Error: Could not determine repository root.")
return 1
repo_root = Path(repo_root)
has_git_repo = _local_has_git(repo_root)
specs_dir = repo_root / "specs"
config_file = repo_root / ".specify" / "extensions" / "git" / "git-config.yml"
author_token = get_author_token(repo_root)
app_token = get_app_token(repo_root)
branch_template = resolve_branch_template(config_file)
validate_branch_template(branch_template)
def build_branch_name(feature_num: str, branch_suffix: str) -> str:
if branch_template:
return render_branch_template(
branch_template, feature_num, branch_suffix, author_token, app_token
)
return f"{feature_num}-{branch_suffix}"
branch_number = args.branch_number
# Check for GIT_BRANCH_NAME env var override (exact name, no prefix/suffix)
env_branch_name = os.environ.get("GIT_BRANCH_NAME", "")
if env_branch_name:
branch_name = env_branch_name
feature_num = extract_feature_num_from_branch(branch_name)
branch_suffix = branch_name
else:
if args.short_name:
branch_suffix = clean_branch_name(args.short_name)
else:
branch_suffix = generate_branch_name(feature_description)
if args.use_timestamp and branch_number:
_err("[specify] Warning: --number is ignored when --timestamp is used")
branch_number = ""
if args.use_timestamp:
feature_num = datetime.now().strftime("%Y%m%d-%H%M%S")
branch_name = build_branch_name(feature_num, branch_suffix)
else:
scope_prefix = ""
if branch_template:
prefix_template = branch_template.split("{number}")[0]
scope_prefix = render_branch_template(
prefix_template, "", branch_suffix, author_token, app_token
)
if not branch_number:
if args.dry_run and has_git_repo:
branch_number = check_existing_branches(
repo_root, specs_dir, True, scope_prefix
)
elif args.dry_run:
branch_number = get_highest_from_specs(specs_dir) + 1
elif has_git_repo:
branch_number = check_existing_branches(
repo_root, specs_dir, False, scope_prefix
)
else:
branch_number = get_highest_from_specs(specs_dir) + 1
feature_num = f"{int(branch_number):03d}"
branch_name = build_branch_name(feature_num, branch_suffix)
branch_byte_len = _byte_length(branch_name)
if env_branch_name and branch_byte_len > MAX_BRANCH_LENGTH:
_err(
"Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. "
f"Provided value is {branch_byte_len} bytes."
)
return 1
if branch_byte_len > MAX_BRANCH_LENGTH:
original_branch_name = branch_name
truncated_suffix = branch_suffix
while _byte_length(branch_name) > MAX_BRANCH_LENGTH and truncated_suffix:
truncated_suffix = truncated_suffix[:-1]
truncated_suffix = truncated_suffix.rstrip("-")
branch_name = build_branch_name(feature_num, truncated_suffix)
if _byte_length(branch_name) > MAX_BRANCH_LENGTH:
_err("Error: Branch template prefix exceeds GitHub's 244-byte branch name limit.")
return 1
_err("[specify] Warning: Branch name exceeded GitHub's 244-byte limit")
_err(
f"[specify] Original: {original_branch_name} "
f"({_byte_length(original_branch_name)} bytes)"
)
_err(f"[specify] Truncated to: {branch_name} ({_byte_length(branch_name)} bytes)")
if not args.dry_run:
if has_git_repo:
create = subprocess.run(
["git", "checkout", "-q", "-b", branch_name],
cwd=repo_root,
capture_output=True,
text=True,
)
if create.returncode != 0:
current_branch_lines = _git_lines(
repo_root, "rev-parse", "--abbrev-ref", "HEAD"
)
current_branch = current_branch_lines[0] if current_branch_lines else ""
branch_exists = bool(
_git_lines(repo_root, "branch", "--list", branch_name)
)
if branch_exists:
if args.allow_existing:
if current_branch != branch_name:
switch = subprocess.run(
["git", "checkout", "-q", branch_name],
cwd=repo_root,
capture_output=True,
text=True,
)
if switch.returncode != 0:
_err(
f"Error: Failed to switch to existing branch '{branch_name}'. "
"Please resolve any local changes or conflicts and try again."
)
if switch.stderr.strip():
_err(switch.stderr.strip())
return 1
elif args.use_timestamp:
_err(
f"Error: Branch '{branch_name}' already exists. Rerun to get "
"a new timestamp or use a different --short-name."
)
return 1
else:
_err(
f"Error: Branch '{branch_name}' already exists. Please use a "
"different feature name or specify a different number with --number."
)
return 1
else:
_err(f"Error: Failed to create git branch '{branch_name}'.")
if create.stderr.strip():
_err(create.stderr.strip())
else:
_err("Please check your git configuration and try again.")
return 1
else:
_err(
"[specify] Warning: Git repository not detected; skipped branch "
f"creation for {branch_name}"
)
_err(f"# To persist: {_persist_hint('SPECIFY_FEATURE', branch_name)}")
if args.json_mode:
payload: dict[str, object] = {
"BRANCH_NAME": branch_name,
"FEATURE_NUM": feature_num,
}
if args.dry_run:
payload["DRY_RUN"] = True
print(json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
else:
print(f"BRANCH_NAME: {branch_name}")
print(f"FEATURE_NUM: {feature_num}")
if not args.dry_run:
print(
"# To persist in your shell: "
f"{_persist_hint('SPECIFY_FEATURE', branch_name)}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Git-specific common helpers for the git extension.
Python port of ``git-common.sh`` / ``git-common.ps1`` — contains only
git-specific branch validation and detection logic.
"""
from __future__ import annotations
import re
import shutil
import subprocess
import sys
from pathlib import Path
def has_git(repo_root: Path | None = None) -> bool:
"""Check if we have git available at the repo root."""
root = Path(repo_root) if repo_root is not None else Path.cwd()
git_marker = root / ".git"
if not (git_marker.is_dir() or git_marker.is_file()):
return False
if shutil.which("git") is None:
return False
result = subprocess.run(
["git", "-C", str(root), "rev-parse", "--is-inside-work-tree"],
capture_output=True,
text=True,
)
return result.returncode == 0
def effective_branch_name(raw: str) -> str:
"""Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
Only when the full name is exactly two slash-free segments; otherwise
returns the raw name.
"""
match = re.fullmatch(r"([^/]+)/([^/]+)", raw)
if match:
return match.group(2)
return raw
def check_feature_branch(raw: str, has_git_repo: bool) -> bool:
"""Validate that a branch name matches the expected feature branch pattern.
Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*)
formats, either at the start of the branch or after path-style namespace
prefixes. Logic aligned with the bash/PowerShell twins.
"""
if not has_git_repo:
print(
"[specify] Warning: Git repository not detected; skipped branch validation",
file=sys.stderr,
)
return True
branch = effective_branch_name(raw)
feature_segment = branch.rsplit("/", 1)[-1]
# Accept sequential prefix (3+ digits) but exclude malformed timestamps:
# 7-or-8 digit date + 6-digit time with no trailing slug.
is_sequential = bool(
re.match(r"^[0-9]{3,}-", feature_segment)
and not re.match(r"^[0-9]{7}-[0-9]{6}-", feature_segment)
and not re.fullmatch(r"[0-9]{7,8}-[0-9]{6}", feature_segment)
)
is_timestamp = bool(re.match(r"^[0-9]{8}-[0-9]{6}-", feature_segment))
if not is_sequential and not is_timestamp:
print(f"ERROR: Not on a feature branch. Current branch: {raw}", file=sys.stderr)
print(
"Feature branches should be named like: 001-feature-name, "
"1234-feature-name, 20260319-143022-feature-name, or "
"<prefix>/001-feature-name",
file=sys.stderr,
)
return False
return True

View File

@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""Git extension: initialize_repo.py
Initialize a Git repository with an initial commit.
Python port of ``initialize-repo.sh`` / ``initialize-repo.ps1``.
Customizable — replace this script to add .gitignore templates,
default branch config, git-flow, LFS, signing, etc.
"""
from __future__ import annotations
import re
import shutil
import subprocess
import sys
from pathlib import Path
def _find_project_root(start: Path) -> Path | None:
current = start
while True:
if (current / ".specify").is_dir() or (current / ".git").exists():
return current
if current.parent == current:
return None
current = current.parent
def _read_commit_message(repo_root: Path) -> str:
"""Read init_commit_message from git-config.yml, mirroring the bash sed pipeline."""
default = "[Spec Kit] Initial commit"
config_file = repo_root / ".specify" / "extensions" / "git" / "git-config.yml"
if not config_file.is_file():
return default
try:
lines = config_file.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeDecodeError):
return default
for line in lines:
if line.startswith("init_commit_message:"):
value = re.sub(r"^init_commit_message:\s*", "", line)
value = re.sub(r"^[\"']", "", value)
value = re.sub(r"[\"']*$", "", value)
if value:
return value
return default
def main() -> int:
script_dir = Path(__file__).resolve().parent
repo_root = _find_project_root(script_dir) or Path.cwd()
commit_msg = _read_commit_message(repo_root)
if shutil.which("git") is None:
print(
"[specify] Warning: Git not found; skipped repository initialization",
file=sys.stderr,
)
return 0
probe = subprocess.run(
["git", "rev-parse", "--is-inside-work-tree"],
cwd=repo_root,
capture_output=True,
text=True,
)
if probe.returncode == 0:
print("[specify] Git repository already initialized; skipping", file=sys.stderr)
return 0
steps = [
(["git", "init", "-q"], "git init"),
(["git", "add", "."], "git add"),
(["git", "commit", "--allow-empty", "-q", "-m", commit_msg], "git commit"),
]
for cmd, label in steps:
result = subprocess.run(cmd, cwd=repo_root, capture_output=True, text=True)
if result.returncode != 0:
output = (result.stdout + result.stderr).strip()
print(f"[specify] Error: {label} failed: {output}", file=sys.stderr)
return 1
print("[OK] Git repository initialized", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())

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.15.dev0"
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

@@ -33,11 +33,17 @@ def _stdin_is_interactive() -> bool:
def ensure_constitution_from_template(
project_path: Path, tracker: StepTracker | None = None
) -> None:
"""Copy constitution template to memory if it doesn't exist."""
"""Materialize the resolved constitution template to memory if missing.
Resolution walks the full priority stack (project overrides → installed
presets → extensions → core) via :class:`PresetResolver`, so a preset that
ships a ``constitution-template`` (e.g. ``strategy: replace`` with a ratified
constitution) can seed the memory file. When nothing overrides it, the
resolver falls through to the core template.
"""
from ..presets import _materialize_constitution_template
memory_constitution = project_path / ".specify" / "memory" / "constitution.md"
template_constitution = (
project_path / ".specify" / "templates" / "constitution-template.md"
)
if memory_constitution.exists():
if tracker:
@@ -45,18 +51,21 @@ def ensure_constitution_from_template(
tracker.skip("constitution", "existing file preserved")
return
if not template_constitution.exists():
if tracker:
tracker.add("constitution", "Constitution setup")
tracker.error("constitution", "template not found")
return
try:
memory_constitution.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(template_constitution, memory_constitution)
materialization = _materialize_constitution_template(
project_path, memory_constitution
)
if materialization is None:
if tracker:
tracker.add("constitution", "Constitution setup")
tracker.error("constitution", "template not found")
return
if tracker:
tracker.add("constitution", "Constitution setup")
tracker.complete("constitution", "copied from template")
if materialization == "copied":
tracker.complete("constitution", "copied from template")
else:
tracker.complete("constitution", "composed from template")
else:
console.print("[cyan]Initialized constitution from template[/cyan]")
except Exception as e:
@@ -476,8 +485,6 @@ def register(app: typer.Typer) -> None:
"shared-infra", f"scripts ({selected_script}) + templates"
)
ensure_constitution_from_template(project_path, tracker=tracker)
try:
bundled_wf = _locate_bundled_workflow("speckit")
if bundled_wf:
@@ -605,6 +612,11 @@ def register(app: typer.Typer) -> None:
continuing="Continuing without the optional preset.",
)
# Seed the constitution AFTER preset installation so that a
# preset-provided constitution-template (resolved via the
# priority stack) wins over the core template.
ensure_constitution_from_template(project_path, tracker=tracker)
tracker.complete("final", "project ready")
except (typer.Exit, SystemExit):
raise
@@ -689,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
@@ -701,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:
@@ -733,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

@@ -31,7 +31,117 @@ 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, version_satisfies
from ..shared_infra import verify_archive_sha256
from ..shared_infra import (
_ensure_safe_shared_destination,
_ensure_safe_shared_directory,
_write_shared_bytes,
_write_shared_text,
verify_archive_sha256,
)
_CONSTITUTION_PROVENANCE_FILE = ".constitution-template.json"
def _content_sha256(content: bytes) -> str:
return hashlib.sha256(content).hexdigest()
def _constitution_is_generated(
project_root: Path,
memory_constitution: Path,
resolver: "PresetResolver",
) -> bool:
"""Return whether the live constitution is an unchanged generated file."""
_ensure_safe_shared_destination(project_root, memory_constitution)
content = memory_constitution.read_bytes()
provenance = memory_constitution.parent / _CONSTITUTION_PROVENANCE_FILE
_ensure_safe_shared_destination(project_root, provenance)
if provenance.exists():
try:
metadata = json.loads(provenance.read_text(encoding="utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
return False
return (
isinstance(metadata, dict)
and metadata.get("sha256") == _content_sha256(content)
)
# Older projects have no provenance sidecar. Only the immutable bundled or
# source-checkout core template is safe to treat as generated.
core = resolver._find_bundled_core(
"constitution-template", "template", ".md"
)
return core is not None and core.read_bytes() == content
def _constitution_provenance_matches_preset(
project_root: Path,
memory_constitution: Path,
pack_id: str,
pack_version: str,
) -> bool:
"""Return whether provenance identifies a preset as the materialized source."""
provenance = memory_constitution.parent / _CONSTITUTION_PROVENANCE_FILE
if not provenance.parent.exists():
return False
_ensure_safe_shared_destination(project_root, provenance)
if not provenance.exists():
return False
try:
metadata = json.loads(provenance.read_text(encoding="utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
return False
return (
isinstance(metadata, dict)
and metadata.get("source") == f"{pack_id} v{pack_version}"
)
def _materialize_constitution_template(
project_root: Path,
memory_constitution: Path,
) -> str | None:
"""Materialize constitution-template content into memory/constitution.md.
Returns:
"copied" when the winning layer is ``replace`` and the source file is
copied verbatim; "composed" when a composing strategy is materialized
via ``resolve_content``; ``None`` when no constitution template resolves.
"""
resolver = PresetResolver(project_root)
layers = resolver.collect_all_layers("constitution-template", "template")
if not layers:
return None
top_layer = layers[0]
if top_layer["strategy"] == "replace":
content = top_layer["path"].read_bytes()
result = "copied"
else:
composed_content = resolver.resolve_content("constitution-template", "template")
if composed_content is None:
return None
content = composed_content.encode("utf-8")
result = "composed"
_ensure_safe_shared_directory(project_root, memory_constitution.parent)
_write_shared_bytes(project_root, memory_constitution, content)
provenance = memory_constitution.parent / _CONSTITUTION_PROVENANCE_FILE
_write_shared_text(
project_root,
provenance,
json.dumps(
{
"sha256": _content_sha256(content),
"source": top_layer["source"],
},
indent=2,
)
+ "\n",
)
return result
def _substitute_core_template(
@@ -1629,8 +1739,73 @@ class PresetManager:
stacklevel=2,
)
# Seed/re-seed memory/constitution.md from a preset-provided
# constitution-template. The constitution is the only template that is
# materialized to a live file rather than resolved on demand, so a
# preset that ships one (e.g. strategy: replace with a ratified
# constitution) must be propagated here. Guard against clobbering an
# already-authored constitution by only replacing a file whose recorded
# hash (or exact legacy core-template content) proves it was generated.
self._seed_constitution_from_preset(manifest, dest_dir)
return manifest
def _seed_constitution_from_preset(
self, manifest: PresetManifest, preset_dir: Path
) -> None:
"""Seed memory/constitution.md from a preset constitution-template.
Only runs when the preset declares a ``type: template`` entry named
``constitution-template`` or provides one at a convention path, and the
live memory file is either missing or is an unchanged generated file.
Authored constitutions are never overwritten.
"""
provides_constitution = any(
t.get("type") == "template" and t.get("name") == "constitution-template"
for t in manifest.templates
) or any(
(preset_dir / relative_path).is_file()
for relative_path in (
"templates/constitution-template.md",
"constitution-template.md",
)
)
if not provides_constitution:
return
self.reconcile_constitution(
f"Failed to seed constitution from preset {manifest.id}",
create_if_missing=True,
)
def reconcile_constitution(
self, failure_context: str, *, create_if_missing: bool = False
) -> None:
"""Reconcile generated constitution content without failing a persisted change."""
try:
self._reconcile_constitution(create_if_missing=create_if_missing)
except (OSError, UnicodeDecodeError, PresetValidationError, ValueError) as exc:
import warnings
warnings.warn(
f"{failure_context}: {exc}.",
stacklevel=2,
)
def _reconcile_constitution(self, *, create_if_missing: bool = False) -> None:
"""Materialize the winning constitution layer when the live file is generated."""
memory_constitution = (
self.project_root / ".specify" / "memory" / "constitution.md"
)
if not memory_constitution.exists() and not create_if_missing:
return
resolver = PresetResolver(self.project_root)
if memory_constitution.exists() and not _constitution_is_generated(
self.project_root, memory_constitution, resolver
):
return
_materialize_constitution_template(self.project_root, memory_constitution)
def install_from_zip(
self,
zip_path: Path,
@@ -1710,6 +1885,25 @@ class PresetManager:
# Also include aliases from the manifest as a safety net for registries
# populated by older versions that may not track aliases.
removed_cmd_names = set()
removed_constitution = any(
path.exists()
for path in (
pack_dir / "templates" / "constitution-template.md",
pack_dir / "constitution-template.md",
)
)
if metadata and isinstance(metadata.get("version"), str):
memory_constitution = (
self.project_root / ".specify" / "memory" / "constitution.md"
)
removed_constitution = removed_constitution or (
_constitution_provenance_matches_preset(
self.project_root,
memory_constitution,
pack_id,
metadata["version"],
)
)
for cmd_names in registered_commands.values():
removed_cmd_names.update(cmd_names)
manifest_path = pack_dir / "preset.yml"
@@ -1717,6 +1911,11 @@ class PresetManager:
try:
manifest = PresetManifest(manifest_path)
for tmpl in manifest.templates:
if (
tmpl.get("type") == "template"
and tmpl.get("name") == "constitution-template"
):
removed_constitution = True
if tmpl.get("type") == "command":
for alias in tmpl.get("aliases", []):
if isinstance(alias, str):
@@ -1763,6 +1962,18 @@ class PresetManager:
stacklevel=2,
)
if removed_constitution:
try:
self._reconcile_constitution()
except (OSError, UnicodeDecodeError, PresetValidationError, ValueError) as exc:
import warnings
warnings.warn(
f"Post-removal constitution reconciliation failed for {pack_id}: "
f"{exc}. The live constitution may be stale.",
stacklevel=2,
)
return True
def list_installed(self) -> List[Dict[str, Any]]:

View File

@@ -484,6 +484,9 @@ def preset_set_priority(
# Update priority
manager.registry.update(preset_id, {"priority": priority})
manager.reconcile_constitution(
f"Failed to reconcile constitution after changing priority for preset {preset_id}"
)
console.print(f"[green]✓[/green] Preset '{preset_id}' priority changed: {old_priority}{priority}")
console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]")
@@ -517,6 +520,9 @@ def preset_enable(
# Enable the preset
manager.registry.update(preset_id, {"enabled": True})
manager.reconcile_constitution(
f"Failed to reconcile constitution after enabling preset {preset_id}"
)
console.print(f"[green]✓[/green] Preset '{preset_id}' enabled")
console.print("\nTemplates from this preset will now be included in resolution.")
@@ -551,6 +557,9 @@ def preset_disable(
# Disable the preset
manager.registry.update(preset_id, {"enabled": False})
manager.reconcile_constitution(
f"Failed to reconcile constitution after disabling preset {preset_id}"
)
console.print(f"[green]✓[/green] Preset '{preset_id}' disabled")
console.print("\nTemplates from this preset will be skipped during resolution.")

View File

@@ -74,6 +74,9 @@ class StepContext:
#: Current run ID.
run_id: str | None = None
#: Source directory of the workflow definition file.
workflow_dir: str | None = None
@dataclass
class StepResult:

View File

@@ -508,6 +508,7 @@ class RunState:
# append_log is never called while _lock is held, the two never nest.
self._log_lock = threading.Lock()
self.inputs: dict[str, Any] = {}
self.workflow_dir: str | None = None
self.created_at = datetime.now(timezone.utc).isoformat()
self.updated_at = self.created_at
self.log_entries: list[dict[str, Any]] = []
@@ -562,6 +563,7 @@ class RunState:
"current_step_index": self.current_step_index,
"current_step_id": self.current_step_id,
"step_results": self.step_results,
"workflow_dir": self.workflow_dir,
"created_at": self.created_at,
"updated_at": self.updated_at,
}
@@ -654,6 +656,7 @@ class RunState:
state.current_step_index = state_data.get("current_step_index", 0)
state.current_step_id = state_data.get("current_step_id")
state.step_results = state_data.get("step_results", {})
state.workflow_dir = state_data.get("workflow_dir")
state.created_at = state_data.get("created_at", "")
state.updated_at = state_data.get("updated_at", "")
@@ -810,6 +813,12 @@ class WorkflowEngine:
# Resolve inputs
resolved_inputs = self._resolve_inputs(definition, inputs or {})
state.inputs = resolved_inputs
workflow_dir = (
str(definition.source_path.resolve().parent)
if definition.source_path is not None
else None
)
state.workflow_dir = workflow_dir
state.status = RunStatus.RUNNING
state.save()
@@ -820,6 +829,7 @@ class WorkflowEngine:
default_options=definition.default_options,
project_root=str(self.project_root),
run_id=state.run_id,
workflow_dir=workflow_dir,
)
# Execute steps
@@ -885,6 +895,7 @@ class WorkflowEngine:
default_options=definition.default_options,
project_root=str(self.project_root),
run_id=state.run_id,
workflow_dir=state.workflow_dir,
)
from . import STEP_REGISTRY
@@ -1197,9 +1208,9 @@ class WorkflowEngine:
already flipped), so the prefix never drops the actual halting item.
``max_concurrency`` is coerced with ``int()``; a value that cannot be
coerced (``None``, a non-numeric string, …) or that coerces to <= 1 runs
sequentially, while a numeric string like ``"4"`` or a float like ``4.0``
is honored.
coerced (``None``, a non-numeric string, ``.inf``/``.nan``, …) or that
coerces to <= 1 runs sequentially, while a numeric string like ``"4"`` or
a float like ``4.0`` is honored.
"""
if not items:
return []
@@ -1207,7 +1218,9 @@ class WorkflowEngine:
halting = (RunStatus.PAUSED, RunStatus.FAILED, RunStatus.ABORTED)
try:
workers = max(1, int(max_concurrency))
except (TypeError, ValueError):
except (TypeError, ValueError, OverflowError):
# OverflowError: int(float("inf")) — a YAML ``max_concurrency: .inf``
# would otherwise crash the whole run instead of falling back.
workers = 1
# Never spin up more workers than there is work — bounds a user-controlled
# max_concurrency from over-allocating threads.

View File

@@ -35,14 +35,38 @@ def _filter_default(value: Any, default_value: Any = "") -> Any:
def _filter_join(value: Any, separator: str = ", ") -> str:
"""Join a list into a string with *separator*."""
"""Join a list into a string with *separator*.
Raises ``ValueError`` when *separator* is not a string. Without the guard a
non-string separator (an authoring mistake like ``| join(5)``) reaches
``str.join`` and raises a cryptic ``AttributeError: 'int' object has no
attribute 'join'`` that escapes the evaluator and crashes the whole run,
since the engine wraps neither expression evaluation nor ``execute`` in a
try/except. Mirrors the strict argument handling in ``from_json``.
"""
if not isinstance(separator, str):
raise ValueError(
f"join: expected a string separator, got {type(separator).__name__}"
)
if isinstance(value, list):
return separator.join(str(v) for v in value)
return str(value)
def _filter_map(value: Any, attr: str) -> list[Any]:
"""Map a list of dicts to a specific attribute."""
"""Map a list of dicts to a specific attribute.
Raises ``ValueError`` when *attr* is not a string. Without the guard a
non-string attribute (an authoring mistake like ``| map(5)``) reaches
``attr.split(".")`` and raises a cryptic ``AttributeError: 'int' object has
no attribute 'split'`` that escapes the evaluator and crashes the whole run,
since the engine wraps neither expression evaluation nor ``execute`` in a
try/except. Mirrors the strict argument handling in ``from_json``.
"""
if not isinstance(attr, str):
raise ValueError(
f"map: expected a string attribute name, got {type(attr).__name__}"
)
if isinstance(value, list):
result = []
for item in value:
@@ -63,9 +87,25 @@ def _filter_map(value: Any, attr: str) -> list[Any]:
return []
def _filter_contains(value: Any, substring: str) -> bool:
"""Check if a string or list contains *substring*."""
def _filter_contains(value: Any, substring: Any) -> bool:
"""Check if a string or list contains *substring*.
For a string *value*, *substring* must itself be a string: ``x in y`` on a
string requires a string left operand, so a non-string argument (an
authoring mistake like ``| contains(5)``) would otherwise raise a cryptic
``TypeError`` that escapes the evaluator and crashes the whole run, since
the engine wraps neither expression evaluation nor ``execute`` in a
try/except. Raise a ``ValueError`` naming the problem instead, mirroring the
strict argument handling in ``from_json``. For a list *value*, membership of
any element type is legitimate (``5 in [1, 2, 5]``), so that branch is left
unguarded.
"""
if isinstance(value, str):
if not isinstance(substring, str):
raise ValueError(
"contains: expected a string argument when the value is a "
f"string, got {type(substring).__name__}"
)
return substring in value
if isinstance(value, list):
return substring in value
@@ -142,7 +182,8 @@ def _build_namespace(context: Any) -> dict[str, Any]:
# runs use an 8-character uuid4 hex; operator-supplied ids may be
# any alphanumeric string with hyphens or underscores.
run_id = getattr(context, "run_id", None) or ""
ns["context"] = {"run_id": run_id}
workflow_dir = getattr(context, "workflow_dir", None) or ""
ns["context"] = {"run_id": run_id, "workflow_dir": workflow_dir}
return ns

View File

@@ -27,6 +27,30 @@ class DoWhileStep(StepBase):
nested_steps = config.get("steps", [])
condition = config.get("condition", "false")
# The engine does not auto-validate step config (see
# ``WorkflowEngine.load_workflow``) and feeds ``next_steps`` straight
# into ``_execute_steps``, which iterates them as step mappings. A
# non-list ``steps`` (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
# ``steps``; fail this step loudly on an unvalidated run instead,
# mirroring the if/switch/fan-out steps. The body always runs on the
# first call, so unlike the while step this guard is unconditional.
if not isinstance(nested_steps, list):
return StepResult(
status=StepStatus.FAILED,
output={
"condition": condition,
"max_iterations": max_iterations,
"loop_type": "do-while",
},
error=(
f"Do-while step {config.get('id', '?')!r}: 'steps' must be "
f"a list of steps, got {type(nested_steps).__name__}."
),
)
# Always execute body at least once; the engine layer evaluates
# `condition` after each iteration to decide whether to loop.
return StepResult(

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

@@ -4,6 +4,7 @@ from __future__ import annotations
import json
import math
import os
import subprocess
from typing import Any
@@ -40,6 +41,13 @@ class ShellStep(StepBase):
error=timeout_error,
output={"exit_code": -1, "stdout": "", "stderr": "invalid timeout"},
)
env = {**os.environ}
if context.workflow_dir:
env["SPECKIT_WORKFLOW_DIR"] = context.workflow_dir
else:
env.pop("SPECKIT_WORKFLOW_DIR", None)
# NOTE: shell=True is required to support pipes, redirects, and
# multi-command expressions in workflow YAML. Workflow authors
# control commands; catalog-installed workflows should be reviewed
@@ -51,6 +59,7 @@ class ShellStep(StepBase):
capture_output=True,
text=True,
cwd=cwd,
env=env,
timeout=timeout,
)
output = {

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

@@ -26,6 +26,32 @@ class WhileStep(StepBase):
nested_steps = config.get("steps", [])
result = evaluate_condition(condition, context)
# The engine does not auto-validate step config (see
# ``WorkflowEngine.load_workflow``) and feeds ``next_steps`` straight
# into ``_execute_steps``, which iterates them as step mappings. A
# non-list ``steps`` (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
# ``steps``; fail this step loudly on an unvalidated run instead,
# mirroring the if/switch/fan-out steps. The guard fires only when the
# body would actually be dispatched (condition truthy). The condition is
# still evaluated first, so its result is surfaced for downstream context.
if result and not isinstance(nested_steps, list):
return StepResult(
status=StepStatus.FAILED,
output={
"condition_result": True,
"max_iterations": max_iterations,
"loop_type": "while",
},
error=(
f"While step {config.get('id', '?')!r}: 'steps' must be a "
f"list of steps, got {type(nested_steps).__name__}."
),
)
if result:
return StepResult(
status=StepStatus.COMPLETED,

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

@@ -0,0 +1,647 @@
"""
Parity tests for the Python port of the git extension scripts (extensions/git/scripts/python/).
Each test runs the bash script and its Python twin in identical twin projects
and asserts matching output, exit codes, and resulting git state.
"""
import json
import os
import re
import runpy
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
from tests.conftest import requires_bash
from tests.extensions.git.test_git_extension import (
_GIT_ENV,
_init_git,
_run_bash,
_setup_project,
_write_config,
)
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent
EXT_PY = PROJECT_ROOT / "extensions" / "git" / "scripts" / "python"
CORE_COMMON_PY = PROJECT_ROOT / "scripts" / "python" / "common.py"
PY_SCRIPTS = {
"create-new-feature-branch": "create_new_feature_branch.py",
"initialize-repo": "initialize_repo.py",
"auto-commit": "auto_commit.py",
}
def _setup_py_project(tmp_path: Path, *, git: bool = True) -> Path:
"""Twin of _setup_project that also installs the Python scripts."""
project = _setup_project(tmp_path, git=git)
py_core = project / ".specify" / "scripts" / "python"
py_core.mkdir(parents=True, exist_ok=True)
shutil.copy(CORE_COMMON_PY, py_core / "common.py")
ext_py = project / ".specify" / "extensions" / "git" / "scripts" / "python"
ext_py.mkdir(parents=True, exist_ok=True)
for f in EXT_PY.iterdir():
if f.suffix == ".py":
shutil.copy(f, ext_py / f.name)
return project
def _run_py(
script_name: str,
cwd: Path,
*args: str,
env_extra: dict | None = None,
run_cwd: Path | None = None,
) -> subprocess.CompletedProcess:
"""Run an extension Python script.
``run_cwd`` overrides the working directory while the script path is
still resolved against ``cwd``, for tests that invoke a project's script
from outside that project.
"""
script = (
cwd / ".specify" / "extensions" / "git" / "scripts" / "python" / PY_SCRIPTS[script_name]
)
env = {**os.environ, **_GIT_ENV, **(env_extra or {})}
return subprocess.run(
[sys.executable, str(script), *args],
cwd=run_cwd or cwd,
capture_output=True,
text=True,
env=env,
)
def _twin_projects(tmp_path: Path, *, git: bool = True) -> tuple[Path, Path]:
"""Two identically named projects so {app} tokens match."""
bash_proj = _setup_py_project(tmp_path / "bash" / "proj", git=git)
py_proj = _setup_py_project(tmp_path / "py" / "proj", git=git)
return bash_proj, py_proj
def _assert_parity(
bash_result: subprocess.CompletedProcess,
py_result: subprocess.CompletedProcess,
*,
stdout: bool = True,
stderr: bool = True,
) -> None:
assert py_result.returncode == bash_result.returncode, (
f"exit codes diverge: bash={bash_result.returncode} py={py_result.returncode}\n"
f"bash stderr: {bash_result.stderr}\npy stderr: {py_result.stderr}"
)
if stdout:
assert py_result.stdout == bash_result.stdout
if stderr:
py_stderr = py_result.stderr
bash_stderr = bash_result.stderr
if os.name == "nt":
py_stderr = _without_persist_hint(py_stderr)
bash_stderr = _without_persist_hint(bash_stderr)
assert py_stderr == bash_stderr
def _without_persist_hint(stderr: str) -> str:
return "".join(
line
for line in stderr.splitlines(keepends=True)
if not line.startswith("# To persist: ")
)
@requires_bash
class TestCreateFeatureBranchParity:
def test_sequential_branch_json(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "Add user authentication")
p = _run_py("create-new-feature-branch", py_proj, "--json", "Add user authentication")
_assert_parity(b, p)
data = json.loads(p.stdout)
assert data == {"BRANCH_NAME": "001-user-authentication", "FEATURE_NUM": "001"}
branch = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=py_proj,
capture_output=True,
text=True,
).stdout.strip()
assert branch == "001-user-authentication"
def test_slug_generation_stop_words_and_acronyms(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
description = "I want to add DB caching for the API layer"
b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "--dry-run", description)
p = _run_py("create-new-feature-branch", py_proj, "--json", "--dry-run", description)
_assert_parity(b, p)
def test_short_name_cleaning(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
# Single separator runs only: the bash twin's collapse step
# (sed 's/-\+/-/g') is a GNU-ism that BSD sed treats literally.
b = _run_bash(
"create-new-feature-branch.sh", bash_proj,
"--json", "--dry-run", "--short-name", "User_Auth!", "desc",
)
p = _run_py(
"create-new-feature-branch", py_proj,
"--json", "--dry-run", "--short-name", "User_Auth!", "desc",
)
_assert_parity(b, p)
assert json.loads(p.stdout)["BRANCH_NAME"] == "001-user-auth"
def test_numbering_from_specs_and_branches(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
for proj in (bash_proj, py_proj):
(proj / "specs" / "007-existing").mkdir(parents=True)
(proj / "specs" / "20260101-120000-timestamped").mkdir(parents=True)
subprocess.run(["git", "branch", "012-in-branch"], cwd=proj, check=True)
b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "--dry-run", "next feature")
p = _run_py("create-new-feature-branch", py_proj, "--json", "--dry-run", "next feature")
_assert_parity(b, p)
assert json.loads(p.stdout)["FEATURE_NUM"] == "013"
def test_explicit_number(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "--number", "42", "some feature")
p = _run_py("create-new-feature-branch", py_proj, "--json", "--number", "42", "some feature")
_assert_parity(b, p)
assert json.loads(p.stdout)["FEATURE_NUM"] == "042"
def test_timestamp_mode_format(self, tmp_path: Path):
_, py_proj = _twin_projects(tmp_path)
p = _run_py(
"create-new-feature-branch", py_proj,
"--json", "--timestamp", "--short-name", "user-auth", "desc",
)
assert p.returncode == 0
data = json.loads(p.stdout)
assert re.fullmatch(r"[0-9]{8}-[0-9]{6}", data["FEATURE_NUM"])
assert data["BRANCH_NAME"] == f"{data['FEATURE_NUM']}-user-auth"
def test_timestamp_with_number_warns(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
b = _run_bash(
"create-new-feature-branch.sh", bash_proj,
"--json", "--dry-run", "--timestamp", "--number", "5", "desc word",
)
p = _run_py(
"create-new-feature-branch", py_proj,
"--json", "--dry-run", "--timestamp", "--number", "5", "desc word",
)
assert p.returncode == b.returncode == 0
warning = "[specify] Warning: --number is ignored when --timestamp is used"
assert warning in b.stderr
assert warning in p.stderr
def test_branch_template_author_app(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
for proj in (bash_proj, py_proj):
_write_config(proj, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "--dry-run", "new payment flow")
p = _run_py("create-new-feature-branch", py_proj, "--json", "--dry-run", "new payment flow")
_assert_parity(b, p)
assert json.loads(p.stdout)["BRANCH_NAME"] == "test-user/proj/001-new-payment-flow"
def test_branch_prefix_shorthand(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
for proj in (bash_proj, py_proj):
_write_config(proj, "branch_prefix: feat\n")
b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "--dry-run", "new payment flow")
p = _run_py("create-new-feature-branch", py_proj, "--json", "--dry-run", "new payment flow")
_assert_parity(b, p)
assert json.loads(p.stdout)["BRANCH_NAME"] == "feat/001-new-payment-flow"
def test_template_scopes_existing_branch_numbers(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
for proj in (bash_proj, py_proj):
_write_config(proj, 'branch_template: "{author}/{number}-{slug}"\n')
subprocess.run(["git", "branch", "test-user/008-scoped"], cwd=proj, check=True)
subprocess.run(["git", "branch", "other-user/030-unscoped"], cwd=proj, check=True)
b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "--dry-run", "next thing")
p = _run_py("create-new-feature-branch", py_proj, "--json", "--dry-run", "next thing")
_assert_parity(b, p)
assert json.loads(p.stdout)["FEATURE_NUM"] == "009"
@pytest.mark.parametrize(
"template",
[
'branch_template: "feat/{slug}"\n',
'branch_template: "{slug}/{number}-x"\n',
'branch_template: "{number}/{slug}-x"\n',
],
)
def test_invalid_template_rejected(self, tmp_path: Path, template: str):
bash_proj, py_proj = _twin_projects(tmp_path)
for proj in (bash_proj, py_proj):
_write_config(proj, template)
b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "--dry-run", "desc word")
p = _run_py("create-new-feature-branch", py_proj, "--json", "--dry-run", "desc word")
assert b.returncode == p.returncode == 1
assert p.stderr.strip() == b.stderr.strip()
def test_git_branch_name_override(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
env = {"GIT_BRANCH_NAME": "team/042-exact-name"}
b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "desc word", env_extra=env)
p = _run_py("create-new-feature-branch", py_proj, "--json", "desc word", env_extra=env)
_assert_parity(b, p)
assert json.loads(p.stdout) == {"BRANCH_NAME": "team/042-exact-name", "FEATURE_NUM": "042"}
def test_git_branch_name_override_persist_hint_matches_bash(
self, tmp_path: Path
):
bash_proj, py_proj = _twin_projects(tmp_path)
env = {"GIT_BRANCH_NAME": "feature/$value's-\"quoted\""}
b = _run_bash(
"create-new-feature-branch.sh",
bash_proj,
"--json",
"desc word",
env_extra=env,
)
p = _run_py(
"create-new-feature-branch",
py_proj,
"--json",
"desc word",
env_extra=env,
)
_assert_parity(b, p)
def test_long_branch_name_truncated_to_244_bytes(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
long_name = "-".join(["word"] * 60)
b = _run_bash(
"create-new-feature-branch.sh", bash_proj,
"--json", "--dry-run", "--short-name", long_name, "desc",
)
p = _run_py(
"create-new-feature-branch", py_proj,
"--json", "--dry-run", "--short-name", long_name, "desc",
)
_assert_parity(b, p)
assert len(json.loads(p.stdout)["BRANCH_NAME"].encode()) <= 244
def test_existing_branch_errors_without_flag(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
for proj in (bash_proj, py_proj):
subprocess.run(["git", "branch", "001-user-auth"], cwd=proj, check=True)
args = ("--json", "--number", "1", "--short-name", "user-auth", "desc")
b = _run_bash("create-new-feature-branch.sh", bash_proj, *args)
p = _run_py("create-new-feature-branch", py_proj, *args)
assert b.returncode == p.returncode == 1
assert p.stderr.strip() == b.stderr.strip()
def test_existing_branch_switches_with_allow_flag(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
for proj in (bash_proj, py_proj):
subprocess.run(["git", "branch", "001-user-auth"], cwd=proj, check=True)
args = ("--json", "--number", "1", "--short-name", "user-auth", "--allow-existing-branch", "desc")
b = _run_bash("create-new-feature-branch.sh", bash_proj, *args)
p = _run_py("create-new-feature-branch", py_proj, *args)
_assert_parity(b, p)
for proj in (bash_proj, py_proj):
branch = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=proj,
capture_output=True,
text=True,
).stdout.strip()
assert branch == "001-user-auth"
def test_no_git_graceful_degradation(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path, git=False)
b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", "offline feature")
p = _run_py("create-new-feature-branch", py_proj, "--json", "offline feature")
_assert_parity(b, p)
assert "skipped branch creation" in p.stderr
def test_missing_git_executable_gracefully_degrades(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
module = runpy.run_path(str(EXT_PY / "create_new_feature_branch.py"))
monkeypatch.setenv("PATH", "")
assert module["_git_lines"](tmp_path, "status") == []
def test_windows_persist_hint_quotes_branch_name(
self, monkeypatch: pytest.MonkeyPatch
):
module = runpy.run_path(str(EXT_PY / "create_new_feature_branch.py"))
monkeypatch.setattr(module["os"], "name", "nt")
result = module["_persist_hint"](
"GIT_BRANCH_NAME", "feature/$value's-\"quoted\""
)
assert (
result
== "$env:GIT_BRANCH_NAME = 'feature/$value''s-\"quoted\"'"
)
def test_shell_specific_persist_hint_can_be_ignored_for_parity(self):
bash_stderr = (
"[specify] Warning\n"
"# To persist: export SPECIFY_FEATURE=feature/name\n"
)
windows_stderr = (
"[specify] Warning\n"
"# To persist: $env:SPECIFY_FEATURE = 'feature/name'\n"
)
assert _without_persist_hint(bash_stderr) == _without_persist_hint(
windows_stderr
)
def test_assert_parity_ignores_windows_persist_hint(
self, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setattr(os, "name", "nt")
bash_result = subprocess.CompletedProcess(
args=[], returncode=0, stdout="", stderr=(
"[specify] Warning\n"
"# To persist: export SPECIFY_FEATURE=feature/name\n"
)
)
py_result = subprocess.CompletedProcess(
args=[], returncode=0, stdout="", stderr=(
"[specify] Warning\n"
"# To persist: $env:SPECIFY_FEATURE = 'feature/name'\n"
)
)
_assert_parity(bash_result, py_result)
def test_empty_description_errors(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
b = _run_bash("create-new-feature-branch.sh", bash_proj, "--json", " ")
p = _run_py("create-new-feature-branch", py_proj, "--json", " ")
assert b.returncode == p.returncode == 1
assert p.stderr.strip() == b.stderr.strip()
assert "cannot be empty or contain only whitespace" in p.stderr
def test_specify_init_dir_resolves_target_project(self, tmp_path: Path):
# The script is installed under host_proj, so script_file-based
# discovery (and cwd-based discovery, since we run from elsewhere)
# would resolve host_proj, not target_proj. host_proj has no specs
# (next number 001); target_proj already has 007-existing (next
# number 008). Only honoring SPECIFY_INIT_DIR produces 008, so this
# proves the env var -- not script location or cwd -- controls
# resolution.
host_proj = _setup_py_project(tmp_path / "host")
target_proj = _setup_py_project(tmp_path / "target")
(target_proj / "specs" / "007-existing").mkdir(parents=True)
elsewhere = tmp_path / "elsewhere"
elsewhere.mkdir()
p = _run_py(
"create-new-feature-branch", host_proj,
"--json", "--dry-run", "init dir feature",
env_extra={"SPECIFY_INIT_DIR": str(target_proj)},
run_cwd=elsewhere,
)
assert p.returncode == 0
assert json.loads(p.stdout)["FEATURE_NUM"] == "008"
def test_specify_init_dir_without_core_errors(self, tmp_path: Path):
_, py_proj = _twin_projects(tmp_path)
(
py_proj / ".specify" / "scripts" / "python" / "common.py"
).unlink()
p = _run_py(
"create-new-feature-branch", py_proj,
"--json", "desc word",
env_extra={"SPECIFY_INIT_DIR": str(py_proj)},
)
assert p.returncode == 1
assert "SPECIFY_INIT_DIR requires updated Spec Kit core scripts" in p.stderr
@requires_bash
class TestInitializeRepoParity:
def test_initializes_repo_with_default_message(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path, git=False)
b = _run_bash("initialize-repo.sh", bash_proj)
p = _run_py("initialize-repo", py_proj)
_assert_parity(b, p)
assert p.stderr.strip() == b.stderr.strip()
for proj in (bash_proj, py_proj):
message = subprocess.run(
["git", "log", "-1", "--format=%s"],
cwd=proj,
capture_output=True,
text=True,
).stdout.strip()
assert message == "[Spec Kit] Initial commit"
def test_custom_commit_message(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path, git=False)
for proj in (bash_proj, py_proj):
_write_config(proj, 'init_commit_message: "Custom initial commit"\n')
b = _run_bash("initialize-repo.sh", bash_proj)
p = _run_py("initialize-repo", py_proj)
_assert_parity(b, p)
for proj in (bash_proj, py_proj):
message = subprocess.run(
["git", "log", "-1", "--format=%s"],
cwd=proj,
capture_output=True,
text=True,
).stdout.strip()
assert message == "Custom initial commit"
def test_skips_existing_repo(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
b = _run_bash("initialize-repo.sh", bash_proj)
p = _run_py("initialize-repo", py_proj)
_assert_parity(b, p)
assert p.stderr.strip() == b.stderr.strip()
assert "already initialized" in p.stderr
@requires_bash
class TestAutoCommitParity:
def _dirty(self, proj: Path) -> None:
(proj / "change.txt").write_text("dirty\n", encoding="utf-8")
def _last_message(self, proj: Path) -> str:
return subprocess.run(
["git", "log", "-1", "--format=%s"],
cwd=proj,
capture_output=True,
text=True,
).stdout.strip()
def test_disabled_by_default(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
for proj in (bash_proj, py_proj):
_write_config(proj, "auto_commit:\n after_specify:\n enabled: false\n")
self._dirty(proj)
b = _run_bash("auto-commit.sh", bash_proj, "after_specify")
p = _run_py("auto-commit", py_proj, "after_specify")
_assert_parity(b, p)
assert self._last_message(py_proj) == "seed"
def test_ignores_unterminated_final_config_line(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
config = "auto_commit:\n after_specify:\n enabled: true"
for proj in (bash_proj, py_proj):
_write_config(proj, config)
self._dirty(proj)
b = _run_bash("auto-commit.sh", bash_proj, "after_specify")
p = _run_py("auto-commit", py_proj, "after_specify")
_assert_parity(b, p)
assert self._last_message(py_proj) == "seed"
def test_enabled_per_command_with_custom_message(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
config = (
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
' message: "spec done"\n'
)
for proj in (bash_proj, py_proj):
_write_config(proj, config)
self._dirty(proj)
b = _run_bash("auto-commit.sh", bash_proj, "after_specify")
p = _run_py("auto-commit", py_proj, "after_specify")
_assert_parity(b, p)
assert p.stderr.strip() == b.stderr.strip()
assert self._last_message(bash_proj) == self._last_message(py_proj) == "spec done"
def test_default_true_applies_to_unlisted_event(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
for proj in (bash_proj, py_proj):
_write_config(proj, "auto_commit:\n default: true\n")
self._dirty(proj)
b = _run_bash("auto-commit.sh", bash_proj, "after_plan")
p = _run_py("auto-commit", py_proj, "after_plan")
_assert_parity(b, p)
expected = "[Spec Kit] Auto-commit after plan"
assert self._last_message(bash_proj) == self._last_message(py_proj) == expected
def test_explicit_false_beats_default_true(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
config = "auto_commit:\n default: true\n after_specify:\n enabled: false\n"
for proj in (bash_proj, py_proj):
_write_config(proj, config)
self._dirty(proj)
b = _run_bash("auto-commit.sh", bash_proj, "after_specify")
p = _run_py("auto-commit", py_proj, "after_specify")
_assert_parity(b, p)
assert self._last_message(py_proj) == "seed"
def test_before_event_message(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
for proj in (bash_proj, py_proj):
_write_config(proj, "auto_commit:\n before_plan:\n enabled: true\n")
self._dirty(proj)
b = _run_bash("auto-commit.sh", bash_proj, "before_plan")
p = _run_py("auto-commit", py_proj, "before_plan")
_assert_parity(b, p)
expected = "[Spec Kit] Auto-commit before plan"
assert self._last_message(bash_proj) == self._last_message(py_proj) == expected
def test_no_changes_skips(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
for proj in (bash_proj, py_proj):
_write_config(proj, "auto_commit:\n after_specify:\n enabled: true\n")
subprocess.run(["git", "add", "-A"], cwd=proj, check=True)
subprocess.run(
["git", "commit", "-q", "-m", "clean"],
cwd=proj,
check=True,
env={**os.environ, **_GIT_ENV},
)
b = _run_bash("auto-commit.sh", bash_proj, "after_specify")
p = _run_py("auto-commit", py_proj, "after_specify")
_assert_parity(b, p)
assert p.stderr.strip() == b.stderr.strip()
assert "No changes to commit" in p.stderr
def test_no_config_file_skips(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
for proj in (bash_proj, py_proj):
self._dirty(proj)
b = _run_bash("auto-commit.sh", bash_proj, "after_specify")
p = _run_py("auto-commit", py_proj, "after_specify")
_assert_parity(b, p)
assert self._last_message(py_proj) == "seed"
@pytest.mark.skipif(os.name != "posix", reason="POSIX file permissions")
def test_unreadable_config_skips_auto_commit(self, tmp_path: Path):
"""An unreadable config behaves like a missing one: no traceback, no commit."""
if os.geteuid() == 0:
pytest.skip("root bypasses file permissions")
proj = _setup_py_project(tmp_path / "proj")
config = _write_config(
proj, "auto_commit:\n after_specify:\n enabled: true\n"
)
self._dirty(proj)
config.chmod(0o000)
try:
p = _run_py("auto-commit", proj, "after_specify")
finally:
config.chmod(0o644)
assert p.returncode == 0
assert "Traceback" not in p.stderr
assert self._last_message(proj) == "seed"
def test_missing_event_argument_errors(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
b = _run_bash("auto-commit.sh", bash_proj)
p = _run_py("auto-commit", py_proj)
assert b.returncode == p.returncode == 1
def test_not_a_repo_skips(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path, git=False)
b = _run_bash("auto-commit.sh", bash_proj, "after_specify")
p = _run_py("auto-commit", py_proj, "after_specify")
_assert_parity(b, p)
assert "Not a Git repository" in p.stderr
class TestGitCommonPython:
"""Unit tests for git_common.py (imported directly)."""
@pytest.fixture()
def git_common(self):
sys.path.insert(0, str(EXT_PY))
try:
import git_common
yield git_common
finally:
sys.path.remove(str(EXT_PY))
sys.modules.pop("git_common", None)
def test_has_git(self, git_common, tmp_path: Path):
assert git_common.has_git(tmp_path) is False
_init_git(tmp_path)
assert git_common.has_git(tmp_path) is True
@pytest.mark.parametrize(
("branch", "expected"),
[
("001-feature-name", True),
("1234-feature-name", True),
("20260319-143022-feature-name", True),
("feat/004-name", True),
("main", False),
("2026031-143022", False),
("20260319-143022", False),
("2026031-143022-slug", False),
],
)
def test_check_feature_branch(self, git_common, branch: str, expected: bool):
assert git_common.check_feature_branch(branch, True) is expected
def test_check_feature_branch_no_git_warns_but_passes(self, git_common, capsys):
assert git_common.check_feature_branch("main", False) is True
assert "skipped branch validation" in capsys.readouterr().err

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
@@ -314,6 +374,18 @@ class TestInitIntegrationFlag:
assert (scripts_dir / "setup-plan.sh").exists()
assert (templates_dir / "plan-template.md").exists()
def test_shared_infra_installs_python_scripts_for_py(self, tmp_path):
from specify_cli import _install_shared_infra
project = tmp_path / "python-scripts"
project.mkdir()
_install_shared_infra(project, "py")
assert (
project / ".specify" / "scripts" / "python" / "common.py"
).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

View File

@@ -253,6 +253,7 @@ class MarkdownIntegrationTests:
"spec-template.md", "tasks-template.md"]:
files.append(f".specify/templates/{name}")
files.append(".specify/memory/.constitution-template.json")
files.append(".specify/memory/constitution.md")
# Bundled workflow
files.append(".specify/workflows/speckit/workflow.yml")

View File

@@ -399,6 +399,7 @@ class SkillsIntegrationTests:
".specify/integration.json",
f".specify/integrations/{self.KEY}.manifest.json",
".specify/integrations/speckit.manifest.json",
".specify/memory/.constitution-template.json",
".specify/memory/constitution.md",
]
# Script variant

View File

@@ -517,6 +517,7 @@ class TomlIntegrationTests:
]:
files.append(f".specify/templates/{name}")
files.append(".specify/memory/.constitution-template.json")
files.append(".specify/memory/constitution.md")
# Bundled workflow
files.append(".specify/workflows/speckit/workflow.yml")

View File

@@ -431,6 +431,7 @@ class YamlIntegrationTests:
]:
files.append(f".specify/templates/{name}")
files.append(".specify/memory/.constitution-template.json")
files.append(".specify/memory/constitution.md")
# Bundled workflow
files.append(".specify/workflows/speckit/workflow.yml")

View File

@@ -214,6 +214,7 @@ class TestClineIntegration(MarkdownIntegrationTests):
]:
files.append(f".specify/templates/{name}")
files.append(".specify/memory/.constitution-template.json")
files.append(".specify/memory/constitution.md")
# Bundled workflow
files.append(".specify/workflows/speckit/workflow.yml")

View File

@@ -252,6 +252,7 @@ class TestCopilotIntegration:
".specify/templates/plan-template.md",
".specify/templates/spec-template.md",
".specify/templates/tasks-template.md",
".specify/memory/.constitution-template.json",
".specify/memory/constitution.md",
".specify/workflows/speckit/workflow.yml",
".specify/workflows/workflow-registry.json",
@@ -313,6 +314,7 @@ class TestCopilotIntegration:
".specify/templates/plan-template.md",
".specify/templates/spec-template.md",
".specify/templates/tasks-template.md",
".specify/memory/.constitution-template.json",
".specify/memory/constitution.md",
".specify/workflows/speckit/workflow.yml",
".specify/workflows/workflow-registry.json",
@@ -724,6 +726,7 @@ class TestCopilotSkillsMode:
".specify/templates/plan-template.md",
".specify/templates/spec-template.md",
".specify/templates/tasks-template.md",
".specify/memory/.constitution-template.json",
".specify/memory/constitution.md",
# Bundled workflow
".specify/workflows/speckit/workflow.yml",

View File

@@ -286,6 +286,7 @@ class TestGenericIntegration:
".specify/integration.json",
".specify/integrations/generic.manifest.json",
".specify/integrations/speckit.manifest.json",
".specify/memory/.constitution-template.json",
".specify/memory/constitution.md",
".specify/scripts/bash/check-prerequisites.sh",
".specify/scripts/bash/common.sh",
@@ -342,6 +343,7 @@ class TestGenericIntegration:
".specify/integration.json",
".specify/integrations/generic.manifest.json",
".specify/integrations/speckit.manifest.json",
".specify/memory/.constitution-template.json",
".specify/memory/constitution.md",
".specify/scripts/powershell/check-prerequisites.ps1",
".specify/scripts/powershell/common.ps1",

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

@@ -2777,6 +2777,40 @@ def install_self_test_preset(manager: PresetManager, speckit_version: str = "0.1
return manager.install_from_directory(SELF_TEST_PRESET_DIR, speckit_version)
def _make_convention_constitution_preset(temp_dir: Path) -> Path:
"""Create a preset whose constitution is found by convention, not its manifest."""
preset_dir = temp_dir / "convention-constitution"
(preset_dir / "templates").mkdir(parents=True)
(preset_dir / "templates" / "constitution-template.md").write_text(
"# Convention Constitution\n"
)
(preset_dir / "templates" / "spec-template.md").write_text("# Spec\n")
(preset_dir / "preset.yml").write_text(
yaml.dump(
{
"schema_version": "1.0",
"preset": {
"id": "convention-constitution",
"name": "Convention Constitution",
"version": "1.0.0",
"description": "Convention-based constitution for testing",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "template",
"name": "spec-template",
"file": "templates/spec-template.md",
}
]
},
}
)
)
return preset_dir
class TestSelfTestPreset:
"""Tests using the self-test preset that ships with the repo.
@@ -2884,6 +2918,24 @@ class TestSelfTestPreset:
assert result is not None
assert result["source"] == "core"
memory = project_dir / ".specify" / "memory" / "constitution.md"
assert memory.read_text() == "# Core constitution-template\n"
def test_self_test_removal_preserves_edited_constitution(self, project_dir):
"""Removing a preset does not overwrite an edited generated constitution."""
templates_dir = project_dir / ".specify" / "templates"
(templates_dir / "constitution-template.md").write_text("# Core Constitution\n")
manager = PresetManager(project_dir)
install_self_test_preset(manager)
memory = project_dir / ".specify" / "memory" / "constitution.md"
edited = memory.read_text() + "\n## Authored amendment\n"
memory.write_text(edited)
manager.remove("self-test")
assert memory.read_text() == edited
def test_self_test_not_in_catalog(self):
"""Verify the self-test preset is NOT in the catalog (it's local-only)."""
catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json"
@@ -2958,6 +3010,432 @@ class TestSelfTestPreset:
metadata = manager.registry.get("self-test")
assert metadata["registered_commands"] == {}
def test_self_test_seeds_constitution_when_memory_absent(self, project_dir):
"""Installing a preset seeds memory/constitution.md from its template."""
manager = PresetManager(project_dir)
install_self_test_preset(manager)
memory = project_dir / ".specify" / "memory" / "constitution.md"
assert memory.exists(), "constitution.md was not seeded from the preset"
assert "preset:self-test" in memory.read_text(), (
"constitution.md was not seeded from the self-test preset template"
)
def test_self_test_reseeds_exact_core_constitution(self, project_dir):
"""An unchanged core constitution is re-seeded from the preset template."""
resolver = PresetResolver(project_dir)
bundled_core = resolver._find_bundled_core(
"constitution-template", "template", ".md"
)
assert bundled_core is not None
core = bundled_core.read_bytes()
memory = project_dir / ".specify" / "memory" / "constitution.md"
memory.parent.mkdir(parents=True, exist_ok=True)
memory.write_bytes(core)
manager = PresetManager(project_dir)
install_self_test_preset(manager)
content = memory.read_text()
assert "preset:self-test" in content, "placeholder constitution was not re-seeded"
assert "[PROJECT_NAME]" not in content
@pytest.mark.parametrize(
"provenance_content",
[
'{"sha256": "does-not-match", "source": "old-preset"}\n',
"{not valid json",
],
ids=["hash-mismatch", "malformed"],
)
def test_self_test_preserves_core_content_with_existing_invalid_provenance(
self, project_dir, provenance_content
):
"""A present invalid sidecar disables legacy core-template migration."""
resolver = PresetResolver(project_dir)
bundled_core = resolver._find_bundled_core(
"constitution-template", "template", ".md"
)
assert bundled_core is not None
memory = project_dir / ".specify" / "memory" / "constitution.md"
memory.parent.mkdir(parents=True, exist_ok=True)
memory.write_bytes(bundled_core.read_bytes())
(memory.parent / ".constitution-template.json").write_text(
provenance_content
)
original = memory.read_bytes()
manager = PresetManager(project_dir)
install_self_test_preset(manager)
assert memory.read_bytes() == original
def test_self_test_preserves_mutable_project_core_copy(self, project_dir):
"""A project template copy does not establish generated provenance."""
authored = "# Acme Organization Constitution\n\nOrganization policy.\n"
project_template = (
project_dir / ".specify" / "templates" / "constitution-template.md"
)
project_template.write_text(authored)
memory = project_dir / ".specify" / "memory" / "constitution.md"
memory.parent.mkdir(parents=True, exist_ok=True)
memory.write_text(authored)
manager = PresetManager(project_dir)
install_self_test_preset(manager)
assert memory.read_text() == authored
assert not (memory.parent / ".constitution-template.json").exists()
def test_core_prefixed_preset_does_not_establish_generated_provenance(
self, project_dir, temp_dir
):
"""A preset ID beginning with core is not an immutable core source."""
authored = "# Acme Organization Constitution\n\nOrganization policy.\n"
memory = project_dir / ".specify" / "memory" / "constitution.md"
memory.parent.mkdir(parents=True, exist_ok=True)
memory.write_text(authored)
preset_dir = temp_dir / "core-company"
(preset_dir / "templates").mkdir(parents=True)
(preset_dir / "templates" / "constitution-template.md").write_text(authored)
(preset_dir / "preset.yml").write_text(
yaml.safe_dump(
{
"schema_version": "1.0",
"preset": {
"id": "core-company",
"name": "Core Company",
"version": "1.0.0",
"description": "Company constitution preset",
"author": "Test Author",
"repository": "https://github.com/test/core-company",
"license": "MIT",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "template",
"name": "constitution-template",
"file": "templates/constitution-template.md",
"description": "Company constitution",
"replaces": "constitution-template",
}
]
},
}
)
)
PresetManager(project_dir).install_from_directory(preset_dir, "0.1.5")
assert memory.read_text() == authored
assert not (memory.parent / ".constitution-template.json").exists()
def test_self_test_preserves_authored_constitution_with_placeholder(
self, project_dir
):
"""A placeholder mention does not establish generated provenance."""
memory = project_dir / ".specify" / "memory" / "constitution.md"
memory.parent.mkdir(parents=True, exist_ok=True)
authored = "# Acme Constitution\n\nGuidance for [PROJECT_NAME].\n"
memory.write_text(authored)
manager = PresetManager(project_dir)
install_self_test_preset(manager)
assert memory.read_text() == authored
def test_self_test_preserves_authored_constitution(self, project_dir):
"""An authored (placeholder-free) constitution is never overwritten."""
memory = project_dir / ".specify" / "memory" / "constitution.md"
memory.parent.mkdir(parents=True, exist_ok=True)
authored = "# Acme Constitution\n\n### I. Ship It\nAuthored by a human.\n"
memory.write_text(authored)
manager = PresetManager(project_dir)
install_self_test_preset(manager)
assert memory.read_text() == authored, "authored constitution was overwritten"
def test_self_test_override_resolves_constitution_template(self, project_dir):
"""The preset override of constitution-template resolves to the preset file."""
templates_dir = project_dir / ".specify" / "templates"
(templates_dir / "constitution-template.md").write_text("# Core constitution\n")
manager = PresetManager(project_dir)
install_self_test_preset(manager)
resolver = PresetResolver(project_dir)
result = resolver.resolve("constitution-template", "template")
assert result is not None
assert "preset:self-test" in result.read_text()
def test_constitution_seed_composes_wrap_strategy(self, project_dir, temp_dir):
"""Seeding memory composes wrap constitution-template layers."""
templates_dir = project_dir / ".specify" / "templates"
templates_dir.mkdir(parents=True, exist_ok=True)
(templates_dir / "constitution-template.md").write_text(
"# Core Constitution\n\n## Core Principle\n"
)
preset_dir = temp_dir / "constitution-wrap"
(preset_dir / "templates").mkdir(parents=True)
(preset_dir / "templates" / "constitution-template.md").write_text(
"# Wrapper Constitution\n\n{CORE_TEMPLATE}\n\n## Wrapper Footer\n"
)
(preset_dir / "preset.yml").write_text(
yaml.dump(
{
"schema_version": "1.0",
"preset": {
"id": "constitution-wrap",
"name": "Constitution Wrap",
"version": "1.0.0",
"description": "Wrap constitution template for testing",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "template",
"name": "constitution-template",
"file": "templates/constitution-template.md",
"strategy": "wrap",
"description": "Wrapped constitution template",
}
]
},
}
)
)
manager = PresetManager(project_dir)
manager.install_from_directory(preset_dir, "0.1.5")
memory = project_dir / ".specify" / "memory" / "constitution.md"
content = memory.read_text()
assert "{CORE_TEMPLATE}" not in content
assert "# Wrapper Constitution" in content
assert "## Core Principle" in content
def test_constitution_follows_priority_when_winning_preset_removed(
self, project_dir, temp_dir
):
"""An unchanged generated constitution follows priority and fallback layers."""
manager = PresetManager(project_dir)
install_self_test_preset(manager)
preset_dir = temp_dir / "higher-priority"
(preset_dir / "templates").mkdir(parents=True)
(preset_dir / "templates" / "constitution-template.md").write_text(
"# Higher Priority Constitution\n"
)
(preset_dir / "preset.yml").write_text(
yaml.dump(
{
"schema_version": "1.0",
"preset": {
"id": "higher-priority",
"name": "Higher Priority",
"version": "1.0.0",
"description": "Higher-priority constitution",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "template",
"name": "constitution-template",
"file": "templates/constitution-template.md",
"strategy": "replace",
"description": "Higher-priority constitution",
}
]
},
}
)
)
manager.install_from_directory(preset_dir, "0.1.5", priority=1)
memory = project_dir / ".specify" / "memory" / "constitution.md"
assert memory.read_text() == "# Higher Priority Constitution\n"
manager.remove("higher-priority")
assert "preset:self-test" in memory.read_text()
def test_convention_constitution_removal_restores_remaining_layer(
self, project_dir, temp_dir
):
"""Removing a convention layer rematerializes the remaining resolver layer."""
manager = PresetManager(project_dir)
install_self_test_preset(manager)
manager.install_from_directory(
_make_convention_constitution_preset(temp_dir), "0.1.5", priority=1
)
memory = project_dir / ".specify" / "memory" / "constitution.md"
assert memory.read_text() == "# Convention Constitution\n"
manager.remove("convention-constitution")
assert "preset:self-test" in memory.read_text()
def test_convention_constitution_removal_preserves_edited_content(
self, project_dir, temp_dir
):
"""Removing a convention layer does not overwrite edited generated content."""
from specify_cli.commands.init import ensure_constitution_from_template
templates_dir = project_dir / ".specify" / "templates"
(templates_dir / "constitution-template.md").write_text("# Core Constitution\n")
manager = PresetManager(project_dir)
manager.install_from_directory(
_make_convention_constitution_preset(temp_dir), "0.1.5"
)
ensure_constitution_from_template(project_dir)
memory = project_dir / ".specify" / "memory" / "constitution.md"
edited = memory.read_text() + "\n## Authored amendment\n"
memory.write_text(edited)
manager.remove("convention-constitution")
assert memory.read_text() == edited
def test_custom_constitution_removal_recovers_with_invalid_manifest(
self, project_dir, temp_dir
):
"""Provenance triggers fallback when a custom-path manifest is invalid."""
manager = PresetManager(project_dir)
install_self_test_preset(manager)
preset_dir = temp_dir / "custom-constitution"
(preset_dir / "policy").mkdir(parents=True)
(preset_dir / "policy" / "charter.md").write_text("# Custom Constitution\n")
(preset_dir / "preset.yml").write_text(
yaml.dump(
{
"schema_version": "1.0",
"preset": {
"id": "custom-constitution",
"name": "Custom Constitution",
"version": "1.0.0",
"description": "Custom-path constitution for testing",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "template",
"name": "constitution-template",
"file": "policy/charter.md",
}
]
},
}
)
)
manager.install_from_directory(preset_dir, "0.1.5", priority=1)
memory = project_dir / ".specify" / "memory" / "constitution.md"
assert memory.read_text() == "# Custom Constitution\n"
installed_manifest = (
project_dir
/ ".specify"
/ "presets"
/ "custom-constitution"
/ "preset.yml"
)
installed_manifest.write_text("invalid: [")
manager.remove("custom-constitution")
assert "preset:self-test" in memory.read_text()
def test_constitution_seed_rejects_symlinked_memory_directory(
self, project_dir, temp_dir
):
"""Preset installation cannot seed through a symlinked memory directory."""
outside = temp_dir / "outside"
outside.mkdir()
try:
(project_dir / ".specify" / "memory").symlink_to(
outside, target_is_directory=True
)
except OSError:
pytest.skip("symlinks are unavailable")
manager = PresetManager(project_dir)
with pytest.warns(UserWarning, match="symlinked"):
install_self_test_preset(manager)
assert manager.registry.is_installed("self-test")
assert not (outside / "constitution.md").exists()
def test_constitution_seed_rejects_dangling_destination_symlink(
self, project_dir, temp_dir
):
"""Preset installation cannot seed through a dangling destination symlink."""
memory = project_dir / ".specify" / "memory"
memory.mkdir(parents=True)
outside = temp_dir / "outside-constitution.md"
try:
(memory / "constitution.md").symlink_to(outside)
except OSError:
pytest.skip("symlinks are unavailable")
manager = PresetManager(project_dir)
with pytest.warns(UserWarning, match="symlinked"):
install_self_test_preset(manager)
assert manager.registry.is_installed("self-test")
assert not outside.exists()
def test_constitution_materialization_error_is_nonfatal(
self, project_dir, temp_dir
):
"""An invalid wrap warns without reporting an uninstalled preset."""
preset_dir = temp_dir / "invalid-wrap"
(preset_dir / "templates").mkdir(parents=True)
(preset_dir / "templates" / "constitution-template.md").write_text(
"# Missing core placeholder\n"
)
(preset_dir / "preset.yml").write_text(
yaml.dump(
{
"schema_version": "1.0",
"preset": {
"id": "invalid-wrap",
"name": "Invalid Wrap",
"version": "1.0.0",
"description": "Invalid wrapping constitution",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "template",
"name": "constitution-template",
"file": "templates/constitution-template.md",
"strategy": "wrap",
"description": "Invalid wrap",
}
]
},
}
)
)
manager = PresetManager(project_dir)
with pytest.warns(UserWarning, match="Failed to seed constitution"):
manifest = manager.install_from_directory(preset_dir, "0.1.5")
assert manifest.id == "invalid-wrap"
assert manager.registry.is_installed("invalid-wrap")
def test_extension_command_skipped_when_extension_missing(self, project_dir, temp_dir):
"""Test that extension command overrides are skipped if the extension isn't installed."""
claude_dir = project_dir / ".claude" / "skills"
@@ -4308,6 +4786,31 @@ class TestPresetSetPriority:
manager2 = PresetManager(project_dir)
assert manager2.registry.get("test-pack")["priority"] == 5
def test_set_priority_reconciles_generated_constitution(
self, project_dir, temp_dir
):
"""Changing priority rematerializes an unchanged generated constitution."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
manager = PresetManager(project_dir)
install_self_test_preset(manager)
manager.install_from_directory(
_make_convention_constitution_preset(temp_dir), "0.1.5", priority=20
)
memory = project_dir / ".specify" / "memory" / "constitution.md"
assert "preset:self-test" in memory.read_text()
with patch.object(Path, "cwd", return_value=project_dir):
result = CliRunner().invoke(
app,
["preset", "set-priority", "convention-constitution", "1"],
)
assert result.exit_code == 0, result.output
assert memory.read_text() == "# Convention Constitution\n"
def test_set_priority_same_value_no_change(self, project_dir, pack_dir):
"""Test set-priority with same value shows already set message."""
from typer.testing import CliRunner
@@ -4528,6 +5031,61 @@ class TestPresetEnableDisable:
manager2 = PresetManager(project_dir)
assert manager2.registry.get("test-pack")["enabled"] is True
def test_enable_disable_reconciles_generated_constitution(
self, project_dir, temp_dir
):
"""Enable and disable rematerialize the winning constitution layer."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
manager = PresetManager(project_dir)
install_self_test_preset(manager)
manager.install_from_directory(
_make_convention_constitution_preset(temp_dir), "0.1.5", priority=1
)
memory = project_dir / ".specify" / "memory" / "constitution.md"
assert memory.read_text() == "# Convention Constitution\n"
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
disabled = runner.invoke(
app, ["preset", "disable", "convention-constitution"]
)
assert disabled.exit_code == 0, disabled.output
assert "preset:self-test" in memory.read_text()
with patch.object(Path, "cwd", return_value=project_dir):
enabled = runner.invoke(
app, ["preset", "enable", "convention-constitution"]
)
assert enabled.exit_code == 0, enabled.output
assert memory.read_text() == "# Convention Constitution\n"
def test_stack_changes_do_not_create_missing_constitution(
self, project_dir, pack_dir
):
"""Stack changes for non-providers do not seed a missing constitution."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
PresetManager(project_dir).install_from_directory(pack_dir, "0.1.5")
memory = project_dir / ".specify" / "memory" / "constitution.md"
runner = CliRunner()
for args in (
["preset", "set-priority", "test-pack", "5"],
["preset", "disable", "test-pack"],
["preset", "enable", "test-pack"],
):
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, args)
assert result.exit_code == 0, result.output
assert not memory.exists()
def test_disable_already_disabled(self, project_dir, pack_dir):
"""Test disable on already disabled preset shows warning."""
from typer.testing import CliRunner
@@ -6678,3 +7236,130 @@ def test_preset_wrapper_resolves_ghes_asset_when_host_configured(tmp_path, monke
)
assert resolved == "https://ghes.example/api/v3/repos/o/r/releases/assets/9"
assert captured == ["https://ghes.example/api/v3/repos/o/r/releases/tags/v2"]
# ===== ensure_constitution_from_template resolver-awareness =====
class TestEnsureConstitutionResolverAware:
"""`ensure_constitution_from_template` must resolve through PresetResolver.
The constitution is the only template materialized to a live file rather
than resolved on demand. These tests pin the regression from issue #3272:
a preset-provided ``constitution-template`` must seed memory, while the
core template is used when no preset overrides it.
"""
def _core_constitution(self, project_dir):
templates_dir = project_dir / ".specify" / "templates"
templates_dir.mkdir(parents=True, exist_ok=True)
(templates_dir / "constitution-template.md").write_text(
"# [PROJECT_NAME] Constitution\n\n### [PRINCIPLE_1_NAME]\n"
)
def _wrap_constitution_preset(self, temp_dir):
preset_dir = temp_dir / "ensure-wrap-preset"
(preset_dir / "templates").mkdir(parents=True)
(preset_dir / "templates" / "constitution-template.md").write_text(
"# Ensure Wrapper\n\n{CORE_TEMPLATE}\n\n## Tail\n"
)
(preset_dir / "preset.yml").write_text(
yaml.dump(
{
"schema_version": "1.0",
"preset": {
"id": "ensure-wrap",
"name": "Ensure Wrap",
"version": "1.0.0",
"description": "Wrap strategy for ensure() coverage",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "template",
"name": "constitution-template",
"file": "templates/constitution-template.md",
"strategy": "wrap",
"description": "Wrapped constitution",
}
]
},
}
)
)
return preset_dir
def test_seeds_from_core_when_no_preset(self, project_dir):
from specify_cli.commands.init import ensure_constitution_from_template
self._core_constitution(project_dir)
ensure_constitution_from_template(project_dir)
memory = project_dir / ".specify" / "memory" / "constitution.md"
assert memory.exists()
assert "[PROJECT_NAME]" in memory.read_text()
assert (memory.parent / ".constitution-template.json").exists()
def test_seeds_from_preset_when_installed(self, project_dir):
from specify_cli.commands.init import ensure_constitution_from_template
self._core_constitution(project_dir)
manager = PresetManager(project_dir)
install_self_test_preset(manager)
# Remove the memory file seeded during install to test ensure() in
# isolation; it must re-seed from the preset, not the core template.
memory = project_dir / ".specify" / "memory" / "constitution.md"
memory.unlink()
ensure_constitution_from_template(project_dir)
assert memory.exists()
content = memory.read_text()
assert "preset:self-test" in content
assert "[PROJECT_NAME]" not in content
def test_preserves_existing_memory(self, project_dir):
from specify_cli.commands.init import ensure_constitution_from_template
self._core_constitution(project_dir)
memory = project_dir / ".specify" / "memory" / "constitution.md"
memory.parent.mkdir(parents=True, exist_ok=True)
authored = "# Acme Constitution\nAuthored.\n"
memory.write_text(authored)
ensure_constitution_from_template(project_dir)
assert memory.read_text() == authored
def test_preserves_edited_generated_memory(self, project_dir):
from specify_cli.commands.init import ensure_constitution_from_template
self._core_constitution(project_dir)
ensure_constitution_from_template(project_dir)
memory = project_dir / ".specify" / "memory" / "constitution.md"
authored = memory.read_text() + "\nAuthored amendment.\n"
memory.write_text(authored)
manager = PresetManager(project_dir)
install_self_test_preset(manager)
assert memory.read_text() == authored
def test_composes_wrap_strategy_when_ensuring(self, project_dir, temp_dir):
from specify_cli.commands.init import ensure_constitution_from_template
self._core_constitution(project_dir)
manager = PresetManager(project_dir)
manager.install_from_directory(self._wrap_constitution_preset(temp_dir), "0.1.5")
# Ensure we validate ensure() behavior directly.
memory = project_dir / ".specify" / "memory" / "constitution.md"
memory.unlink()
ensure_constitution_from_template(project_dir)
content = memory.read_text()
assert "{CORE_TEMPLATE}" not in content
assert "# Ensure Wrapper" in content
assert "[PROJECT_NAME]" in content

View File

@@ -586,6 +586,51 @@ class TestExpressions:
with pytest.raises(ValueError, match="unknown filter 'upper'"):
evaluate_expression("{{ inputs.text | upper('x') }}", ctx)
def test_filter_map_non_string_attr_raises(self):
# A non-string attribute (authoring mistake like `map(5)`) must raise a
# ValueError naming the problem, not leak the cryptic AttributeError
# from attr.split() that would escape the evaluator and crash the run.
import pytest
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(inputs={"rows": [{"id": "a"}, {"id": "b"}]})
with pytest.raises(ValueError, match="map: expected a string attribute name"):
evaluate_expression("{{ inputs.rows | map(5) }}", ctx)
def test_filter_join_non_string_separator_raises(self):
# A non-string separator (authoring mistake like `join(5)`) must raise a
# ValueError, not leak the cryptic AttributeError from str.join.
import pytest
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(inputs={"tags": ["a", "b"]})
with pytest.raises(ValueError, match="join: expected a string separator"):
evaluate_expression("{{ inputs.tags | join(5) }}", ctx)
def test_filter_contains_non_string_arg_on_string_raises(self):
# For a string value, `contains` requires a string argument: `x in y` on
# a string needs a string left operand. A non-string argument must raise
# a ValueError, not leak the cryptic TypeError that would crash the run.
import pytest
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(inputs={"text": "hello"})
with pytest.raises(ValueError, match="contains: expected a string argument"):
evaluate_expression("{{ inputs.text | contains(5) }}", ctx)
def test_filter_contains_non_string_arg_on_list_ok(self):
# For a list value, membership of any element type is legitimate, so a
# non-string argument stays valid and is not rejected.
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(inputs={"nums": [1, 2, 5]})
assert evaluate_expression("{{ inputs.nums | contains(5) }}", ctx) is True
assert evaluate_expression("{{ inputs.nums | contains(9) }}", ctx) is False
def test_registered_filters_unaffected(self):
# Regression: all five registered filters keep working unchanged.
from specify_cli.workflows.expressions import evaluate_expression
@@ -2067,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.
@@ -2200,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
@@ -2260,6 +2453,47 @@ class TestWhileStep:
assert result.output["condition_result"] is False
assert result.next_steps == []
@pytest.mark.parametrize("bad_steps", [{"id": "x"}, "oops", 5])
def test_execute_non_list_steps_fails_loudly(self, bad_steps):
"""A non-list ``steps`` reached at runtime must fail the step, not crash.
``validate`` rejects a non-list ``steps``, 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. The while body only dispatches when the condition is
truthy, so a non-list ``steps`` reaches ``next_steps`` and would crash
the engine's step iteration on an unvalidated run. Mirrors the
if/switch/fan-out non-list handling.
"""
from specify_cli.workflows.steps.while_loop import WhileStep
from specify_cli.workflows.base import StepContext, StepStatus
step = WhileStep()
ctx = StepContext(inputs={})
result = step.execute(
{"id": "retry", "condition": "true", "steps": bad_steps}, ctx
)
assert result.status == StepStatus.FAILED
assert "'steps' must be a list of steps" in (result.error or "")
assert result.next_steps == []
@pytest.mark.parametrize("bad_steps", [{"id": "x"}, "oops", 5])
def test_execute_non_list_steps_ok_when_condition_false(self, bad_steps):
"""A false condition never dispatches the body, so a non-list ``steps``
stays benign — the step completes without touching ``next_steps``.
"""
from specify_cli.workflows.steps.while_loop import WhileStep
from specify_cli.workflows.base import StepContext, StepStatus
step = WhileStep()
ctx = StepContext(inputs={})
result = step.execute(
{"id": "retry", "condition": "false", "steps": bad_steps}, ctx
)
assert result.status == StepStatus.COMPLETED
assert result.output["condition_result"] is False
assert result.next_steps == []
def test_validate_missing_fields(self):
from specify_cli.workflows.steps.while_loop import WhileStep
@@ -2350,6 +2584,30 @@ class TestDoWhileStep:
assert result.next_steps == []
assert result.status.value == "completed"
@pytest.mark.parametrize("bad_steps", [{"id": "x"}, "oops", 5])
def test_execute_non_list_steps_fails_loudly(self, bad_steps):
"""A non-list ``steps`` must fail the step, not crash the run.
``validate`` rejects a non-list ``steps``, 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. The do-while body always dispatches on the first call
regardless of condition, so a non-list ``steps`` always reaches
``next_steps`` and would crash the engine's step iteration on an
unvalidated run. Mirrors the if/switch/fan-out non-list handling.
"""
from specify_cli.workflows.steps.do_while import DoWhileStep
from specify_cli.workflows.base import StepContext, StepStatus
step = DoWhileStep()
ctx = StepContext(inputs={})
result = step.execute(
{"id": "cycle", "condition": "false", "steps": bad_steps}, ctx
)
assert result.status == StepStatus.FAILED
assert "'steps' must be a list of steps" in (result.error or "")
assert result.next_steps == []
def test_validate_missing_fields(self):
from specify_cli.workflows.steps.do_while import DoWhileStep
@@ -2662,8 +2920,13 @@ class TestFanOutConcurrency:
results, _ = self._run(tmp_path, list(range(n)), n, on_item)
assert results == [{"seen": i} for i in range(n)]
@pytest.mark.parametrize("bad", [0, -1, None, "abc", 1.0])
@pytest.mark.parametrize(
"bad", [0, -1, None, "abc", 1.0, float("inf"), float("nan")]
)
def test_invalid_max_concurrency_coerces_to_sequential(self, tmp_path, bad):
# float("inf") -> int() raises OverflowError (not TypeError/ValueError);
# it must fall back to sequential like any other uncoercible value, not
# crash the run.
results, _ = self._run(tmp_path, list(range(4)), bad)
assert results == [{"seen": i} for i in range(4)]
@@ -4446,6 +4709,286 @@ steps:
assert state.step_results["stamp"]["output"]["stdout"].strip() == "explicit-456"
# ===== context.workflow_dir Tests =====
class TestContextWorkflowDir:
"""Tests for `{{ context.workflow_dir }}` and `SPECKIT_WORKFLOW_DIR`."""
def test_context_workflow_dir_resolves(self):
"""``{{ context.workflow_dir }}`` resolves to ``StepContext.workflow_dir``."""
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(workflow_dir="/home/user/my-workflow")
assert evaluate_expression("{{ context.workflow_dir }}", ctx) == "/home/user/my-workflow"
def test_context_workflow_dir_defaults_to_empty_when_unset(self):
"""``{{ context.workflow_dir }}`` resolves to ``""`` when no source
path is available (string-loaded workflows, dry-run).
"""
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext()
assert evaluate_expression("{{ context.workflow_dir }}", ctx) == ""
def test_context_workflow_dir_string_interpolation(self):
"""Workflow dir interpolates inside a larger template string."""
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(workflow_dir="/opt/workflows/setup")
result = evaluate_expression("cp {{ context.workflow_dir }}/config.yml .", ctx)
assert result == "cp /opt/workflows/setup/config.yml ."
def test_step_context_workflow_dir(self):
"""StepContext accepts and stores workflow_dir."""
from specify_cli.workflows.base import StepContext
ctx = StepContext(workflow_dir="/some/path")
assert ctx.workflow_dir == "/some/path"
ctx_none = StepContext()
assert ctx_none.workflow_dir is None
def test_from_yaml_sets_workflow_dir(self, project_dir):
"""Workflow loaded from a YAML file has workflow_dir set to the
file's parent directory.
"""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
wf_dir = project_dir / "my-workflows"
wf_dir.mkdir()
wf_file = wf_dir / "setup.yml"
wf_file.write_text("""
schema_version: "1.0"
workflow:
id: "from-yaml"
name: "From YAML"
version: "1.0.0"
steps:
- id: check-dir
type: shell
run: "echo DIR={{ context.workflow_dir }}"
""")
definition = WorkflowDefinition.from_yaml(wf_file)
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
stdout = state.step_results["check-dir"]["output"]["stdout"]
assert stdout.strip() == f"DIR={wf_dir.resolve()}"
def test_from_string_has_empty_workflow_dir(self, project_dir):
"""String-loaded workflows have empty workflow_dir."""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "from-string"
name: "From String"
version: "1.0.0"
steps:
- id: check-dir
type: shell
run: "echo DIR={{ context.workflow_dir }}"
""")
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
stdout = state.step_results["check-dir"]["output"]["stdout"]
assert stdout.strip() == "DIR="
def test_shell_step_receives_speckit_workflow_dir_env_var(self, project_dir):
"""Shell steps receive SPECKIT_WORKFLOW_DIR in their environment."""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
import sys
wf_dir = project_dir / "wf"
wf_dir.mkdir()
wf_file = wf_dir / "workflow.yml"
python = sys.executable.replace("\\", "/")
wf_file.write_text(f"""
schema_version: "1.0"
workflow:
id: "env-var-test"
name: "Env Var Test"
version: "1.0.0"
steps:
- id: print-env
type: shell
run: '"{python}" -c "import os; print(os.environ.get(''SPECKIT_WORKFLOW_DIR'', ''UNSET''))"'
""")
definition = WorkflowDefinition.from_yaml(wf_file)
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
stdout = state.step_results["print-env"]["output"]["stdout"]
assert stdout.strip() == str(wf_dir.resolve())
def test_shell_step_no_env_var_when_workflow_dir_unset(self, project_dir, monkeypatch):
"""Shell steps do not set SPECKIT_WORKFLOW_DIR for string-loaded workflows."""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
import sys
monkeypatch.delenv("SPECKIT_WORKFLOW_DIR", raising=False)
python = sys.executable.replace("\\", "/")
definition = WorkflowDefinition.from_string(f"""
schema_version: "1.0"
workflow:
id: "no-env-var"
name: "No Env Var"
version: "1.0.0"
steps:
- id: check-env
type: shell
run: '"{python}" -c "import os; print(os.environ.get(''SPECKIT_WORKFLOW_DIR'', ''UNSET''))"'
""")
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
stdout = state.step_results["check-env"]["output"]["stdout"]
assert stdout.strip() == "UNSET"
def test_resume_preserves_original_workflow_dir(self, project_dir):
"""Resumed workflow uses the original source directory, not the
run-directory copy path.
"""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
from specify_cli.workflows.base import RunStatus
wf_dir = project_dir / "original-source"
wf_dir.mkdir()
wf_file = wf_dir / "resumable.yml"
wf_file.write_text("""
schema_version: "1.0"
workflow:
id: "resumable"
name: "Resumable"
version: "1.0.0"
steps:
- id: gate-step
type: gate
message: "Approve?"
- id: after-gate
type: shell
run: "echo DIR={{ context.workflow_dir }}"
""")
definition = WorkflowDefinition.from_yaml(wf_file)
engine = WorkflowEngine(project_dir)
# Execute -- gate pauses the workflow
state = engine.execute(definition)
assert state.status == RunStatus.PAUSED
assert state.workflow_dir == str(wf_dir.resolve())
# Simulate gate approval by patching the gate step
from unittest.mock import patch
from specify_cli.workflows.base import StepResult
with patch(
"specify_cli.workflows.steps.gate.GateStep.execute",
return_value=StepResult(output={"approved": True}),
):
state = engine.resume(state.run_id)
assert state.status == RunStatus.COMPLETED
stdout = state.step_results["after-gate"]["output"]["stdout"]
assert stdout.strip() == f"DIR={wf_dir.resolve()}"
def test_workflow_dir_persisted_in_state(self, project_dir):
"""workflow_dir is persisted in state.json and survives load/save."""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine, RunState
wf_dir = project_dir / "persist-test"
wf_dir.mkdir()
wf_file = wf_dir / "workflow.yml"
wf_file.write_text("""
schema_version: "1.0"
workflow:
id: "persist-wfdir"
name: "Persist WfDir"
version: "1.0.0"
steps:
- id: noop
type: shell
run: "echo ok"
""")
definition = WorkflowDefinition.from_yaml(wf_file)
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
# Reload state from disk and verify workflow_dir survived
loaded = RunState.load(state.run_id, project_dir)
assert loaded.workflow_dir == str(wf_dir.resolve())
def test_installed_workflow_has_workflow_dir(self, project_dir):
"""Installed-by-ID workflows get workflow_dir pointing to the
installation directory (.specify/workflows/<id>/).
"""
from specify_cli.workflows.engine import WorkflowEngine
from specify_cli.workflows.base import RunStatus
wf_id = "installed-wfdir"
install_dir = project_dir / ".specify" / "workflows" / wf_id
install_dir.mkdir(parents=True)
(install_dir / "workflow.yml").write_text("""
schema_version: "1.0"
workflow:
id: "installed-wfdir"
name: "Installed WfDir"
version: "1.0.0"
steps:
- id: check-dir
type: shell
run: "echo DIR={{ context.workflow_dir }}"
""")
engine = WorkflowEngine(project_dir)
definition = engine.load_workflow(wf_id)
state = engine.execute(definition)
assert state.status == RunStatus.COMPLETED
stdout = state.step_results["check-dir"]["output"]["stdout"]
assert stdout.strip() == f"DIR={install_dir.resolve()}"
def test_workflow_dir_is_resolved_to_absolute(self, project_dir):
"""workflow_dir is resolved to an absolute path even when the
source path is relative.
"""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
import os
wf_dir = project_dir / "rel-test"
wf_dir.mkdir()
wf_file = wf_dir / "workflow.yml"
wf_file.write_text("""
schema_version: "1.0"
workflow:
id: "rel-path"
name: "Relative Path"
version: "1.0.0"
steps:
- id: check
type: shell
run: "echo ok"
""")
# Load via a relative path
saved_cwd = os.getcwd()
try:
os.chdir(project_dir)
rel_path = Path("rel-test/workflow.yml")
definition = WorkflowDefinition.from_yaml(rel_path)
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
finally:
os.chdir(saved_cwd)
assert Path(state.workflow_dir).is_absolute()
assert state.workflow_dir == str(wf_dir.resolve())
# ===== continue_on_error Tests =====
#
# Locks the contract documented in workflows/README.md "Error Handling"

View File

@@ -348,6 +348,7 @@ current run:
| Variable | Description |
|----------|-------------|
| `context.run_id` | The current workflow run id (the same value Spec Kit prints as `Run ID:` at the end of `workflow run`). Auto-generated runs are 8-character hex from `uuid4`; operator-supplied ids may be any alphanumeric string with hyphens or underscores. Empty string outside a run context. |
| `context.workflow_dir` | The resolved absolute path to the directory containing the workflow source file. For file-loaded workflows this is the parent directory of the YAML file; for installed-by-ID workflows it is the absolute path to the installation directory (e.g. `<project>/.specify/workflows/<id>/`); for string-loaded workflows it is an empty string. On resume the original source directory is preserved from the first execution. |
```yaml
# Stamp telemetry events with the run id for cross-system join.
@@ -365,6 +366,11 @@ current run:
command: speckit.specify
input:
args: "{{ context.run_id }}"
# Reference a sibling file shipped alongside the workflow definition.
- id: apply-config
type: shell
run: 'cp "{{ context.workflow_dir }}/defaults.yml" ./config.yml'
```
## Input Types
@@ -445,6 +451,7 @@ specify workflow catalog remove <index>
| Variable | Description |
|----------|-------------|
| `SPECKIT_WORKFLOW_CATALOG_URL` | Override the catalog URL (replaces all defaults) |
| `SPECKIT_WORKFLOW_DIR` | Set automatically for shell steps; contains the resolved absolute path to the workflow source directory (same value as `{{ context.workflow_dir }}`). Not set when the workflow has no source path (string-loaded workflows). |
## Configuration Files