Compare commits

..

82 Commits

Author SHA1 Message Date
Ali jawwad
d1e86f6382 fix(workflows): fail a gate whose on_reject is not abort/skip/retry (#3888)
execute() reads `on_reject = config.get("on_reject", "abort")` and, in the
reject branch, handles only "abort" and "retry" before falling through to
its `# on_reject == "skip"` case. So any other value makes a REJECTED gate
report COMPLETED and the run walks straight past the review the gate
exists to enforce:

  on_reject='abort'  -> failed     "Gate rejected by user at step 'g'"
  on_reject='retry'  -> paused
  on_reject='skip'   -> completed  (by design)
  on_reject='Abort'  -> completed  <-- rejection silently discarded
  on_reject='fail'   -> completed  <-- same
  on_reject='stop'   -> completed  <-- same
  on_reject=None     -> completed  <-- same
  on_reject=5        -> completed  <-- same

Reachable by a capitalisation slip, a guessed verb, a non-string, or a
bare `on_reject:` — note `config.get(k, default)` does NOT substitute the
default for an explicit YAML null.

`validate` already rejects anything outside abort/skip/retry, but the
engine does not auto-validate before execute(). Fail loudly instead,
mirroring the `options` and `verdict_input` guards in the same method, and
placed before the non-TTY short-circuit so it surfaces in CI too.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:45:15 -05:00
Marsel Safin
400ad01f12 fix(presets): validate required manifest mappings (#3898)
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-31 12:15:18 -05:00
Quratulain-bilal
642fa56c0a fix: eliminate TOCTOU race in zip packaging (#3855)
* fix: eliminate TOCTOU race in zip packaging

Open file once and derive both stat info and content from the same file
descriptor to prevent race conditions where the file is modified between
stat() and read_bytes() calls.

* test: add regression test for TOCTOU stat/read consistency in packager

The old implementation called file_path.stat() then file_path.read_bytes()
as separate syscalls. The fix opens the file once and uses os.fstat() +
fh.read() on the same handle. This test verifies the archived bytes and
mode are consistent with the opened file descriptor.
2026-07-31 12:05:47 -05:00
Ali jawwad
521020bc3a fix(workflows): fail a fan-in step whose output is not a mapping (#3887)
execute() did:

    output_config = config.get("output") or {}
    if not isinstance(output_config, dict):
        output_config = {}

so every non-mapping `output` was silently discarded and the step still
returned COMPLETED — every declared aggregation key vanished, and
downstream `{{ steps.<id>.output.<key> }}` resolved to None and
interpolated as an empty string:

  output=[]       -> completed, error=None
  output=False    -> completed, error=None
  output=0        -> completed, error=None
  output=''       -> completed, error=None
  output=['a']    -> completed, error=None
  output='oops'   -> completed, error=None
  output=5        -> completed, error=None

`validate` already rejects this and its comment names the flaw exactly:
"execute() silently coerces a non-mapping output to {}, so the author's
declared aggregation keys would vanish with no error." The engine does not
auto-validate before execute(), so on an unvalidated run that is what
happened — and `x or {}` masked the falsy shapes before the isinstance
check even ran.

Fail loudly with validate()'s own message, mirroring the `wait_for` guard
in the same method. An explicit `output:` (YAML null) stays valid.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:04:02 -05:00
Marsel Safin
14e82353cb fix(workflows): refetch non-UTF-8 catalog caches (#3901)
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-31 12:02:49 -05:00
Marsel Safin
1831fffde6 fix(bundler): wrap local catalog decode failures (#3902)
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-31 11:52:29 -05:00
Manfred Riem
ba7ae79c66 Add --extension flag to specify init for opting into extensions at init time (#3914)
* Add --extension flag to specify init for installing extensions at init time

Adds a repeatable --extension flag to `specify init` so users can opt into
extensions (bundled name, local path, or HTTPS URL) during initialization,
without a separate `specify extension add` step.

- New `_install_extension_during_init` helper in commands/init.py that
  auto-detects source type (URL / local path / bundled name / catalog) and
  installs via ExtensionManager. Failures are non-fatal and recorded in the
  tracker without aborting init.
- Extension tracker steps are pre-registered before the Live context and run
  after preset install, before finalize.
- Five new tests in TestExtensionFlag covering bundled name, multiple
  extensions, local absolute path, unknown extension (graceful error), and
  combination with --preset.

Rebased onto upstream/main and adapted to the refactored init command
(moved to src/specify_cli/commands/init.py) from stale PR #2396.

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

* Address review: reuse hardened downloader, refresh events, escape labels, fix bundler call

Responds to review feedback on #3914 and fixes CI (pytest bundler failure).

- Extract shared `install_extension_from_url` helper in extensions/_commands.py
  that reuses the authenticated, redirect-guarded, bounded (50 MiB) download
  and TOCTOU-safe transient archive used by `extension add --from`. Both
  `extension add --from` and `specify init --extension <url>` now go through
  this single downloader instead of a second raw urlopen path.
- Refresh native event configuration once after successful extension installs
  during init (mirrors `_refresh_events_and_warn` in the add path) so an
  extension declaring `events:` has its hooks activated.
- Escape user-controlled extension specs and error text before interpolating
  them into StepTracker labels (Rich markup injection).
- Pass `extensions=None` from bundler's `_run_init` so the init callback no
  longer receives the typer OptionInfo sentinel ('OptionInfo' object is not
  iterable), which broke `test_install_initializes_uninitialized_project`.
- Add init URL coverage in TestExtensionFlag: non-HTTPS rejection and a
  successful HTTPS ZIP install with download-cache cleanup assertion.

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

* Add default-deny trust confirmation for URL extension installs at init

URL-based --extension installs now require explicit trust, matching the
`extension add --from` posture. Interactive sessions show an "Untrusted
Source" panel and prompt (default no); non-interactive sessions deny by
default unless --trust-extension-urls is passed. Trust is resolved before
the Live display since the prompt can't be answered under the spinner.

- Add --trust-extension-urls option and _ext_spec_is_url /
  _confirm_extension_url_trust helpers
- Skip (not abort) unconfirmed URL extensions, consistent with other
  non-fatal extension failures
- Pass trust_extension_urls=False from the bundler init callback
- Add tests for deny-by-default, interactive confirm, and trusted install

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8bc6802d-81b8-48f4-8f60-cba3aebc3bb3

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8bc6802d-81b8-48f4-8f60-cba3aebc3bb3
2026-07-31 11:15:25 -05:00
Quratulain-bilal
36cb7e3c11 fix: bound response reads in extension catalog and download (#3775)
* fix: bound response reads in extension catalog and download

Replace unbounded 
esponse.read() calls with 
ead_response_limited()
from _download_security in extensions/__init__.py to prevent denial-
of-service via oversized catalog or extension archive responses.

Three call sites fixed:
- _fetch_single_catalog JSON read (catalog metadata)
- _fetch_catalog JSON read (legacy path)
- download_extension ZIP read (binary download)

All existing mock tests updated to use side_effect with BytesIO.read
instead of 
eturn_value, ensuring compatibility with the chunked read
loop in 
ead_response_limited.

Two regression tests added:
- test_oversized_catalog_response_rejected
- test_oversized_extension_download_rejected

* fix: remove .decode utf-8 to preserve bytes for json.loads

json.loads accepts bytes directly. Removing .decode maintains
compatibility with BOM-bearing or UTF-16/32 catalogs.
2026-07-31 10:02:25 -05:00
Noor ul ain
cf71d00dfe fix(workflows): reject a retry gate whose verdict enum forbids the reset value (#3912)
A gate with `on_reject: retry` consumes a bound reject verdict before
pausing by resetting the named input to `""` (documented behaviour, so a
later resume prompts again). Every `resume()` re-resolves the persisted
inputs through `_coerce_input`.

Those two rules collide when the bound input declares an `enum` that does
not list `""`. The reset writes a value the input's own enum forbids, and
the run wedges:

    inputs:
      spec_verdict:
        type: string
        enum: [approve, reject]
    steps:
      - id: review
        type: gate
        options: [approve, reject]
        on_reject: retry
        verdict_input: spec_verdict

    $ specify workflow run wf --input spec_verdict=reject
    Status: paused
    $ specify workflow resume <run_id> --input note=b
    Error: Input 'spec_verdict' value '' not in allowed values:
           ['approve', 'reject'].

The workflow validates clean and the first run looks fine, so the failure
only appears at the second resume. It is also unrecoverable in practice:
`_resolve_inputs` re-coerces the whole persisted map, so *any* resume that
supplies an input dies on the stored `""`. Only a resume with no inputs at
all still works -- and that is precisely the call that cannot deliver a new
verdict, which is the one thing the retry cycle exists to allow.

Extend the existing `verdict_input` cross-check (which already confirms the
name is declared) to also require that a retry-bound input's `enum` admits
the reset sentinel, and report it with a fix hint. To do that, thread the
input *definitions* through `_validate_steps` instead of just their names.

Rejected the alternative of popping the key instead of writing `""`: that
lets the input's `default` flow back in on the next resume, so a gate the
user just rejected would silently auto-approve.

Docs: note the `enum` requirement next to the reset behaviour it follows
from.

Adds 4 validation tests for the new guard plus a characterization test that
drives the engine directly to pin the wedge it prevents.

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

Assisted-by: Claude Opus 5 (1M context)
2026-07-31 09:53:25 -05:00
Manfred Riem
7f40c82945 chore: release 0.15.1, begin 0.15.2.dev0 development (#3913)
* chore: bump version to 0.15.1

* chore: begin 0.15.2.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-31 08:38:33 -05:00
Noor ul ain
184de79749 fix: escape Rich markup in workflow resolve output (#3879)
`workflow resolve` printed two lines through `console.print`, which has
Rich markup enabled, without escaping:

1. The layer tier was wrapped in literal brackets:
   `f"  • [{layer.tier}] {layer.source} ..."`. Rich parsed `[base]`
   and `[project-overlay]` as style tags, so the tier label was swallowed
   on *every* invocation -- no untrusted input required. The column has
   never rendered.

2. Step attribution interpolated `composed.step_id` raw. Step IDs come
   from base-workflow / overlay YAML and are only validated against `:`
   (see `_parse_edit`), so brackets pass validation. A balanced
   `[stuff]` is silently swallowed; an unbalanced `[/red]` raises
   `rich.errors.MarkupError`, producing an uncaught traceback and exit 1
   -- the workflow cannot be inspected at all.

Route the interpolated fields through `rich.markup.escape` and escape
the literal tier bracket as `\[`, matching the existing pattern in
`workflow info`'s step graph and `workflow_list`'s `\[disabled]`.

Only display is affected; the returned payload was already unescaped and
is unchanged.

Adds 3 regression tests, all of which fail without the fix: the tier
label renders, and a step ID survives both the swallowing and the
crashing markup cases.

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

Assisted-by: Claude Opus 5 (1M context)
2026-07-31 08:36:44 -05:00
dependabot[bot]
d82c915f9f chore(deps): bump actions/stale from 10.4.0 to 11.0.0 (#3877)
Bumps [actions/stale](https://github.com/actions/stale) from 10.4.0 to 11.0.0.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](1e223db275...4391f3da66)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-31 07:27:08 -05:00
dependabot[bot]
acd8b801fd chore(deps): bump actions/setup-python from 6.3.0 to 7.0.0 (#3876)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6.3.0 to 7.0.0.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](ece7cb06ca...5fda3b95a4)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-31 07:25:00 -05:00
Manfred Riem
a3e183d069 feat: support tar archives for installs (#3874)
* feat: support tar archives for installs

Add secure .tar.gz and .tgz parity with ZIP installation for extensions, presets, and workflows, including full workflow package preservation.

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

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

Copilot-Session: bd07c6b3-f1f9-484c-869a-94d8fef970dd

* chore: clean rebased archive imports

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

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

Copilot-Session: bd07c6b3-f1f9-484c-869a-94d8fef970dd

* fix: preserve hardened archive install behavior

Keep malformed ZIP diagnostics, filesystem-independent manifest selection, and reserved workflow overlays consistent after adding generic archive support.

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

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

Copilot-Session: bd07c6b3-f1f9-484c-869a-94d8fef970dd

* fix: extract staged workflow archives by descriptor

Avoid reopening a held staging path on Windows while retaining authoritative-inode archive validation.

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

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

Copilot-Session: bd07c6b3-f1f9-484c-869a-94d8fef970dd

* fix: extract catalog archives from verified bytes

Use the already bounded and SHA-verified response bytes directly so Windows file-sharing semantics cannot affect archive detection.

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

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

Copilot-Session: bd07c6b3-f1f9-484c-869a-94d8fef970dd

* fix: address archive install review feedback

Preserve forced preset reinstalls, sniff suffixless workflow archives without weakening YAML limits, and restore prior workflow packages before failed-install cleanup.

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

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

Copilot-Session: bd07c6b3-f1f9-484c-869a-94d8fef970dd

---------

Copilot-Session: bd07c6b3-f1f9-484c-869a-94d8fef970dd
2026-07-31 07:23:00 -05:00
Quratulain-bilal
6bf51e728a fix: eliminate TOCTOU race in file unlink calls (#3819) 2026-07-31 07:19:25 -05:00
Ali jawwad
5e2f9bcd9b fix(scripts): tolerate an unusable integration.json in the Python helper (#3785)
* fix(scripts): tolerate an unusable integration.json in the Python helper

`get_invoke_separator()` in scripts/python/common.py indexed the parsed JSON
directly, so two shapes escaped its `except (OSError, json.JSONDecodeError)`
while BOTH of its twins fall back to "." for them:

  * A non-mapping top level is valid JSON, so JSONDecodeError never fires and
    `state.get(...)` raised AttributeError.
  * A non-UTF-8 file raises UnicodeDecodeError -- a ValueError, not an OSError.
    Realistic on Windows, where PowerShell 5.1's Out-File/`>` default to UTF-16.

Measured on main -- 6 of 7 inputs crashed the Python helper while bash and
PowerShell 5.1 returned "." for every one:

    input                             python        bash   pwsh 5.1
    {"default_integration":"forge"}   '.'           .      .
    []                                AttributeError .      .
    "forge"                           AttributeError .      .
    42                                AttributeError .      .
    null                              AttributeError .      .
    UTF-16 file                       UnicodeDecodeError .  .

Split the parse out of the lookup, complete the exception tuple, and guard the
top-level shape -- matching `read_feature_json_feature_directory` in this same
module, which already does exactly this. The hyphen-separator feature is
unchanged (regression test included).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* docs(scripts): point the parity comment at the sibling above, not below

read_feature_json_feature_directory is defined at line 81, above
get_invoke_separator, so "below" sent maintainers the wrong way.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 14:22:38 -05:00
Ali jawwad
e4318a3d1a fix(catalogs): validate the port in the shared catalog-URL validator, like its mirrors do (#3804)
* fix(catalogs): validate the port in the shared catalog-URL validator

`CatalogStackBase._validate_catalog_url()` reads `parsed.hostname` inside
its `try/except ValueError` but never reads `parsed.port`. `urlparse()` and
`.hostname` do not perform port validation — only `.port` does — so a
catalog URL with a non-numeric or out-of-range port passes validation.

Every implementation that documents itself as mirroring this function
already reads `.port` inside the same try: workflows/catalog.py (4 sites),
bundler/services/adapters.py (2), bundler/commands_impl/catalog_config.py,
and commands/bundle/__init__.py. The shared base — inherited by
ExtensionCatalog and IntegrationCatalog — is the only one without it.

The accepted URL then escapes as a raw `http.client.InvalidURL`, which is
neither `urllib.error.URLError` nor `json.JSONDecodeError` (the only two
the fetcher converts), so it surfaces as an unhandled traceback rather
than the validator's normal error.

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

* docs(catalogs): describe both bad-port failure modes accurately

The comment attributed both malformed-port cases to
http.client.InvalidURL. Only a non-numeric port raises that (when the
connection object is built); an out-of-range port constructs fine and
fails later in the socket layer. Measured:

  example.invalid:notaport -> http.client.InvalidURL: nonnumeric port
  example.invalid:65536    -> HTTPSConnection() OK, connect() fails

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 13:41:17 -05:00
Manfred Riem
43a54bf2d6 feat(presets): add opt-in constitution-sync preset (#3873)
* feat(presets): add opt-in constitution-sync preset

Follow-up to #3790, which removed the consistency-propagation pass from the
core /constitution command in favor of runtime resolution. Teams that treat
materialized plan/spec/tasks templates as reviewed, committed artifacts lost
the auto-sync of amended constitutional guidance on a non-forced upgrade.

Add a bundled, opt-in `constitution-sync` preset that restores that behavior
via a wrap-strategy override of speckit.constitution (composes on {CORE_TEMPLATE}
so it stays forward-compatible). It only writes into the project's own
.specify/templates scaffolds and installed command files, never into
stack-owned template layers.

- presets/constitution-sync/: preset.yml (requires >=0.14.4), wrap command,
  README documenting the tension between auto-propagation and the resolution stack
- presets/catalog.json: bundled entry
- docs/upgrade.md: document the 0.14.4 behavior change and the opt-in
- tests/test_presets.py: structural + composition coverage (TestConstitutionSyncPreset)

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: afa7c1d2-147b-4f62-a6fc-a2cc824cfa3e

* fix(presets): ship constitution-sync in wheel, clarify scope guard, assert composition

Address review feedback on #3873:

- pyproject.toml: force-include presets/constitution-sync into the wheel's
  core_pack so `_locate_bundled_preset` resolves it in a released install; the
  bundled advertisement was otherwise unshippable.
- tests/contract/test_wheel_bundled_presets.py: new contract test asserting
  every bundled preset in presets/catalog.json is force-included (guards lean too).
- commands/speckit.constitution.md: explicitly state the propagation section
  supersedes the core Scope Guard, which otherwise says dependent templates are
  not modified here.
- tests/test_presets.py: assert resolve_content substitutes {CORE_TEMPLATE} and
  the effective command embeds both the core body and the sync pass.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: afa7c1d2-147b-4f62-a6fc-a2cc824cfa3e

* test(presets): parse frontmatter as YAML in constitution-sync wrapper test

Address review feedback on #3873: assert `strategy: wrap` structurally by
parsing the Markdown frontmatter as YAML (instead of a substring match that
could false-positive on body text), and assert {CORE_TEMPLATE} in the body
section only.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: afa7c1d2-147b-4f62-a6fc-a2cc824cfa3e

* docs(presets): reframe constitution-sync README around behavior and caveats

Rework the preset's user-facing docs to describe what it does, what it does
not do, and the caveats you take on — rather than leading with version/origin
history. The preset stack is the project's forward direction, so the README no
longer positions this as "restoring pre-0.14.4 behavior."

Also make the edit-in-place vs. composition conflict explicit and consistent
across the wrapper command and docs: propagation into command files/templates
that are provided or wrapped by a preset/extension is clobbered on stack
reconciliation (integration use/upgrade, preset/extension install/remove), so
the wrapper restricts propagation to project-local artifacts the team owns.

- README: forward-looking "What it does / does not do / When to use / Caveats"
- speckit.constitution.md: step 4 no longer hand-edits composed command files;
  closing caveat covers command files too
- docs/upgrade.md: note the composition-model conflict in the opt-in section
- tests: assert the updated closing-caveat wording

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: afa7c1d2-147b-4f62-a6fc-a2cc824cfa3e

* docs(presets): tweak constitution-sync README default-behavior wording

Phrase the default-behavior note as "the current version of Spec Kit" and
rewrap the opening paragraph.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: afa7c1d2-147b-4f62-a6fc-a2cc824cfa3e

* docs(presets): keep emphasis spans on one line in constitution-sync README

Avoid **bold** spans broken across soft line breaks (runtime resolution,
reviewed committed artifacts) so they render consistently.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: afa7c1d2-147b-4f62-a6fc-a2cc824cfa3e

* docs(presets): refocus constitution-sync README on what it restores

Reframe the intro around what the preset restores and what the user opts
into, rather than describing current Spec Kit default behavior. Be honest
that propagation was removed deliberately (duplicates the source of truth,
fights composition) and this preset knowingly reintroduces it and its
tradeoffs. Minor flow fixes (comma splice, terse bullet).

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: afa7c1d2-147b-4f62-a6fc-a2cc824cfa3e

* docs(upgrade): de-pin version from /constitution behavior-change section

The upgrade guide always describes the latest version, so hard-pinning
"0.14.4" in the heading and "Starting in 0.14.4" in the body added no
value. Keep the #3790 provenance link and the "no longer propagates"
framing; the machine-readable version gate stays in preset.yml.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: afa7c1d2-147b-4f62-a6fc-a2cc824cfa3e

* docs(upgrade): clarify non-breaking nuance and presets direction

Note that the /constitution scope change is only noticeable if you relied
on the old edit-in-place behavior, and add the forward-looking framing:
presets and extensions — not in-place file edits — are how Spec Kit now
governs, versions, and audits shared assets across repositories.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: afa7c1d2-147b-4f62-a6fc-a2cc824cfa3e

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: afa7c1d2-147b-4f62-a6fc-a2cc824cfa3e
2026-07-30 13:36:13 -05:00
Marsel Safin
515d2810fb fix: reject non-object workflow caches (#3860)
* fix: reject non-object workflow caches

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

* test: cover non-object stale workflow cache

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-30 13:25:21 -05:00
Manfred Riem
6577ffc92b Harden extension URL download cache against symlink and junction races (#3869)
* fix(extensions): harden URL download cache

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

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

Copilot-Session: 8f71e02a-bc64-4593-b305-2554debe96f6

* fix(extensions): retain secure archive descriptor

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

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

Copilot-Session: 8f71e02a-bc64-4593-b305-2554debe96f6

* Harden extension URL cache anchor opens

Assisted-by: GitHub Copilot (model: MAI-Code-1-Flash, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8f71e02a-bc64-4593-b305-2554debe96f6

* Use descriptor-safe mkdir for cache components

Assisted-by: GitHub Copilot (model: MAI-Code-1-Flash, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8f71e02a-bc64-4593-b305-2554debe96f6

* Harden extension URL download cache

Assisted-by: GitHub Copilot (model: MAI-Code-1-Flash, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8f71e02a-bc64-4593-b305-2554debe96f6

* Align extension manifest regression expectation

Assisted-by: GitHub Copilot (model: MAI-Code-1-Flash, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8f71e02a-bc64-4593-b305-2554debe96f6

* Make download cache leaf anonymous to remove cleanup TOCTOU

Address review: the best-effort cleanup walk re-derived the downloads
directory by path, so a cache ancestor swapped after the archive was
opened could redirect os.unlink to a replacement leaf, and it silently
no-op'd (failing open) on platforms without descriptor-relative unlink.

_safe_open_download_zip now unlinks the exclusively-created leaf
immediately via the same directory descriptor, returning an fd backed by
an anonymous inode. Installation already consumes that descriptor through
archive_file, so the on-disk pathname is never reopened and no cleanup
walk is needed. The capability gate additionally requires os.unlink in
os.supports_dir_fd, so unsupported platforms fail closed. Removed the
now-unused _safe_unlink_download_zip helper and its cleanup finally, and
updated the tests accordingly.

Assisted-by: GitHub Copilot (model: MAI-Code-1-Flash, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8f71e02a-bc64-4593-b305-2554debe96f6

* Fix Windows test matrix for cache hardening tests

The hardened cache primitives fail closed on platforms without dir_fd/
O_NOFOLLOW, so on the windows-latest matrix several tests errored instead
of exercising POSIX behavior:

- test_symlinked_cache_ancestor_is_refused and
  test_cache_ancestor_resolving_outside_project_is_refused called
  _validate_safe_cache_dir directly and expected typer.Exit, but on
  Windows it raises NotImplementedError first. Guard both with
  _require_secure_dir_fd() so they skip where the primitive is unavailable.
- test_safe_open_fails_closed_without_atomic_platform_support built its
  download dir via _validate_safe_cache_dir, which itself fails closed on
  Windows; construct the directory directly so the assertion targets
  _safe_open_download_zip's platform gate in isolation.
- The _open_test_download_zip stand-in unlinked a still-open file, which
  raises PermissionError on Windows. Use O_TEMPORARY there (auto-delete on
  close) and keep immediate unlink on POSIX.

Assisted-by: GitHub Copilot (model: MAI-Code-1-Flash, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8f71e02a-bc64-4593-b305-2554debe96f6

* Support Windows in extension URL download-cache hardening

Replace the fail-closed NotImplementedError on platforms lacking dir_fd
with a portable, still-hardened download path so `specify extension add
--from <url>` works on Windows instead of rejecting the install.

- `_validate_safe_cache_dir` now dispatches to a POSIX dir_fd + O_NOFOLLOW
  walk when available, and otherwise a portable path-wise walk that rejects
  symlink/junction components before and after each mkdir and requires every
  component to resolve back under the project root.
- `_safe_open_download_zip` keeps the POSIX anonymous-inode create/unlink and
  adds a portable leaf create using O_EXCL + O_TEMPORARY (auto-delete on
  close) plus a post-open fstat/lstat inode-identity check to detect a leaf
  swapped underneath us. Installation still consumes only the open
  descriptor, so the cache pathname is never reopened.
- Detect the symlink-refusal case via errno (ELOOP/ENOTDIR/EMLINK) instead of
  FileExistsError, and add O_CLOEXEC to the descriptor-walk opens.
- Drop the now-unreachable NotImplementedError handling in the --from branch.
- Tests: cover the portable path (success, symlinked-leaf refusal, symlinked
  ancestor refusal, full --from install) and keep the POSIX-only cases guarded.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8f71e02a-bc64-4593-b305-2554debe96f6

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8f71e02a-bc64-4593-b305-2554debe96f6
2026-07-30 11:59:50 -05:00
Marsel Safin
0f3f2aa45e fix: escape workflow step metadata (#3863)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-30 11:10:22 -05:00
github-actions[bot]
81bf741b92 [bug-fix] Fix bundle-update-force-mislead: add refresh() to DefaultPrimitiveInstaller (#3452)
* Fix bundle-update-force-mislead: add refresh() to DefaultPrimitiveInstaller

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

DefaultPrimitiveInstaller lacked a refresh() method, causing
_refresh_component() to fall back to install(), which calls
ExtensionManager.install_from_directory() with force=False. This
raised ExtensionError with a leaked --force hint that bundle update
does not support, leaving users with no valid recovery path.

Fix: add refresh() to each kind manager (ExtensionKindManager and
PresetKindManager delegate to _do_install(force=True); WorkflowKindManager
and StepKindManager delegate to install() as their callables are
idempotent). DefaultPrimitiveInstaller.refresh() dispatches to the kind
manager's refresh(). PresetManager.install_from_directory() and
install_from_zip() gain a force parameter that removes the existing
preset before reinstalling, mirroring ExtensionManager's force semantics.

Refs #3424

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

* Address review feedback on primitives.py and test_bundler_primitives.py

- Replace ... with pass in _KindManager Protocol method stubs
- Conditionally pass force= keyword only when force=True in _PresetKindManager
- Fix _StepKindManager.refresh() to remove step before re-installing
- Rename test to reflect actual assertion (refresh succeeds + force=True)
- Remove duplicate install_bundle import

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

* fix: add missing role/effective_integration to InstallPlan in _plan() and remove redundant import

- Remove duplicate `DefaultPrimitiveInstaller` import inside test body
  (already imported at module scope on line 15)
- Add required `role` and `effective_integration` fields to `InstallPlan`
  constructor in `_plan()` helper to prevent TypeError at runtime

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

* fix: address latest PR review comments

Assisted-by: GitHub Copilot (model: gpt-5.6-terra, 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-30 10:59:53 -05:00
Quratulain-bilal
4803a22b33 fix: use chunked read for extension manifest hash (#3841)
Replace unbounded f.read() with chunked iteration to prevent excessive
memory allocation on large or corrupted manifest files. Matches the
pattern used in integrations/manifest.py _sha256().
2026-07-30 09:22:35 -05:00
Marsel Safin
e916fd1b3b fix: preserve unreadable event config files (#3861)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-30 09:08:27 -05:00
Noor ul ain
296fdf2ee7 fix(scripts): use a .NET Framework-safe trim in the PowerShell init-dir resolver (#3872)
`Resolve-SpecifyInitDir` normalized the resolved path with
`[System.IO.Path]::TrimEndingDirectorySeparator`, which is .NET Core only.
Windows PowerShell 5.1 runs on .NET Framework, so on every 5.1 host the call
throws at that line and root resolution fails before the requested command runs:

    $ $env:SPECIFY_INIT_DIR = "C:\repo\web"
    $ .specify\scripts\powershell\check-prerequisites.ps1 -Json
    check-prerequisites.ps1 : Method invocation failed because
    [System.IO.Path] does not contain a method named
    'TrimEndingDirectorySeparator'.

The same file already documents this exact incompatibility and avoids it
correctly in `Get-FeaturePathsEnv` (~150 lines below), which uses `TrimEnd`
with a comment naming `TrimEndingDirectorySeparator` as .NET Core only.

Worse than a clean failure when the resolver is called directly: the throw is
non-terminating, so `$initRoot` stays `$null` and the very next `Join-Path`
throws too, `Get-RepoRoot` returns `$null`, and the shell exits **0**. A caller
that checks the exit code sees success with an empty root.

Switched to the `TrimEnd('/', '\')` the file already endorses. Note the obvious
swap is not quite enough on its own: a bare `TrimEnd` turns `C:\` into `C:`,
which is not the drive root but a drive-relative reference that later path APIs
re-resolve against the *current directory* — so validation would probe the wrong
tree and, from a cwd that happens to contain `.specify/`, could silently accept
`C:` as the project root. A `GetPathRoot` length check keeps a path that is its
own root intact. Both `GetPathRoot` and `TrimEnd` exist on .NET Framework.

Trailing-separator trimming (the reason the call was there — bash's `cd && pwd`
never yields one, so the two resolvers must agree) is unchanged, as are all
error paths and messages.

Tests in `tests/test_init_dir.py`:

- A static check that no shipped `.ps1` calls a .NET Core-only
  `[System.IO.Path]` member (`TrimEndingDirectorySeparator`,
  `EndsInDirectorySeparator`, `GetRelativePath`, `Join`). This one runs on all
  platforms and is what actually guards CI: the matrix runs the PowerShell
  tests under `pwsh`, which is .NET Core, so a .NET Framework-only regression
  is otherwise invisible to it. Anchored to the `Path` type so `[string]::Join`
  is not flagged.
- Two runtime tests under `powershell.exe` specifically (never `pwsh`),
  covering resolution and trailing-separator parity.
- A drive-root test asserting the reported root survives the trim intact.

Test-the-test: reverting the source change fails all four (the runtime pair
with the `does not contain a method named` throw, the static check by locating
the call). Applying only the naive `TrimEnd` fails the drive-root test, which
reports `C:` instead of `C:\`. Verified on Windows PowerShell 5.1.19041.6456,
including the previously-crashing `check-prerequisites.ps1 -Json` end to end.

Also fixes six pre-existing `test_ps_*` failures on 5.1-only hosts, which were
this bug rather than test-harness issues.

Fixes #3749

Assisted-by: Claude Code (model: claude-opus-5, under direct human supervision)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 07:51:38 -05:00
github-actions[bot]
fdfc5ae330 Add ContextForge MCP extension to community catalog (#3487)
Add contextforge-mcp extension submitted by @capatinore to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #3456

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-30 07:49:40 -05:00
Marsel Safin
227b4f5e11 fix: normalize non-UTF-8 integration manifests (#3862)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-30 07:46:23 -05:00
Markus Wondrak
675143591d feat: bind gate verdict to workflow input via verdict_input (#3725)
* feat: bind gate verdict to workflow input via verdict_input

Add an optional `verdict_input` field to gate steps that lets an
external system supply a verdict through a declared workflow input
instead of an interactive TTY prompt.

When the referenced input carries a non-empty string value that
matches one of the gate's `options` (case-insensitive), the gate
auto-decides, records the matched spelling in `output.choice`, and
applies the existing `on_reject` / abort / skip / retry semantics.
If the value is present but does not match an option, or is a
non-string, the gate fails immediately with a clear error message.
When the input is absent, null, or empty, the gate falls back to
today's TTY-prompt-or-pause behaviour unchanged.

The engine now persists `result.error` alongside each step's status
and output so that failed-step error messages survive across runs.
The CLI (`workflow run` and `workflow resume`) surfaces these
persisted errors after a failed or aborted run. `validate_workflow`
cross-references `verdict_input` against the workflow's declared
inputs block and reports an error for undeclared names, consistent
with the existing `wait_for` id cross-check.

Closes discussion: https://github.com/github/spec-kit/discussions/3717

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

* Potential fix for pull request finding

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

* Fix workflow JSON error payloads

Include persisted step errors in _workflow_run_payload so workflow run/resume/status --json all surface failure reasons consistently. Add JSON-path tests for failed and successful runs.

Assisted-by: GitHub Copilot (model: gpt-5.3-codex, autonomous)

Co-authored-by: Copilot <223556219+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>

* fix(workflows): reject verdict inputs in fan-out

Fan-out items share workflow inputs and cannot safely consume a bound gate verdict. Reject verdict_input bindings during validation and at runtime while preserving unbound gates.

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

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

* fix: update workflow command handling

Assisted-by: GitHub Copilot (model: gpt-5.3-codex, autonomous)

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

---------

Co-authored-by: Markus <markus@example.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-30 07:39:49 -05:00
Manfred Riem
99cd5e21b1 docs: use absolute image URLs in README for PyPI rendering (#3867)
Relative image paths do not render on the PyPI project page. Convert the
remaining logo and video-header image references to absolute
raw.githubusercontent.com URLs so they display correctly on
https://pypi.org/project/specify-cli/ while continuing to render on GitHub.

Addresses the rendering gap noted in github/spec-kit#2908.

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

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e8a8563a-328e-43a4-8eb7-ff381f912161
2026-07-30 07:08:41 -05:00
Manfred Riem
edc1699481 chore: release 0.15.0, begin 0.15.1.dev0 development (#3871)
* chore: bump version to 0.15.0

* chore: begin 0.15.1.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-30 07:01:41 -05:00
Clint Parker
f36634b5c1 Add yolo to community workflow catalog (#3864)
* Add yolo to community workflow catalog

- Workflow ID: yolo
- Version: 0.1.0
- Author: clintcparker
- Description: Runs specify → plan → tasks → implement without review gates

* Update speckit_version requirement to 0.8.12
2026-07-29 15:10:29 -05:00
Noor ul ain
6712665bba fix(workflows): guard the shell step's timeout check against OverflowError (#3865)
PR #3847 hardened the prompt step's `timeout` guard against a huge-int
value, but its twin in the shell step — the step the prompt one was
mirrored from — still has the hole.

`math.isfinite(10**400)` raises `OverflowError: int too large to convert
to float`. A 400-digit YAML scalar is an `int` and is not a `bool`, so it
clears every clause before `isfinite()` and raises there, escaping
`_timeout_error()` as exactly the uncaught crash that helper exists to
prevent:

    steps:
      - id: qa
        type: shell
        run: echo hi
        timeout: 1000...0   # 400 digits

    $ specify workflow run wf.yml
    Traceback (most recent call last):
      ...
      File "src/specify_cli/workflows/engine.py", line 361, in _validate_steps
        step_errors = step_impl.validate(step_config)
      File "src/specify_cli/workflows/steps/shell/__init__.py", line 127
        or not math.isfinite(timeout)
    OverflowError: int too large to convert to float

`workflow_run` calls `engine.validate()` before executing any step, so
the OverflowError propagates out of `validate_workflow` and kills the
command with a bare traceback that names neither the step nor the field,
instead of the "Workflow validation failed" report. `execute()` shares
the same helper, so an unvalidated run raises there too — and the engine
re-raises anything a step throws, aborting the whole workflow after
earlier steps have already run their side effects. The value is
genuinely invalid rather than merely unrepresentable in the check:
`subprocess.run(timeout=10**400)` raises the same OverflowError.

Unlike the prompt step, the shell step checks `isfinite()` *before*
`timeout <= 0`, so a negative huge int (`-(10**400)`) crashes as well
rather than being caught by the sign check.

Wrapped the condition in `try/except OverflowError` and treated the
value as invalid, mirroring the prompt step's guard so both steps reject
the same values with the same message. Now:

    Workflow validation failed:
      - Shell step 'qa': 'timeout' must be a positive number of seconds,
        got 1000...0.

Valid int/float timeouts, non-finite floats, bools, strings and
non-positive values are unaffected — the existing clauses are unchanged.

Regression tests in `TestShellStep`: `validate()` rejects both signs of
the huge int, `validate_workflow()` reports it end to end (pinning the
path the CLI actually takes, not just the helper), and `execute()` fails
only that step with `subprocess.run` patched to assert it is never
reached. Test-the-test: reverting the source change fails all three with
`OverflowError` and leaves the rest of `TestShellStep` passing.


Assisted-by: Claude Code (model: claude-opus-5, under direct human supervision)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 14:51:39 -05:00
github-actions[bot]
5827db5359 Add Intent Reconciliation extension to community catalog (#3858)
Add `intent` extension submitted by @SuhaibAslam to:
- extensions/catalog.community.json (inserted alphabetically between intake and issue)
- docs/community/extensions.md community extensions table

This revision limits the catalog change to the intent addition and the
top-level updated_at bump only, reverting the unrelated re-serialization
(entry reordering, \u2014 Unicode escaping, tool-array reformatting) that a
reviewer flagged.

Closes #3854
cc @SuhaibAslam

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

Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-07-29 12:58:32 -05:00
Noor ul ain
afbb2c7b65 fix(workflows): validate prompt step 'timeout' like the shell step (#3847)
* fix(workflows): validate prompt step 'timeout' like the shell step

PR #3768 added a `timeout` to the prompt step and passed it straight into
`subprocess.run(timeout=...)`. Neither `validate()` nor `execute()` checks
it, so a bad value from a user-authored `workflow.yml` escapes as a raw
exception:

    steps:
      - id: first
        type: shell
        run: echo side-effect
      - id: ask
        type: prompt
        prompt: do it
        timeout: abc

    $ specify workflow run wf.yml
      > [first] shell ...
    Workflow failed: unsupported operand type(s) for +: 'float' and 'str'

The engine re-raises anything a step throws, so this takes down the whole
run — after `first` has already run its side effect — with a message that
names neither the step nor the field. `timeout: .nan` raises `ValueError:
cannot convert float NaN to integer` the same way, and a non-positive
`timeout` (`0`, `-5`) makes `subprocess.run` report an immediate
TimeoutExpired for a command that never got the time to run. `timeout:
true` silently becomes a 1-second limit, since bool is an int subclass.

The sibling shell step already rejects exactly these values via a
`_timeout_error()` helper shared by `execute()` and `validate()`, so the
same workflow failed validation cleanly as a shell step and crashed as a
prompt one. Mirrored that helper onto PromptStep: `validate()` reports the
contract error, and `execute()` re-checks it so an unvalidated run fails
just that step instead of aborting. Now:

    Workflow validation failed:
      - Prompt step 'ask': 'timeout' must be a positive number of seconds,
        got 'abc'.

caught before the first step runs. Positive int/float timeouts and an
absent `timeout` are unaffected.

Regression tests in `TestPromptStep` mirror the shell step's: validate
rejects "30"/True/inf/nan/0/-5/list/None, validate accepts 300/5/0.5 and
an absent field, and execute fails cleanly with `subprocess.run` patched
to assert it is never reached. With the source fix reverted, all 9
rejection tests fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Assisted-by: Claude Code (model: claude-opus-5, under direct human supervision)

* Potential fix for pull request finding

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

* test(workflows): cover the huge-int timeout OverflowError guard

The autofix commit wrapped the prompt step's `_timeout_error()` check in
`try/except OverflowError` but added no test, so nothing pins the
behaviour it introduced.

`math.isfinite(10**400)` raises `OverflowError: int too large to convert
to float` — the value is an `int`, is `> 0`, and is not a `bool`, so it
clears every other clause of the guard and reaches `isfinite()`. Without
the `except`, validating

```yaml
- id: ask
  type: prompt
  prompt: do it
  timeout: 1000...0   # 400 digits
```

raises that `OverflowError` out of `validate()`/`execute()` — exactly the
uncaught-crash failure mode this guard was added to prevent. The same
value raises `OverflowError` from `subprocess.run(timeout=...)`.

Add `10**400` to both parametrized rejection lists (`validate()` and the
`execute()` fails-cleanly loop). Test-the-test: reverting the `try/except`
fails both new cases with `OverflowError` and leaves the rest passing.

Assisted-by: Claude Opus 5 (model: claude-opus-5, autonomous)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-29 10:50:45 -05:00
Quratulain-bilal
6337ebfe59 fix: add utf-8 encoding to registry file open calls (#3816) 2026-07-29 10:45:20 -05:00
Quratulain-bilal
e543147ccb fix: eliminate TOCTOU race in file unlink calls (#3815) 2026-07-29 10:42:25 -05:00
Ali jawwad
6033c6957b test(workflows): name the condition-rejection tests for the real boundary (#3808)
`test_validate_rejects_non_string_condition` contradicts its sibling
`test_validate_accepts_string_or_bool_condition` in the same class: a
bool *is* a non-string, so the two names disagree about the contract the
validator actually implements.

Rename to `test_validate_rejects_non_string_non_bool_condition` in all
three step classes, matching the validator's own message: "'condition'
must be a string or boolean, got <type>".

Test names only — no behaviour change, and the parametrized values are
untouched.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 10:38:02 -05:00
Quratulain-bilal
13f2b135cc fix: eliminate TOCTOU race in file unlink calls (#3811) 2026-07-29 10:36:40 -05:00
Ali jawwad
54396780f3 fix(presets): escape user-supplied catalog name/URL in add/remove output (#3806)
`preset catalog add` and `preset catalog remove` interpolate the raw
`--name` and URL into `console.print()`, so Rich parses them as markup.
Two failure modes:

* Silent misreporting — a name like `[bold red]pwned[/]` is printed as
  `pwned`, so the confirmed name is not the persisted name and a later
  `remove` with the reported name fails.
* Unhandled MarkupError — an unbalanced closing tag raises, and because
  the crash happens *after* preset-catalogs.yml is written, the user gets
  a traceback for a catalog that was in fact added.

This file already imports `_escape_markup` and escapes name/description/
url in `preset catalog list` (whose invariant `test_catalog_list_escapes_
rich_markup` already pins); `add`/`remove` were the remaining gaps.

Only rendering changes: the raw values are still what get persisted and
what the duplicate-name comparison uses.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 10:10:10 -05:00
Quratulain-bilal
db5802b39b fix: add missing utf-8 encoding to registry file open calls (#3810) 2026-07-29 10:05:09 -05:00
github-actions[bot]
8394c8d536 [bug-fix] Fix upgrade-overwrites-copilot-skills: pass force=True to extension skill re-registration after upgrade (#3853)
* Fix upgrade-overwrites-copilot-skills: pass force=True to extension skill re-registration after upgrade

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

_register_extension_skills() had a skip guard that refused to overwrite
existing SKILL.md files (protecting user customizations). In the upgrade
path, setup() regenerates all core-template SKILL.md files first, then
calls register_enabled_extensions_for_agent(). The guard then sees those
freshly-written core files as 'existing' and skips every extension, leaving
only core template content on disk.

Fix: add force: bool = False to _register_extension_skills() and thread it
through register_enabled_extensions_for_agent() and
_register_extensions_for_agent(). In integration_upgrade(), pass force=True
so extension content layers on top of the just-regenerated core files.

The force flag is off-by-default so plain extension add still protects
user-modified skill files.

Refs #3849

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

* Potential fix for pull request finding 'Unused local variable'

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>

* test: add end-to-end regression guard for upgrade-overwrites-copilot-skills (#3849)

The existing regression tests in TestRegisterExtensionSkillsForceFlag exercise
the new force parameter at the helper level, so without the fix they fail only
with a TypeError (unknown kwarg) rather than on the user-facing behaviour.

Add a command-level test that runs 'specify integration upgrade copilot --skills
--force' end-to-end and asserts the installed git extension's SKILL.md is
restored (with its extension content, not a bare core-template stub) when the
skill directory already exists — the exact skill_dir_preexists path the bug
depends on. The test fails on pre-fix source (the skill is never recreated) and
passes with the fix, so it is a genuine behavioural regression guard rather than
an API-surface check.

Refs #3849

Assisted-by: GitHub Copilot (model: claude-opus-4.8, 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: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-07-29 10:03:14 -05:00
Ali jawwad
89126f3a33 fix(integrations): don't abort uninstall when the manifest can't be deleted (#3805)
`IntegrationManifest.uninstall()` guards every tracked-file `path.unlink()`
with `except OSError: skipped.append(path)`, but the manifest's own
`manifest.unlink()` is bare. The manifest is deleted *last*, so an
undeletable manifest (read-only file, a directory left at the path, a
Windows lock) raises after the tracked files are already gone.

The caller loses the `(removed, skipped)` result and never runs its
post-uninstall bookkeeping — reassigning the default integration,
rewriting/removing `integration.json`, clearing init options — leaving a
removed integration still recorded as installed.

Report it in `skipped` like any other file we could not remove, mirroring
the `path.unlink()` guard above and the same `except OSError:
skipped.append(...)` pattern in kimi's legacy-directory cleanup.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 09:28:29 -05:00
Manfred Riem
623466dc42 test(extensions): update stale manifest validation message assertion (#3859)
The extensions `events` feature changed the "nothing provided" validation
error from "Extension must provide at least one command or hook" to
"Extension must provide at least one command, hook, or event", but
test_empty_provides_and_no_hooks_keeps_its_own_message still asserted the
old wording, so it failed on main. Update the regex and also pop `events`
from the fixture so the test truly exercises the empty-provides path.

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

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 189d67d7-2028-4319-a459-b22919d43a3e
2026-07-29 09:21:21 -05:00
Ali jawwad
2ef96532d2 fix(agents): coerce a non-string description in TOML command rendering (#3799)
CommandRegistrar.render_toml_command passes the raw frontmatter `description`
straight into `_render_basic_toml_string`, which iterates the value and calls
ord() on each character. Frontmatter comes from yaml.safe_load, so description
can be any YAML type:

    description='ok string' -> description = "ok string"
    description=None        -> TypeError: 'NoneType' object is not iterable
    description=42          -> TypeError: 'int' object is not iterable
    description=True        -> TypeError: 'bool' object is not iterable
    description=['a','b']   -> description = "ab"     <- silently WRONG value

This is a format-branch asymmetry: it is the only renderer reached from
register_commands' format branches that does not normalise description.
render_yaml_command (same class, ~70 lines below) already does exactly
`if not isinstance(description, str): description = str(description) if
description is not None else ""`, render_markdown_command goes through
yaml.dump which handles any type, and TomlIntegration._extract_description
returns "" for a non-str. So only extension/preset commands rendered for the two
TOML agents were affected.

Apply the same coercion the sibling uses. After: None -> "", 42 -> "42",
True -> "True", ['a','b'] -> "['a', 'b']", each still valid parseable TOML.
String descriptions are untouched.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 09:07:29 -05:00
Marsel Safin
de54ff73fe fix(workflows): make security requirements sync deterministic (#3832)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-29 08:56:18 -05:00
Ali jawwad
f4a9b890cc fix(cli): render the literal [suffix] in --tag help and rejection message (#3800)
Both places a user learns the `specify self upgrade --tag` syntax silently drop
the `[suffix]` token, because Rich parses the literal square brackets as a
markup tag and discards them:

    rejected tag -> "Invalid --tag: expected vMAJOR.MINOR.PATCH"
    (constant is  "Invalid --tag: expected vMAJOR.MINOR.PATCH[suffix]")

    --help       -> "Pin the target version (vX.Y.Z). Without --tag, ..."

So the CLI implies a bare vX.Y.Z is the ONLY accepted form, when v1.0.0-rc1,
v0.8.0.dev0 and v0.8.0+build.42 are all valid -- and the shipped docs advertise
the suffix in four places (docs/upgrade.md x3, README.md x2).

Escape the rejection message at the PRINT site rather than baking `\[` into
_INVALID_TAG_MESSAGE: the same constant is raised through typer.BadParameter,
which Click renders without Rich, so it must stay plain text. Escape the literal
bracket in the option help, which Typer renders through Rich.

Same literal-bracket class as the existing precedents in workflows/_commands.py
(`\[disabled]`, `\[<type>]`). Static CLI text only -- no validation semantics
change and `_validate_tag` is untouched.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 08:54:10 -05:00
Marsel Safin
b7b0e966cc fix(integrations): preserve non-UTF-8 VS Code settings (#3833)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-29 08:43:03 -05:00
Ali jawwad
884950f88a fix(bundler): treat an explicit-null manifest field as missing, not the text "None" (#3798)
BundleManifest.from_dict read every required scalar as
`str(raw.get(key, "")).strip()`. The `""` default only covers a MISSING key. A
key present but null -- exactly how YAML spells an empty field (`author:` with
nothing after it) -- yields None, and `str(None)` is the literal string "None".
That value is non-empty, so it sailed past the `if not value` required-field
checks in structural_errors().

Reproduced on main:

    bundle.yml with description:/author:/license: left empty
    -> description='None'  author='None'  license='None'
    -> structural_errors() == []
    -> specify bundle validate: exit 0, "demo is well-formed and valid."

So an empty required field was silently accepted and the bundle shipped the
literal text "None" as its author/license/description -- which is what
`bundle info` and a catalog entry then display. A null `provides.<kind>[].id`
likewise became a component literally named "None".

Add a `_text()` helper beside the existing `_parse_str_list` (the file's
established "one coercion helper applied at every site" shape) mapping an
explicit null to "", and route the required scalars through it. Same
silent-acceptance class as the already-merged guards in this function: #3629
(non-mapping `integration:`) and #3661 (falsy non-mapping requires/provides).

Non-null values are still `str()`-coerced and stripped, and an absent key
already produced "" -- so valid manifests are byte-for-byte unaffected.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 08:40:26 -05:00
kanfil
f8e474d6fd feat: first-class agent-native runtime hooks for integrations (#3704)
* feat: first-class agent-native runtime hooks for integrations

* refactor: rework integration events per maintainer review

- Rename hooks terminology to 'events' (events:, --events flag, events.py).
- Use snake_case names for canonical events consistent with spec-kit vocabulary.
- Fold event config adapters into integration classes via class attributes (CANONICAL_TO_NATIVE, events_config_file, events_format).
- Lift event command-script resolution to core 'specify event run' command.
- Split events sourcing from integration config writing.
- Support first-class Copilot CLI events JSON generation under '.github/hooks/speckit.json'.
- Rewrite and expand full test suite under 'tests/integrations/test_events.py'.

Assisted-by: opencode (model: litellm/gemini-3.5-flash, autonomous)

* fix(events): resolve ruff lint errors blocking CI

Address Copilot review finding #18 (src/specify_cli/__init__.py event-command
import missing # noqa: E402), #19 (unused console import in commands/event.py),
and #20 (unused patch/yaml/Path/integration imports in test_events.py). Also
fix two stray F541 f-string prefixes in _build_opencode_plugin that ruff
flagged in the same job.

Bump dev version 0.14.2.dev0 -> 0.14.2.dev1 and add a CHANGELOG entry per the
AGENTS.md convention for Specify CLI __init__.py changes.

Refs: PR #3704 Copilot inline review (findings #18, #19, #20)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): make generated native hooks actually execute

Address Copilot review findings that left generated event hooks inert or
schema-invalid after the rework:

- #2: the resolved events map now carries an ordered list of handlers per
  event (dict[str, list[dict]]) so two extensions declaring the same event
  both run instead of the last one silently winning. collect_extension_events
  accumulates; every adapter emits one native entry per handler.
- #6: Claude/Gemini/Qwen/Devin/Tabnine native schema accepts a single
  'command' string, not command+args. Each adapter now renders one complete
  shell invocation of the dispatcher via _dispatcher_command().
- #7: Gemini measures hook timeouts in milliseconds; add events_timeout_unit
  attr and _native_timeout() so the 60s default becomes 60000ms instead of
  terminating the dispatcher after 60ms.
- #4: _resolve_event_command_argv() replaces _extract_script_path() —
  scripts: values are command strings (e.g. 'scripts/bash/setup-plan.sh --json'),
  not bare paths. Resolves the project's sh/ps/py variant, splits safely into
  argv, and prepends the interpreter for .py.
- #5: bundled-template fallback now uses _locate_core_pack()/_repo_root()
  (core_pack/commands, not the non-existent core_pack/templates/commands).
- #16: all formatters use IntegrationBase.resolve_python_interpreter() so
  generated commands honor the project venv and never hard-code python3
  (absent on Windows). The opencode TS plugin bakes in the same resolved
  interpreter.
- #13: opencode TS plugin runEvent() now throws on failure instead of
  process.exit(2), which killed the OpenCode host process; only the failing
  hook is rejected.
- #21: user YAML override is validated (event names, non-empty command
  strings) before returning; a malformed override is warned about and
  ignored rather than crashing installation on cfg.get().

Bump dev version 0.14.2.dev1 -> 0.14.2.dev2 (gemini/__init__.py change) and
add a CHANGELOG entry.

Refs: PR #3704 Copilot inline review (findings #2, #4, #5, #6, #7, #13, #16, #21)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): merge/teardown idempotency and data safety

Address Copilot review findings on native-config merge and teardown:

- #9: _has_marker now recurses into nested 'hooks' arrays so a matcher-group
  containing Specify-owned inner hooks is recognized and replaced on upgrade
  instead of accumulating duplicates.
- #11: _merge_json_fragment strips ALL Specify-marked entries from every event
  before adding the new set, so an override that drops an event (pre_tool_use
  -> stop) removes the stale marked entry instead of leaving it active.
- #3: an empty resolved map (--events false / disabled override) now runs the
  native-config removal path instead of early-returning, so prior Specify
  hooks are stripped. The shared dispatcher is left untouched (#10).
- #14: teardown deletes a Spec-Kit-created config that is now empty of user
  content (rather than leaving '{}' that confused manifest.uninstall()),
  while preserving pre-existing configs with user hooks/settings.
- #10: the shared .specify/events.py dispatcher is deleted only when no other
  installed event-capable integration's manifest still references it, so
  uninstalling one multi-install integration doesn't break the others.
- #8: Copilot's .github/hooks/speckit.json now merges owned entries (with
  markers) into a pre-existing file instead of overwriting, and teardown
  removes only owned entries (deleting the file when no user hooks remain).
- #22/#23: JSON/JSONC parse failures in native configs (Claude/Cursor/etc.
  and opencode.json) abort the merge with a warning instead of resetting user
  content to '{}'.
- #12: write destinations are validated (symlinked-ancestor rejection +
  containment) before any bytes are written, so a symlinked .specify or
  native config directory can't redirect writes outside the repository.

Refs: PR #3704 Copilot inline review (findings #3, #8, #9, #10, #11, #12, #14, #22, #23)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): honor enabled flag, refresh on extension lifecycle, strict command validation

Address Copilot review findings on sourcing, validation, and lifecycle:

- #1: collect_extension_events now honors the extension registry's 'enabled'
  flag — a disabled extension's events are skipped so disabling an extension
  actually deactivates its runtime hooks. Adds refresh_integration_events(),
  wired into extension add/remove/enable/disable, so installing, removing,
  enabling, or disabling an extension regenerates each installed event-capable
  integration's native event config (the documented install-after-init flow is
  no longer inert, and disabled/removed extension events are stripped).
- #17: validate_events now requires 'command' to be a non-empty string, not
  merely truthy, so a value like 'command: [foo]' is rejected at manifest
  load instead of rendering into invalid native configuration.
- #15: updated PR #3704 description to the implemented events terminology
  (.specify/events.py, events:, --events, integration-events.yml) replacing
  the stale bridge.py / runtime_hooks: / --hooks false / integration-hooks.yml
  references that no longer match the shipped API.

(#21 — user YAML override validation — was addressed in the prior tier.)

Refs: PR #3704 Copilot inline review (findings #1, #15, #17)

Assisted-by: opencode (model: glm-5.2, autonomous)

* revert: drop CHANGELOG.md/pyproject.toml version bumps from events fixes

Per maintainer request, the events PR no longer carries CHANGELOG entries or
pyproject version revs. This restores both files to their pre-PR (da6c20d9)
state: pyproject.toml back to 0.14.2.dev0 and the [Unreleased] block removed
from CHANGELOG.md. The AGENTS.md version-rev convention for __init__.py
changes is intentionally waived for this PR by maintainer decision.

This also clears the pending merge conflicts with upstream/main on these two
files (upstream's 0.14.2 release commit c0fe0e43): our side now makes no
net change to them relative to the merge-base, so a future upstream merge
takes theirs on both without conflict.

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): compose --events into Copilot/Devin options() (#8, #9)

Copilot and Devin are event-capable, but their options() overrides returned
only --skills without calling super(), so the base class never declared
--events. The documented --integration-options "--events false" opt-out was
therefore rejected as unknown for both adapters.

Both now compose with super().options() (mirroring Codex and Cursor) so
--events is declared alongside --skills. Added a TestEventCapableOptionsCompo
sition test class asserting --events appears in Copilot, Devin, Cursor, and
Codex options() output.

Refs: PR #3704 Copilot review 4790195897 (findings #8, #9)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): Cursor version field, matcher grouping, Copilot cross-OS

Address three Copilot review findings on native-config generation:

- #7: Cursor's .cursor/hooks.json schema requires top-level "version": 1,
  but json-flat used _merge_json_fragment() which only writes hooks, so a
  freshly generated file was missing the required schema version. Added a
  version kwarg to _merge_json_fragment (preserving a user's value if
  present) and the Cursor json-flat branch now passes version=1.
- S3: json-nested placed all handlers under the first handler's matcher, so
  two extensions registering the same event with different matchers both ran
  for the first matcher and neither for the later. Handlers are now grouped
  by distinct matcher, emitting one matcher-group per matcher (handlers
  sharing a matcher stay in one group).
- S4: Copilot's bash and powershell fields both received the same
  host-resolved command, so a config generated on Linux wrote a POSIX venv
  path into the PowerShell hook (and vice-versa). _dispatcher_command gains
  a target_os kwarg; Copilot now emits an independent POSIX interpreter
  (python3) for bash and a Windows interpreter (python) for powershell, so
  the checked-in config works on either OS.

Tests: added TestCursorJsonWriting (version present + preserved) and
matcher-grouping regressions (per-distinct-matcher, shared-matcher); updated
the Copilot generation test to assert bash != powershell with OS-appropriate
interpreters.

Refs: PR #3704 Copilot review 4790195897 (findings #7, S3, S4)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): anchor py scripts and prefix ps launcher in command runner

Address two Copilot review findings on the core command runner:

- S2: the py variant called build_python_invocation() on the raw scripts:
  command string, which left 'scripts/...' anchored at the project root
  instead of under .specify/ (or .specify/extensions/<id>/). Every event
  command in a project configured with --script py launched a nonexistent
  project-root path. The py branch now shares the same base-anchoring as
  sh/ps and prepends the resolved interpreter as argv (no shell quoting
  needed for subprocess.run(shell=False)).
- S6: the ps variant returned the .ps1 path as the executable, but Windows
  subprocess.run(shell=False) cannot execute a PowerShell script directly,
  so event dispatch failed on the default Windows script type. The ps branch
  now prefixes argv with 'pwsh -File' (PowerShell 7+), falling back to
  'powershell -File' (Windows PowerShell) when pwsh is absent.

Tests: added test_py_variant_anchored_under_specify and
test_ps_variant_prefixed_with_powershell_launcher covering the new argv
shapes (interpreter + .specify-anchored path; launcher -File + path).

Refs: PR #3704 Copilot review 4790195897 (findings S2, S6)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): skip-tracking on parse fail, drop dispatcher claim on retain, honor --events false in refresh, preserve layers on invalid override

Address four Copilot review findings on merge/teardown/refresh safety:

- S5: _merge_json_fragment/_merge_opencode_plugin_ref/_merge_copilot_json now
  return bool (wrote). Install branches skip manifest.record_existing() and
  created.append() when a merge was skipped on parse failure, so a user's
  JSONC/malformed native config is not tracked and manifest.uninstall() can't
  later delete the untouched file.
- S1: remove_integration_events now drops this integration's manifest claim on
  the shared dispatcher (manifest.remove) even when the file is retained
  because another integration references it. Previously the retained file
  stayed tracked, so the subsequent manifest.uninstall() in teardown() saw
  the matching hash and deleted the file another integration still depended
  on. The unit test now exercises full teardown() (not just
  remove_integration_events) to cover the gap.
- S7: refresh_integration_events reads each integration's stored
  parsed_options via _resolve_integration_options and passes them to
  resolve_events, so a persisted --events false is honored across extension
  add/enable/disable instead of being discarded (which re-enabled events the
  user had disabled).
- #10: an invalid override entry now abandons the entire override and keeps
  the accumulated built-in + extension layers, instead of resetting
  resolved_override to {} and assigning that empty map to events (which
  silently disabled all hooks on a single typo). Only a fully-valid override
  (including an explicit events: {}) replaces the prior layers.

Tests: added TestOverridePreserveLayers (invalid entry keeps layers; explicit
empty disables), TestSkippedMergeNotTracked (JSONC not recorded), and
TestDispatcherManifestClaimDroppedOnRetain (full teardown keeps dispatcher
when another integration references it). Added S7 refresh-honors-events-false
regression.

Refs: PR #3704 Copilot review 4790195897 (findings S5, S1, S7, #10)

Assisted-by: opencode (model: glm-5.2, autonomous)

* test(extensions): update stale validation-message assertion

The 'no commands/hooks/events' validation message changed to
'Extension must provide at least one command, hook, or event' when the
events feature added a third provider kind, but test_no_commands_no_hooks
still matched the old 'must provide at least one command or hook' text and
failed on every CI job. Update the regex to the current message.

Refs: PR #3704 CI failure (test_extensions.py:579)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): forced-teardown data safety, manifest-driven command resolution, toml teardown safe-dest

Address three findings from Copilot review 4791088500:

- S9: _remove_native_event_hooks now unconditionally drops this integration's
  manifest claim on the native config, not only when the file was deleted.
  Previously a config whose owned entries were cleaned but user content
  retained stayed tracked, so teardown(force=True) -> manifest.uninstall(
  force=True) deleted the entire user-owned settings file. This is the
  config-file mirror of the earlier shared-dispatcher fix.
- S8: _find_command_template resolved extension event commands via a broken
  registry lookup (the registry stores per-agent registered_commands
  name-lists, not a {name, file} map) and a file-stem scan that only matched
  when the .md stem equaled the command name. A manifest mapping
  speckit.selftest.extension -> commands/selftest.md resolved as missing. It
  now enumerates installed extensions via ExtensionManager.get_extension()
  and matches provides.commands[].name -> file, with the directory scan and
  core-template lookups kept as fallbacks.
- R3: _remove_toml_entries now validates the destination with
  _ensure_safe_destination before read/write, matching the merge path, so a
  symlink swap of .codex/config.toml after install can't make teardown
  overwrite a file outside the project.

Tests: forced full teardown preserves a user settings file; an extension
command whose file stem differs from its name resolves via the manifest;
TOML teardown rejects a symlinked config destination.

Refs: PR #3704 Copilot review 4791088500 (findings S8, S9, R3)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): subprocess cwd, shell quoting, TOML matcher escaping, Tabnine ms

Address four findings from Copilot review 4791088500:

- R1: the generated dispatcher and resolve_and_run_event_command now run
  their subprocesses with cwd set to the dispatcher-derived project root.
  Previously 'specify event run' (and the resolved script) inherited the
  agent's working directory, but event_run resolves the project via
  Path.cwd(), so a hook fired from a subdirectory targeted the wrong project
  and reported the command missing.
- R2: _dispatcher_command now shell-quotes each component (interpreter,
  command, event) for the target shell (POSIX via shlex.quote; PowerShell via
  single-quoted literals with doubled quotes). An interpreter path containing
  spaces or an extension/override command containing shell metacharacters is
  passed as a single argument instead of being reinterpreted by the native
  hook shell. Claude's  prefix is left unquoted so the
  shell still expands it (prefix + relative path are fixed, safe strings).
- R4: the Codex TOML matcher is now rendered through the shared TOML escaper
  like command, so a matcher containing a quote/backslash/newline/control
  character no longer produces malformed config.toml.
- R5: Tabnine declares events_timeout_unit='ms' (its hook schema mirrors
  Gemini's BeforeTool/AfterTool), so the 60s default becomes 60000ms instead
  of timeout: 60 (60 ms), which would terminate the dispatcher immediately.

Tests: cwd-forced execution from a subdirectory; POSIX/PowerShell quoting of
metacharacter and space-bearing components; TOML matcher with a quote parses
cleanly; Tabnine timeout converts to 60000. Updated the Copilot generation
test for the new quoted args.

Refs: PR #3704 Copilot review 4791088500 (findings R1, R2, R4, R5)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): POSIX dispatcher path constant + platform-agnostic tests

Three Windows test failures, one a real cross-OS bug:

- W1 (bug): EVENTS_DISPATCHER_REL was str(Path('.specify')/'events.py'),
  which yields '.specify\events.py' on Windows. Manifest keys are stored in
  POSIX form (.as_posix()), so 'dispatcher_rel in manifest.files' was always
  False on Windows: the shared-dispatcher manifest-claim drop was skipped and
  manifest.uninstall(force=True) deleted the dispatcher another integration
  still depended on. Make it a POSIX constant (.as_posix()) so it matches
  manifest keys on every platform.
- W2/W3 (tests): the py/ps argv assertions used endswith() and an exact
  launcher-name set that broke on Windows backslash paths and the
  pwsh.EXE/full-path launcher returned by shutil.which. Compare in POSIX form
  and match the launcher by case-insensitive stem.

Refs: PR #3704 Windows CI failures

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): override layer preservation, matcher validation, event command-ref canonicalization

Address four Copilot review findings:

- C4: a malformed override handler (e.g. "stop: []" or "stop: bad-value")
  normalizes to no handlers. Previously the entry was skipped and the override
  still adopted, so an override whose only entry was malformed silently
  disabled every built-in and extension hook. The empty-handler case now
  abandons the whole override (keeps prior layers); an explicit "events: {}"
  (no entries) remains a valid disable.
- C6: a non-mapping integration entry (e.g. "claude: bad") was coerced to
  "events: {}" and treated as a valid explicit disable. It now warns and
  abandons the override, keeping the accumulated layers. Only an explicitly
  present, mapping-valued "events" field replaces the prior layers.
- C10: matcher is now validated as a string (or absent) in both
  validate_events (manifest) and _validate_resolved_event (override). A
  non-string matcher such as "matcher: []" previously passed validation but
  crashed by_matcher.setdefault(matcher, ...) with TypeError: unhashable
  type, aborting init or refresh.
- C11: ExtensionManifest._validate now applies the same rename + alias-lift
  canonicalization to event command references that it already applies to
  hook references. An event referencing an auto-corrected command (e.g.
  my-ext.boot -> speckit.my-ext.boot) previously kept the obsolete name,
  so dispatch reported no command and the event silently no-oped.

Tests: empty-handler/non-mapping override preserves layers; non-string
matcher rejected in manifest and abandoned in override; event command ref
lifted to canonical form with a warning.

Refs: PR #3704 Copilot review (findings C4, C6, C10, C11)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): protect shared dispatcher from stale cleanup, delete Cursor version stub, non-destructive refresh

Address three Copilot review findings:

- C3: the shared .specify/events.py dispatcher is now in
  events_stale_exclusions(). It is written into every event-capable
  integration's manifest but reference-counted across them; an upgrade with
  --events false omits events.py from the new manifest, so the generic stale
  pass would delete it without the refcount check, breaking any other
  installed event-capable integration. Its deletion is left to
  remove_integration_events(), which checks the refcount.
- C5: _remove_json_entries now deletes a Spec-Kit-created Cursor file that
  retains only {"version": 1} after all owned hooks are removed (we added the
  version field), mirroring _remove_copilot_entries. Previously the generic
  remover only deleted a literally-empty object, so clean teardown left a
  generated stub behind.
- C12: refresh_integration_events now resolves first and calls
  install_integration_events once, instead of running the destructive
  _remove_native_event_hooks pre-step before resolution. A later failure
  (invalid destination, write error, formatter error) no longer destroys the
  working native config before the new one is written.
  install_integration_events already removes stale Specify-marked entries and
  handles an empty map (stripping prior hooks), so the pre-step was both
  unsafe and redundant.

Tests: dispatcher in stale exclusions; Cursor version-only stub deleted on
teardown; refresh failure preserves the pre-existing config (no pre-strip).

Refs: PR #3704 Copilot review (findings C3, C5, C12)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): host target uses POSIX quoting, Claude dispatcher double-quoted, & for windows

Address two Copilot review findings on the shell-quoting added in the prior
round (R2):

- C1: _shell_quote("host") now always uses POSIX shlex.quote, not PowerShell
  single-quoting on Windows. The single-command-string formats
  (Claude/Gemini/Qwen/Devin/Tabnine) are run via the agent's POSIX-ish shell
  (Git Bash on Windows), and a single-quoted 'python' is not invoked as a
  command by PowerShell without the call operator — so generated hooks failed
  to launch the dispatcher on Windows. Safe tokens pass through bare
  (python3, speckit.ext.cmd) on every platform. PowerShell single-quoting is
  now used only for the explicit target_os="windows" (Copilot's powershell
  field), where the quoted interpreter is prefixed with "& " so it is
  actually invoked.
- C2: Claude's ${CLAUDE_PROJECT_DIR} dispatcher path is now double-quoted
  ("${CLAUDE_PROJECT_DIR}/.specify/events.py") so the variable still expands
  (double quotes allow expansion in POSIX shells) but a project path
  containing spaces no longer word-splits and breaks dispatcher launch.

Tests: host target never emits PowerShell quotes; windows target carries the
& call operator; Claude dispatcher is double-quoted; updated Copilot
generation assertions for the &-prefixed powershell command.

Refs: PR #3704 Copilot review (findings C1, C2)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): opencode TS plugin resolves dispatcher from directory, execFileSync argv, forwards input+output

Address three Copilot review findings on the opencode TS plugin:

- C8: the dispatcher and interpreter are now resolved per-project at plugin
  load from the `directory` OpenCode passes to the plugin factory, not
  process.cwd(). OpenCode may be launched from a parent directory or host
  another workspace, in which case process.cwd() pointed at the wrong project
  and every event failed. The resolver prefers a project-local venv
  interpreter, then falls back to python3.
- C9: the dispatcher is launched with execFileSync and an argv array
  [interpreter, dispatcher, command, event] instead of a shell command string
  built by interpolating the interpreter/command/event into a template
  literal. Command/event strings are only validated as non-empty, so quotes or
  backticks could previously break the generated TypeScript and shell
  metacharacters could execute outside the dispatcher; an interpreter path
  with spaces also failed. No shell is involved now.
- C7: tool callbacks now forward both `input` and `output` to runEvent
  (combined into one JSON payload), so pre_tool_use can inspect the tool
  arguments and post_tool_use can inspect the result — the primary payload for
  those events. Previously only `input` was forwarded.

Tests: plugin resolves dispatcher/interpreter from `directory` (no
process.cwd() path.join), uses execFileSync (no shell string), and forwards
output to runEvent for both pre/post_tool_use.

Refs: PR #3704 Copilot review (findings C7, C8, C9)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): Qwen ms timeout, Devin root-nested format, Copilot agentStop

Address three Copilot review findings on adapter mappings (verified against
each agent's published hook documentation):

- U1: Qwen Code command hooks measure timeout in milliseconds (default
  60000), per the Qwen Code hooks docs. The adapter previously inherited the
  seconds default, so every generated handler got timeout: 60 (60 ms) and was
  killed before the dispatcher could start. Declare events_timeout_unit="ms".
- U2: Devin's .devin/hooks.v1.json is a root event map ({"PreToolUse": [...]})
  with no top-level "hooks" wrapper (the docs state "the hooks object is the
  entire file"). The adapter reused json-nested, which writes events under a
  "hooks" key Devin never reads. Add a json-root-nested format with a matching
  writer (_merge_json_root) and remover (_remove_json_root_entries) that
  operate on the root event keys, sharing the matcher-grouping, marker, and
  JSONC-abort behavior of the nested variants.
- U3: Copilot CLI supports the canonical per-turn stop lifecycle as native
  agentStop; add "stop": "agentStop" to the mapping so an extension's stop
  handler fires for Copilot.

Tests: Qwen timeout converts to 60000; Devin events written at the root (no
"hooks" wrapper) and teardown preserves user root entries; Copilot stop maps
to agentStop.

Refs: PR #3704 Copilot review (findings U1, U2, U3)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): collect events via validated manifest, surface refresh failures

Address two Copilot review findings:

- R1: collect_extension_events now reads events from a validated
  ExtensionManifest (whose command refs were canonicalized at install
  validation, C11) instead of the raw extension.yml YAML. Previously an
  event command ref like my-ext.boot was normalized to speckit.my-ext.boot
  during install validation, but the on-disk YAML kept the obsolete name;
  refresh then emitted it and _find_command_template could not match it,
  leaving the hook silently inert. Registry-tracked extensions use the
  validated manifest; on-disk extensions not yet in the registry fall back
  to the raw YAML (preserving the partial-staged-install scan behavior).
- R3: refresh_integration_events now accumulates per-integration failures
  and raises EventRefreshError at the end (after refreshing the others) so
  the extension lifecycle commands (add/remove/enable/disable) can't claim
  an extension was fully deactivated while a stale native hook may still be
  active. A new _refresh_events_and_warn helper surfaces the aggregated
  failures as a warning at each call site without aborting the overall
  command (the extension was already added/removed/enabled/disabled).

Tests: event command ref canonicalized via the validated manifest;
refresh failure raises EventRefreshError (aggregated) while still preserving
the pre-existing config.

Refs: PR #3704 Copilot review (findings R1, R3)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): probe venv for specify_cli before selecting it; python on Windows

Address two Copilot review findings on interpreter resolution:

- R2: the dispatcher's _find_specify and the opencode TS resolver both
  selected a project-local venv python and ran `-m specify_cli` without
  checking that specify_cli is importable there. In a typical project where
  Spec Kit is installed globally (or via uv tool) but the project has its own
  unrelated virtualenv, every event invoked that interpreter and failed
  instead of reaching the PATH `specify` fallback. Both now probe the
  candidate interpreter (subprocess `import specify_cli` / execFileSync probe)
  before selecting it, falling through to the fallback when the venv lacks
  Spec Kit.
- S2: the opencode TS PATH fallback was always `python3`, which is commonly
  unavailable on Windows. It is now `python` on Windows
  (process.platform === 'win32') and `python3` on POSIX.

Tests: the generated dispatcher contains the _has_specify_cli probe and the
PATH fallback; the opencode TS plugin probes for specify_cli and uses a
platform-appropriate PATH interpreter.

Refs: PR #3704 Copilot review (findings R2, S2)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): serialize opencode TS plugin string literals as JSON

Address Copilot review finding S1: command and matcher values come from
user/extension YAML but were interpolated into single-quoted TypeScript
literals without escaping. A quote, backslash, or backtick in a command or
matcher produced invalid generated TypeScript and could inject code into the
plugin. _build_opencode_plugin now serializes every interpolated value
(command, event name, native hook key, matcher tool names) as a JSON string
literal via json.dumps, which produces a valid double-quoted, fully-escaped
TS/JS string.

Tests: a command and matcher containing quotes/backticks render inside JSON
double-quoted literals; the dangerous single-quoted form is absent. Updated
the forwards-output test for the new double-quoted literals.

Refs: PR #3704 Copilot review (finding S1)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): thread per-handler timeout through dispatcher, bash launcher for sh on Windows

Address two Copilot review findings:

- S4: the dispatcher and inner runner both hardcoded timeout=120, so a valid
  handler configured with a timeout above 120 seconds could never run for its
  full duration. The resolved per-handler timeout now flows through the chain:
  _dispatcher_command appends it (in the integration's native unit, plus a
  small buffer) as a 4th argument; the generated dispatcher reads sys.argv[3]
  and uses it for its inner subprocess and the `event run` invocation;
  `event run` accepts a timeout argument and passes it to
  resolve_and_run_event_command, which uses it for the script subprocess.
  Defaults to 120s when absent (backward compat with already-deployed
  dispatchers that don't pass the arg).
- S5: for a project configured with the sh script type on Windows,
  subprocess.run(shell=False) cannot execute a .sh file directly (chmod
  doesn't change that). The sh variant now prefixes a bash/sh launcher
  (resolved via shutil.which) on Windows, mirroring the ps branch's
  pwsh -File handling.

Tests: dispatcher reads the timeout arg and uses it; the native command
appends the resolved timeout; the sh variant uses a launcher on Windows.

Refs: PR #3704 Copilot review (findings S4, S5)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): delete shared dispatcher when last event integration disables events

Address Copilot review finding S3: the empty-resolved-map install path
(--events false upgrade, or override disabling events) stripped prior native
hooks but left the shared dispatcher behind. Because the new manifest no
longer claims it and stale cleanup excludes it (C3), .specify/events.py
became permanently orphaned when this was the last event-capable
integration — uninstall could not remove it.

Extracted the dispatcher refcount cleanup into _cleanup_shared_dispatcher
(shared by remove_integration_events and the empty-map install path) and
called it from the empty-map path so the dispatcher is deleted when no other
installed event-capable integration's manifest references it, while still
being retained when another integration does.

Tests: an --events false upgrade of the last event integration deletes the
dispatcher; with another integration still referencing it, the dispatcher is
retained.

Refs: PR #3704 Copilot review (finding S3)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): map user_prompt_submit/stop for Gemini and Tabnine

Address two Copilot review findings on adapter mappings:

- S6: Gemini exposes BeforeAgent for the per-turn prompt-submit lifecycle
  point (verified against Gemini CLI's hooks docs — BeforeAgent fires after
  the user submits a prompt, before planning). The mapping omitted
  user_prompt_submit, so valid extension handlers were skipped. Added
  user_prompt_submit -> BeforeAgent.
- S7: Tabnine's Gemini-compatible schema also provides BeforeAgent and
  AfterAgent, but the mapping omitted user_prompt_submit and stop. Added
  user_prompt_submit -> BeforeAgent and stop -> AfterAgent so those
  extension events fire instead of being warned about and skipped.

Tests: Gemini and Tabnine mappings include BeforeAgent/AfterAgent.

Refs: PR #3704 Copilot review (findings S6, S7)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): correct timeout unit threading through dispatcher and opencode TS

Address two Copilot review findings on the per-handler timeout threading
added in the prior round (S4):

- R2: _dispatcher_command passed _native_timeout(timeout_seconds) as the
  dispatcher's 4th argument, but the dispatcher interprets that argument as
  seconds. For Gemini/Qwen/Tabnine (ms adapters), 60 seconds became 60000
  seconds (~16h). It now passes the raw seconds (no unit conversion). The
  +5s buffer moves to the native hook timeout field
  (_native_timeout(seconds + EVENT_TIMEOUT_BUFFER)) so the agent's outer cap
  fires after the dispatcher's inner subprocess timeout — letting the inner
  kill its child cleanly instead of being killed mid-flight (which orphaned
  the grandchild script process).
- S3: the opencode TS runEvent hardcoded timeout: 60000 (60s) and invoked the
  dispatcher without its timeout argument, so handlers configured above 60s
  were killed early while the inner runner defaulted to 120s. runEvent now
  accepts a timeoutSec parameter (seconds); execFileSync uses
  (timeoutSec + buffer) * 1000 ms and appends String(timeoutSec) to the
  dispatcher argv, so both layers honor the per-handler timeout.

Tests: the dispatcher arg is raw seconds for ms adapters (60, not 60000); the
native timeout field carries the buffer (65 for a 60s Claude handler); opencode
runEvent threads the per-handler timeout as the 5th argument.

Refs: PR #3704 Copilot review (findings R2, S3)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): skip disabled extensions in _find_command_template and disk fallback

Address Copilot review finding S1: _find_command_template resolved event
commands without filtering enabled: false — the registry loop used
registry.keys() and the raw directory fallback could also rediscover
disabled extensions. If native cleanup is skipped (e.g. a JSONC config
cannot be parsed), a stale hook would therefore continue executing a
disabled extension.

Extracted the disabled-ID logic into _disabled_extension_ids (shared with
collect_extension_events) and applied it to both the manifest-resolution
loop and the on-disk fallback scan in _find_command_template, so a disabled
extension's command is never resolved for dispatch.

Tests: a disabled extension's command resolves to None via both the manifest
loop and the disk-fallback path.

Refs: PR #3704 Copilot review (finding S1)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): delete shared dispatcher regardless of fresh manifest claim

Address Copilot review finding S2: _cleanup_shared_dispatcher gated the
no-other-references deletion on `dispatcher_rel in manifest.files`. An
`integration upgrade --integration-options "--events false"` passes a fresh
manifest (created in _migrate_commands) that never recorded the dispatcher,
so the condition was false even though the old on-disk manifest owned the
file — and stale cleanup explicitly excludes it (C3), leaving
.specify/events.py orphaned after the last integration disabled events.

The refcount deletion now runs independently of whether the new manifest
contains the key; manifest.remove() stays conditional (a no-op when the key
is absent).

Tests: an upgrade passing a fresh manifest (no dispatcher claim) still
deletes the shared dispatcher when no other integration references it.

Refs: PR #3704 Copilot review (finding S2)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): refresh native event config after extension update

Address Copilot review finding S4: the _refresh_events_and_warn helper was
wired to extension add/remove/enable/disable, but not to extension_update,
which replaces the installed extension.yml (remove + install_from_zip).
If an update adds, removes, or changes event declarations, native configs
remained stale until a manual integration upgrade.

extension_update now refreshes once after the update loop finalizes its
successful updates (skipped on rollback/failure), mirroring the other
lifecycle commands.

Refs: PR #3704 Copilot review (finding S4)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): make the dispatcher self-contained for one-time/temporary installs

Address Copilot review finding R1: the dispatcher required a persistent
`specify` executable at runtime. The supported one-time flow runs
`specify init` through a temporary `uvx` environment that is discarded, so
generated hooks later reached the PATH fallback with no `specify` on PATH
and every event failed.

The generated .specify/events.py is now self-contained:

- Preferred path: it imports specify_cli.events.resolve_and_run_event_command
  when the package is importable (durable pip/pipx/uv-tool install), which
  handles extension manifests whose file stem differs from the command name
  and the project's custom script selection, staying in sync with the CLI.
- Fallback path: an inline stdlib-only resolver finds the command template,
  parses its scripts: frontmatter, resolves the project's script variant
  (reading .specify/init-options.json directly), and runs the script with
  the correct launcher (pwsh/bash/interpreter), so one-time and temporary
  installs work without a persistent `specify` executable on PATH.

The `event run` CLI command remains available for manual use; the dispatcher
no longer depends on it.

Tests: the dispatcher delegates to specify_cli when importable and falls back
to the inline resolver when it is not; the inline fallback finds the command
template and runs its script end-to-end (shadowing specify_cli with an empty
package to force the fallback); the preferred path also runs end-to-end.

Refs: PR #3704 Copilot review (finding R1)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): validate safe destination on all removers and teardown unlinks

Address Copilot review findings (inline #1, suppressed #2, #3):

- Guard all removers (_remove_json_entries, _remove_copilot_entries,
  _remove_json_root_entries, _remove_opencode_entries, _remove_native_event_hooks),
  _cleanup_shared_dispatcher, and remove_integration_events with
  _ensure_safe_destination(dst) before reading, rewriting, or unlinking.
- Prevents teardown or removal operations from overwriting or unlinking external
  files if a config file, plugin path, or .specify directory is replaced with
  a symlink post-installation.

Tests: added unit tests in TestSafeWriteDestination covering JSON config,
OpenCode plugin, and TOML teardown symlink rejection.

Refs: PR #3704 Copilot review (findings inline #1, suppressed #2, #3)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): manifest-driven resolution and disabled-extension filter in dispatcher template

Address Copilot review finding (suppressed #1):

- In _EVENTS_DISPATCHER_TEMPLATE's _find_command_template, read
  .specify/extensions/.registry to identify disabled extensions (enabled == false).
- Parse provides.commands in each enabled extension's extension.yml to match
  command_name to its declared file, so commands whose file stem differs
  from the command name (e.g. speckit.selftest.extension -> commands/selftest.md)
  resolve correctly when specify_cli is unavailable (one-time uvx installs).
- Skip disabled extensions in both manifest-driven and on-disk fallback scans.

Refs: PR #3704 Copilot review (finding suppressed #1)

Assisted-by: opencode (model: glm-5.2, autonomous)

* fix(events): positive integer timeout validation and OpenCode multi-handler error aggregation

Address Copilot review findings (suppressed #4, #6):

- In validate_events and _validate_resolved_event, validate that timeout (when
  present) is a positive integer (isinstance(t, int) and not isinstance(t, bool)
  and t > 0). Rejects string, boolean, zero, or negative timeouts at manifest
  and override validation time instead of crashing during setup/refresh.
- In _build_opencode_plugin, wrap each runEvent invocation inside _ev() in a
  try/catch block, collect error messages, and throw an aggregate error at the
  end if any handler failed. Guarantees that all handlers for an event execute
  to completion even if an earlier handler throws.

Tests: added TestTimeoutValidation testing string, boolean, and zero timeout
rejections; updated OpenCode plugin merging tests for try/catch error collection.

Refs: PR #3704 Copilot review (findings suppressed #4, #6)

Assisted-by: opencode (model: glm-5.2, autonomous)
2026-07-29 08:00:26 -05:00
Ali jawwad
1fff7a196d fix(extensions): guard the required manifest sections so one bad extension cannot break extension list (#3797)
ExtensionManifest.REQUIRED_FIELDS only checks key PRESENCE, so a section that is
written but left empty (`provides:` -> None) or given the wrong shape
(`provides: []`) passes it and then fails on first use:

    extension: null  -> TypeError: argument of type 'NoneType' is not iterable
    requires:  null  -> TypeError: argument of type 'NoneType' is not iterable
    provides:  null  -> AttributeError: 'NoneType' object has no attribute 'get'
    provides:  []    -> AttributeError: 'list' object has no attribute 'get'

Neither is a ValidationError, so both escape the callers that already handle
malformed manifests. list_installed() catches ValidationError only and has a
deliberate "Corrupted extension" fallback, so a single bad extension took down
the whole command -- reproduced end-to-end:

    before: specify extension list -> exit 1, raw AttributeError, no output
    after:  specify extension list -> exit 0, the good extension listed, the
            bad one shown as "Corrupted extension"

Add an isinstance guard for each required section, mirroring the nested guards
already in this function ("Invalid provides.commands: expected a list", "Invalid
hooks: expected a mapping") and _load_yaml's document-root check. Only the three
REQUIRED sections lacked one.

`provides: {}` is unaffected: it is a well-shaped mapping, so an extension that
provides only hooks still validates, and with no hooks it keeps the pre-existing
"must provide at least one command or hook" message. Both are locked by tests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 07:54:25 -05:00
Noor ul ain
b048e339a5 fix(presets): escape installed preset metadata in Rich output (#3826)
* fix(presets): escape installed preset metadata in Rich output

`preset.yml` is user-editable, but the installed-preset display paths
interpolated its fields straight into `console.print`, where Rich parses
`[...]` as a style tag. PR #3773 escaped the *catalog* branch of these
commands; the local branch was left behind, so the same field rendered
correctly from a catalog and incorrectly once installed.

Two failure modes:

- Silent data loss: a description `Does [stuff] nicely` renders as
  `Does  nicely`.
- Hard crash: an unbalanced tag such as `Broken [/red] tag` raises
  `rich.errors.MarkupError`, aborting `preset list`/`preset info` with a
  traceback and exit code 1 — the preset cannot be inspected at all.

Escaped the installed branch of `preset list` (name/id/version/
description) and `preset info` (name/id/version/description/author/tags/
repository/license plus the per-template description), and the catalog
branch's tags join that the earlier sweep missed.

`preset resolve` was unescaped throughout: it echoes its own
`template_name` argument, so `preset resolve 'no[/red]such'` crashed on
user input alone. Also escaped the resolved paths, layer sources, and
composition-error message.

Separately, the composition chain's `[{strategy_label}]` was consumed as
a style tag, so every chain line printed a blank label instead of
`[base]`/`[append]`. Escaped the literal bracket as `\[`, matching the
step-graph line in `workflow info`.

Regression tests in `TestInstalledPresetRichMarkup` cover all five
behaviours; each fails before this change.

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

* test(presets): cover catalog tags and resolve escapes

Addresses Copilot review feedback on #3826: two escapes added by the
previous commit had no regression assertion, so they could be reverted
with the suite still green.

- `test_info_escapes_catalog_markup` asserted every catalog field except
  `tags`; the new tag assertion only exercised an installed preset. Assert
  the rendered tags join in the catalog branch too.
- The escapes on `preset resolve`'s resolved path, layer source, and
  composition-error message were untested. Add three cases patching
  `PresetResolver` to feed markup through the top-layer line, the no-layer
  `resolve_with_source` fallback, and a markup-bearing `resolve_content`
  exception.

Test-the-test: with `_commands.py` reverted to the pre-fix revision, 9 of
the 10 markup tests fail (was 5); with the fix applied all 10 pass.

A closing tag cannot be embedded in the mocked path — `Path` treats the
`/` as a separator — so the path assertion uses an opening tag for the
swallowing case and the unbalanced tag rides on the adjacent `source`
field on the same line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Assisted-by: Claude Code (model: claude-opus-5, under direct human supervision)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 07:48:28 -05:00
Ali jawwad
d99170fb5e fix(workflows): dispatch prompt steps via the resolved executable (#3793)
PromptStep._try_dispatch runs `subprocess.run(exec_args, ...)` with an
UNRESOLVED argv[0] -- a bare name like `claude`. On Windows subprocess.run calls
CreateProcess, which does not consult PATHEXT, so an agent CLI installed as a
`.cmd`/`.bat` shim (the usual npm layout) raises FileNotFoundError [WinError 2].
That OSError is swallowed by the method's `except OSError: return None`, and
execute() then reports "CLI not found or not installed" -- even though the
step's own preflight `shutil.which(...)` two lines earlier just found it.

The sibling path does not have this bug: IntegrationBase.dispatch_command (used
by the `command` step) resolves argv[0] through shutil.which first, added in
8e5643d for exactly this reason. Same machine, same integration, CLI present as
a .cmd shim:

    type: prompt  -> failed    "integration 'claude' CLI not found or not installed."
    type: command -> completed

Primitive confirmation: bare `subprocess.run(["fakeagent"])` raises
[WinError 2] while `subprocess.run([shutil.which("fakeagent")])` runs fine.

Reuse the path the preflight already resolved (`fallback_cli_path`) instead of
calling which() again, so the shim is executed. On POSIX it is the same
executable, so behaviour is unchanged there.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 07:44:04 -05:00
Manfred Riem
f04a36a629 chore: release 0.14.4, begin 0.14.5.dev0 development (#3850)
* chore: bump version to 0.14.4

* chore: begin 0.14.5.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-29 07:09:24 -05:00
Ali jawwad
be33d2a5f6 fix(bundler): degrade non-UTF-8 config reads into BundlerError (#3784)
yamlio.py is the single chokepoint for every bundler read, and its module
docstring states the contract: "All reads/writes go through these functions so
that IO failures degrade into actionable BundlerError rather than raw
tracebacks."

Both readers catch only OSError, but `Path.read_text(encoding="utf-8")` and
`json.load()` raise UnicodeDecodeError on a non-UTF-8 file --
`issubclass(UnicodeDecodeError, OSError)` is False (its MRO is UnicodeError ->
ValueError). So the decode error escaped uncaught:

    load_yaml: LEAKED UnicodeDecodeError -> 'utf-8' codec can't decode byte 0xff
    load_json: LEAKED UnicodeDecodeError -> 'utf-8' codec can't decode byte 0xff

In load_json, json.JSONDecodeError does not help: it is a *sibling* of
UnicodeDecodeError, not a parent.

This is realistic rather than theoretical -- on Windows, PowerShell 5.1's
`Out-File` and `>` default to UTF-16, so a hand-edited
`.specify/bundle-catalogs.yml` or records file hits it.

Widen both read clauses to `(OSError, UnicodeError)`, matching the sibling
catalog readers (catalogs.py:101, workflows/catalog.py:336). JSONDecodeError
deliberately stays FIRST so malformed-but-decodable JSON keeps its more
specific "Invalid JSON" message; a regression test locks that ordering.

Write paths are unaffected -- verified that dump_yaml/dump_json do not leak
UnicodeEncodeError (both escape unencodable input), so this stays scoped to the
two read paths.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 17:29:27 -05:00
Ali jawwad
751eae727e fix(workflows): escape the step-progress line so step ids render (and / stops failing the run) (#3783)
`workflow run` and `workflow resume` both print the step-progress line as
`f"  ▸ [{sid}] {label} …"`. Rich parses the bracketed step id as a style tag,
which produces three failures on main:

1. The id is SILENTLY SWALLOWED on every run -- the only identifying content on
   the line. `id: greet` prints "  ▸  shell …"; "[greet]" is absent.
2. An id that forms a closing tag FAILS THE WHOLE RUN. `validate_workflow`
   places no charset restriction on step ids, so `id: "/"` is a valid workflow;
   the callback then raises MarkupError, which propagates into execute()'s
   handler -> run persisted as `failed` with empty `step_results`, the step
   never executed, exit 1 with a Rich internals error.
3. An id that is a real style (`bold`, `red`) is applied as FORMATTING to the
   rest of the line.

The unescaped `label` (from `step_config["command"]`) compounds it.

Escape the literal bracket with `\[` and escape both interpolated values, at
both sites. This mirrors the `\[<type>]` step-graph precedent already in this
file (workflow_info). Escaping only the values is NOT sufficient -- the
f-string's own brackets are what Rich consumes.

Verified through the real CLI: ids `greet`/`bold`/`a]b` now render verbatim, and
`id: "/"` goes from a failed run to `Status: completed`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 17:28:22 -05:00
github-actions[bot]
4ad7ef2b42 Update Agent Parity Governance preset to v0.4.1 (#3830)
Update agent-parity-governance preset submitted by @hindermath to:
- presets/catalog.community.json (version, download_url, documentation, description, updated_at)
- docs/community/presets.md community presets table

Closes #3829

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-28 17:25:54 -05:00
Ali jawwad
6ef96373e2 fix(integrations): reject empty --commands-dir in generic raw_options (#3714)
* fix(integrations): reject empty --commands-dir in generic raw_options

GenericIntegration._resolve_commands_dir has a parity gap: the parsed-options
branch guards emptiness (`if commands_dir:`), but the raw_options fallback
returned the value verbatim with no check. So `--integration-options=
"--commands-dir="` (or `--commands-dir ""`) resolves to `""`, which makes
setup() compute `dest = project_root / "" == project_root` and write every
speckit command file (specify.md, plan.md, ...) directly into the PROJECT
ROOT — silently bypassing the documented "--commands-dir is required"
contract and polluting the repo root.

Apply the same non-empty guard to the raw_options branch so an empty value
falls through to the existing "required" ValueError on every input form.
Non-empty values resolve exactly as before.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* fix(integrations): reject a BLANK --commands-dir, not just an empty one

Self-review follow-up: bare truthiness only closes the empty-string subset. A
whitespace-only value passed both branches (verified: raw "--commands-dir ' '"
returned ' ', parsed {"commands_dir": "  "} returned '  '), so command files
still landed in a directory literally named " " instead of failing with the
documented "required" error.

Require a non-BLANK value and normalize the padding, in the parsed branch as
well as raw_options so the two cannot drift apart -- a padded but real value
(" .myagent/cmds ") now resolves to ".myagent/cmds" rather than being rejected,
matching how other padded config references are normalized.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* fix(integrations): use strip() only to test blankness, return the value verbatim

Address review feedback: normalizing with strip() changed EXISTING valid values,
contrary to this PR's "no behaviour change for valid usage" claim -- a quoted
`--commands-dir ' commands '` previously targeted the literal ` commands `
directory and would have started writing to `commands` instead.

The blankness test still uses strip(), but the accepted value is now returned
unchanged, so the fix stays limited to empty/blank input. Test updated
accordingly: a padded non-blank value must round-trip verbatim (quoted in
raw_options, since shlex.split() consumes unquoted padding before this code
sees it).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 17:25:04 -05:00
Ali jawwad
4bc79fe243 fix(presets): guard non-list/non-mapping provides.templates in PresetManifest (#3712)
* fix(presets): guard non-list/non-mapping provides.templates in PresetManifest

PresetManifest._validate iterated provides["templates"] with no shape guards,
unlike the sibling ExtensionManifest. A malformed third-party preset.yml
crashed with a raw TypeError that escapes the install handler's
PresetValidationError/PresetError catch and dumps an unhandled traceback:

  templates: 5       -> "'int' object is not iterable"
  templates: [null]  -> "argument of type 'NoneType' is not iterable"
  templates: [5]     -> "argument of type 'int' is not iterable"

(and a string/list entry raised the misleading "Template missing 'type',
'name', or 'file'"). Add a container list-guard and a per-entry mapping-guard
that raise a clean PresetValidationError, mirroring ExtensionManifest's
provides.commands guards. Valid manifests (list of mappings) are unaffected.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* fix(presets): check provides.templates type before emptiness

Address review feedback: the new shape guard sat behind the existing truthiness
check, so a FALSY non-list (templates: 0/false/null/''/{}) still reported the
misleading "Preset must provide at least one template" instead of the type
error. Only truthy non-lists (5, "oops", {"a": 1}) reached the guard, which is
why the original test (templates: 5) passed.

Split the checks: presence -> container type -> emptiness. A falsy non-list now
reports "expected a list"; an EMPTY LIST keeps the "at least one template"
message, since that genuinely is a well-typed container with no templates.
Parametrize the non-list test over truthy AND falsy values, and add a
regression guard for the empty-list message.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* test(presets): drop the redundant empty-list templates test

Address review feedback: the added test duplicated the pre-existing
test_no_templates_provided -- both set provides.templates to [] and assert the
same "must provide at least one template" error. That test already guards the
empty-list result of the type-before-emptiness ordering, so keeping mine only
added maintenance. Left a pointer comment where it was.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 17:24:01 -05:00
Ali jawwad
596a31ee0f fix(auth): resolve az via shutil.which so azure-cli token works on Windows (#3709)
* fix(auth): resolve az via shutil.which so azure-cli token works on Windows

AzureDevOpsAuth._acquire_via_az_cli runs subprocess.run with a bare "az".
On Windows the Azure CLI is installed as az.cmd, and subprocess.run calls
CreateProcess, which does not consult PATHEXT -- so a bare "az" fails with
WinError 2 even after `az login`, and azure-cli token acquisition silently
returns None (the OSError is swallowed).

Resolve the executable with shutil.which("az") (which honors PATHEXT) before
the call, mirroring the maintainer's own fix in integrations/base.py for the
same CreateProcess/.cmd issue. `or "az"` preserves prior behavior (and the
existing not-installed OSError path) when az is absent. POSIX is unaffected.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* fix(auth): require an absolute az path so the CWD cannot hijack the lookup

Self-review catch on my own change: resolving with a bare
`shutil.which("az") or "az"` widened an execution surface. On Windows
shutil.which prepends the CURRENT DIRECTORY to the search path (unless
NoDefaultCurrentDirectoryInExePath is set) AND honors PATHEXT, so a stray
.\az.cmd / .\az.bat in the working directory resolves ahead of the real Azure
CLI -- for a credential operation. Verified: with the real az scrubbed from
PATH, shutil.which("az") returns '.\az.CMD'.

Accept the resolution only when it is absolute; otherwise fall back to the bare
"az" (which also preserves the existing not-installed OSError path). A
legitimate install always resolves absolutely, so the Windows .cmd fix this PR
exists for is unaffected. The not-installed and PATHEXT tests are extended with
relative-result cases, all of which fail before this commit.

Note: integrations/base.py resolves executables the same way; hardening that
shared path is a separate concern and is left untouched here.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* test(auth): build the mocked az path with the host's path rules

Fixes the macOS CI failure. The test hardcoded a Windows absolute path, but the
production code calls os.path.isabs() -- on POSIX runners "C:\Program
Files\..." reads as RELATIVE, so the fallback branch ran and argv[0] was "az"
instead of the resolved path.

Construct the path with os.path.join(os.path.abspath(os.sep), ...) so it is
absolute under the host's rules, and assert against that value. The fallback
test's inputs (".\az.CMD", "az.cmd", "./az") are relative under both ntpath
and posixpath, so they were already portable.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 17:21:42 -05:00
Ali jawwad
52a6514e38 fix(workflows): reject falsy non-mapping workflow-catalogs.yml top level (#3707)
* fix(workflows): reject falsy non-mapping workflow-catalogs.yml top level

WorkflowCatalog._load_catalog_config parsed the config with
`yaml.safe_load(...) or {}`, then checked `isinstance(data, dict)`. The
`or {}` coerces a FALSY non-mapping top level (`[]`, `false`, `0`, `''`) to
`{}` *before* the guard runs, so those are silently swallowed as "empty
config" and fall back to the built-in defaults -- while a TRUTHY non-mapping
(`5`, a bare list) correctly raises. Same silent-swallow inconsistency the
bundler catalog reader fixed for its own config.

Drop the `or {}` and branch on `None` (empty document / explicit `null`)
explicitly: `None` stays a valid no-op, every non-mapping (falsy or truthy)
now raises the same actionable error. Correct configs are unaffected.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* docs(workflows): describe the catalog-config fallthrough accurately

The comment said a None return means "no project catalogs, fall back to the
built-in defaults". Both halves were imprecise: _load_catalog_config serves the
project AND user configs, and get_active_catalogs falls through env -> project
-> user -> built-in, so a None from the project layer moves on to the USER
config; the built-in defaults apply only once every layer returned None.

Reword the loader comment and the mirror test docstring. Comments only -- no
behaviour change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* fix(workflows): close the same falsy-mask gap in 'catalogs' and StepCatalog

Self-review follow-up: the top-level fix left the identical asymmetry live five
lines below, and again in this file's twin loader.

1. WorkflowCatalog._load_catalog_config: the ``catalogs`` shape check sat behind
   an emptiness check, so a FALSY non-list (``catalogs: {}``/``''``/``0``/
   ``false``) was silently swallowed as "no catalogs" while ``catalogs: 5``
   raised. Verified before this commit: ``catalogs: {}`` -> None (no error).
   Shape now checked first; absent/explicit-null and empty-list stay no-ops
   (matching the bundler's reader).

2. StepCatalog._load_catalog_config -- the step-catalog twin, read the same way
   -- still had ``yaml.safe_load(...) or {}``, so falsy non-mappings bypassed its
   isinstance guard (``[]`` -> None while ``5`` raised). Same two guards applied,
   keeping the two loaders in lockstep.

Eight new parametrized cases, all failing before this commit.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* test(workflows): move StepCatalog guards into TestStepCatalog and add the nested case

Address three review points:

1. The StepCatalog regression tests sat inside TestWorkflowCatalog, so a
   targeted `pytest ...::TestStepCatalog` run skipped them entirely. Moved into
   that class, where the duplicated twin loader belongs.
2. StepCatalog had no nested-value coverage (only top-level). Added the
   parametrized falsy ``catalogs:`` case, plus the absent/null/empty no-op
   cases. Verified against upstream/main's catalog.py: 8 fail there, pass here.
3. Dropped the inaccurate parity parenthetical. src/specify_cli/catalogs.py
   RAISES for missing/empty ``catalogs`` and coerces a null document to {}, so
   it is not the behavior this loader matches -- the comment now just states
   what changed (only the misreported shapes) without claiming parity.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 17:20:21 -05:00
Ali jawwad
3dad624e5d fix(integrations): render hyphenated /speckit-<name> for Droid (always-slash agent) (#3688)
* fix(integrations): render hyphenated /speckit-<name> for Droid (always-slash agent)

DroidIntegration is an always-skills agent: it installs commands as
.factory/skills/speckit-<name>/SKILL.md and its build_command_invocation
returns the hyphenated /speckit-<name>. But "droid" was missing from every
_invocation_style set, so is_slash_skills_agent("droid", True) returned False
and both HookExecutor._render_hook_invocation and `specify init` next-steps
fell through to the dotted /speckit.<name> form — a command Droid never
registers.

Add "droid" to ALWAYS_SLASH_AGENTS, matching its always-skills siblings
grok/trae/zed/devin (each added there by their own integration PR; droid's
#3587 omitted it).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* test(integrations): assert Droid is ALWAYS-slash (disabled case too)

Address review: the test only covered ai_skills=True, which would also pass
if Droid were miscategorized as CONDITIONAL_SLASH. Add the ai_skills=False
assertion — True there is what distinguishes an ALWAYS_SLASH agent from a
conditional one.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 17:14:31 -05:00
github-actions[bot]
0231ee056b [preset] Update A11Y Governance preset to v0.4.2 (#3828)
* Update A11Y Governance preset to v0.4.2

Update a11y-governance preset submitted by @hindermath to:
- presets/catalog.community.json (version, download_url, documentation, description, updated_at)
- docs/community/presets.md community presets table

Closes #3827

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

* Potential fix for pull request finding

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

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-28 16:55:37 -05:00
github-actions[bot]
ca2b494335 [preset] Update Parallel Autonomous Run Governance to v0.2.4 (#3825)
* Update Parallel Autonomous Run Governance preset to v0.2.4

Update parallel-autonomous-run-governance preset submitted by @hindermath:
- presets/catalog.community.json (version, download_url, documentation, description, requires.extensions, tags, updated_at)
- docs/community/presets.md community presets table

Closes #3824

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

* Potential fix for pull request finding

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

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-28 16:23:34 -05:00
Quratulain-bilal
86c4610b7d fix: correct Optional type annotation for _resolved_dir parameter (#3801) 2026-07-28 16:07:27 -05:00
Quratulain-bilal
a2b0d0d3c1 fix: add timeout to prompt step subprocess execution (#3768)
The prompt step subprocess.run() had no timeout, allowing a hung
LLM invocation to block the entire workflow engine indefinitely.
The shell step already defaults to 300s timeout.

Add timeout parameter (defaulting to 300s, matching shell step)
and handle subprocess.TimeoutExpired gracefully.
2026-07-28 15:49:45 -05:00
Quratulain-bilal
56b1839fba fix: handle tags containing / in GitHub release asset URL resolution (#3767)
The tag extraction in resolve_github_release_asset_api_url split the
URL path on / and assumed the tag was a single segment at index 4.
Tags containing literal / (e.g. feature/v1) would be split across
multiple segments, causing the tag to be truncated to only the first
part and the asset name to include leftover tag segments.

Fix by reconstructing the tag as all segments between 'download' and
the final asset segment: tag = '/'.join(parts[4:-1]), asset = parts[-1].
2026-07-28 15:45:38 -05:00
Marsel Safin
98b2551ade fix(presets): escape catalog metadata in discovery output (#3773)
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-28 15:36:57 -05:00
github-actions[bot]
9bb7206d3b Update Autonomous Run Governance preset to v0.3.3 (#3823)
Update autonomous-run-governance preset submitted by @hindermath to:
- presets/catalog.community.json (version, download_url, documentation, description, tags, updated_at)
- docs/community/presets.md community presets table

Closes #3821

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-28 15:18:57 -05:00
Quratulain-bilal
89e204ca3c fix: use bounded read for integration catalog HTTP responses (#3763)
* fix(skills): match closing frontmatter delimiter on its own line

SkillsIntegration.setup parsed each command template's frontmatter with
raw.split("---", 2). A bare substring split stops at the first `---`
*anywhere*, so a template whose description embeds `---` (e.g.
"Separate sections with --- markers") truncated the parsed frontmatter:
later keys were dropped, the description fell back to the generic default,
and the leftover frontmatter spilled into the skill body.

Scan for the closing `---` on its own line instead, for both the
description parse and the body strip. The frontmatter block is parsed
unstripped so trailing newlines in literal (|) block scalars still survive,
and the body slice keeps the newline after the marker so output stays
byte-for-byte identical to the old split for well-formed templates.

Adds regression tests covering the dashed-description truncation and the
frontmatter-spilled-into-body cases.

* fix: use bounded read for integration catalog HTTP responses

The integration catalog fetch used unbounded resp.read() to read
HTTP responses into memory. A malicious or misconfigured catalog
server could return an arbitrarily large response causing OOM.

Replace with read_response_limited() capped at MAX_JSON_METADATA_BYTES
(1 MiB), consistent with how other JSON fetch paths in the codebase
(_version.py, _github_http.py, authentication/azure_devops.py) already
enforce bounded reads.

Pass error_type=IntegrationCatalogError so oversized catalogs are
caught by the existing per-entry recovery path in
_get_merged_integrations() rather than aborting the entire merge.

Add regression test verifying oversized responses are rejected as
IntegrationCatalogError and that healthy catalogs remain usable.
2026-07-28 14:32:31 -05:00
21Silva
88e997306f docs: add Simplified Chinese translation of README (#3740)
Add README.zh-CN.md with a hand-crafted (non-machine) Chinese
translation of the project README, and add a language switcher
link at the top of both README files.

Code blocks, command names, badges, and links are kept identical to
the English source; only prose is translated.

Co-authored-by: yifosheng001 <yifosheng001@ke.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 13:59:39 -05:00
github-actions[bot]
186ca25c99 Update Intake Sequencing Governance preset to v0.2.2 (#3809)
Update intake-sequencing-governance preset submitted by @hindermath to:
- presets/catalog.community.json (version, download_url, documentation, description, provides)
- docs/community/presets.md community presets table

Closes #3807

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-28 13:18:10 -05:00
Ali jawwad
a9c9905400 fix(workflows): reject non-string/non-boolean 'condition' in if/while/do-while steps (#3706)
* fix(workflows): reject non-string 'condition' in if/while/do-while steps

`if_then`, `while_loop`, and `do_while` validate() confirm `condition` is
present but never that it is a string. execute() feeds it to
`evaluate_condition()`, which returns a non-string as-is and takes `bool()`
of it -- so `condition: [1, 2]` (a list authoring mistake) silently resolves
to `True`, branching wrongly / spinning the loop to `max_iterations`, with no
error reported.

Reject a present-but-non-string `condition` at validation, mirroring the
existing prompt/shell/command 'must be a string' guards. `"true"`/`"false"`
and expressions like `"{{ ... }}"` are strings, so they stay valid.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* docs(workflows): describe the evaluate_expression/evaluate_condition split accurately

Address review feedback: the guard comments attributed the non-string
pass-through to evaluate_condition(), which always returns a bool. It is
evaluate_expression() (called by evaluate_condition) that returns a non-string
unchanged; evaluate_condition then applies bool() to that value.

Reword all four sites (if/while/do-while guards + the mirror test comment) to
name the two stages correctly. Comments only -- no behaviour change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* fix(workflows): keep a literal bool 'condition' valid

Self-review catch: the guard rejected EVERY non-string, which broke an input
that previously worked. An unquoted ``condition: false`` is idiomatic YAML and
resolves exactly today -- evaluate_expression passes the bool through and
evaluate_condition's bool() is a no-op (verified: evaluate_condition(False) is
False, (True) is True). The if/while steps even default ``condition`` to the
bool ``False`` themselves, so bool is the field's natural type, not an
authoring mistake.

Accept (str, bool) and reject only the genuinely silent-coercion types
(list/dict/int/float, e.g. condition: [1, 2] is always True). Message updated
to "must be a string or boolean"; the bad-value tests drop True and gain 1.5,
and each step gains a positive bool case.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 11:40:36 -05:00
Marsel Safin
054fb7723d fix(bundle): escape catalog metadata in discovery output (#3774)
* fix(bundle): escape catalog metadata in discovery output

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

* fix(bundle): escape provides fallback values

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

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-28 11:35:49 -05:00
Noor ul ain
8bfe6e14d1 fix(workflows,extensions): tolerate non-list catalog tags in search/info display (#3770)
`workflow search`, `workflow info`, `extension search` and `extension info`
crashed with `TypeError: 'int' object is not iterable` when a catalog entry
carried a scalar `tags:` value (e.g. `tags: 5`). Catalog payloads are
user-editable YAML/JSON, so this shape reaches the display unvalidated.

Both backends already guard their tag *filter* with
`isinstance(raw_tags, list)` — `WorkflowCatalog.search` and
`ExtensionCatalog.search` skip a non-list `tags` cleanly. Only the display
paths were unguarded: they tested truthiness (`if info.get("tags"):`) and
then iterated. A scalar is truthy but not iterable, so `--tag` filtering
survived while plain `search`/`info` rendering blew up.

Note this is distinct from the non-string *element* handling added in
#3746/#3747: coercing elements with `str(t) for t in ...` does not help when
`tags` is not a sequence at all. The fix is the guard the sibling
integration commands already use — `integrations/_query_commands.py:332,402`
gate on `isinstance(tags, list) and tags`. This aligns workflows and
extensions with that reference pattern, leaving all four tag-join display
sites in these modules consistent.

Regression tests drive the full CLI via CliRunner and cover search + info in
one case per module; both fail before the fix with the exact TypeError.


Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 11:24:19 -05:00
Marsel Safin
a482fb2fce fix: correct nullable resolved directory annotation (#3771)
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-28 11:23:02 -05:00
Noor ul ain
05cb3cba34 fix(presets): tolerate non-string and non-list catalog fields in preset search/info (#3769)
* fix(presets): tolerate non-string and non-list catalog fields in preset search/info

`preset search` and `preset info` crashed with a raw traceback on catalog
payloads that are valid YAML/JSON but not string-typed. Catalog files are
user-editable, so these shapes reach the code unvalidated.

`PresetCatalog.search` had three unguarded assumptions:

- `--author` called `.lower()` on the raw value → `AttributeError: 'int'
  object has no attribute 'lower'` for `author: 789`.
- the query searchable-text join passed raw `name`/`description` through →
  `TypeError: sequence item 0: expected str instance, int found`.
- the `--tag` filter iterated `tags` without a list check, so a scalar
  `tags: 5` (truthy, not iterable) raised `TypeError: 'int' object is not
  iterable`.

PR #3743 fixed only the non-string *elements* of `tags` here; a non-list
`tags` and the `author`/`name`/`description` fields were still unguarded.
The sibling catalogs already handle all of these — `extensions/__init__.py`
and `integrations/catalog.py` coerce with `str(...)` and gate on
`isinstance(raw_tags, list)`. This aligns presets with them.

The same scalar-`tags` crash reached the four display sites in
`presets/_commands.py`, so those now gate on `isinstance(tags, list)`,
matching `integrations/_query_commands.py`. Note `PresetManifest.tags`
returns `self.data.get("tags", [])` and manifest validation does not
enforce list-ness, so a local `preset.yaml` with `tags: 5` validates
successfully and then crashed `preset info` — hence the guard on the
local-manifest branch too.

While here, `preset search` printed tags unescaped, so a tag containing
`[bold]` was silently swallowed as a Rich style tag; it now routes through
`_escape_markup` like the `preset list` line directly above it.

Regression tests in `TestPresetTagsNonString` drive the full CLI path via
CliRunner. All five fail before the fix, each with the exact exception it
targets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Assisted-by: Claude Opus 5 (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>

* chore: regenerate security audit requirements (annotated-doc 0.0.5)

The Security Audit workflow's "Check committed audit requirements are
current" step regenerates requirements with `uv pip compile --upgrade`,
which now resolves annotated-doc==0.0.5. Re-sync the committed snapshot
so the check passes. No pyproject dependency changes; upgrade drift only.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b3d0333b-4a36-4f69-9273-3ac0c3f46481

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b3d0333b-4a36-4f69-9273-3ac0c3f46481
2026-07-28 11:07:51 -05:00
Marsel Safin
2e44ed60e8 fix(integrations): escape catalog metadata in discovery output (#3772)
* fix(integrations): escape catalog metadata in discovery output

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

* fix(integrations): escape unknown query IDs

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

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-28 10:20:31 -05:00
github-actions[bot]
999f8e6497 Update Verify Review Ship extension to v0.4.2 (#3792)
Update verify-review-ship extension submitted by @cadugevaerd to:
- extensions/catalog.community.json (version, download_url, sha256, updated_at)
- docs/community/extensions.md community extensions table

Closes #3791

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-28 10:20:07 -05:00
Ben Buttigieg
655a3cb8ca fix(integrations): preserve native skill invocation prefixes (#3663)
* fix(integrations): use native dollar skill invocations

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

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

Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19

* fix(integrations): preserve skill post-process idempotence

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

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

Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19

* fix(integrations): preserve literal skill invocations

Resolve generated command references with the active agent prefix instead of rewriting all slash-form text during post-processing.

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

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

Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19

* fix(integrations): preserve shared invocation prefix

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

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

Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19

* fix(integrations): preserve install invocation prefix

Pass dollar-style skill prefixes through bare-project integration installation and cover the shared template output.

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

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

Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19

* fix(integrations): preserve dollar refs everywhere

Use agent-native invocation prefixes in extension command registration and dynamic shared-script command hints.

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

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

Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19

* fix(shared-infra): preserve dollar command hints

Escape dollar-prefixed commands embedded in Bash strings and propagate the native prefix into installed Python command helpers.

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

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

Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19

* fix(shared-infra): render native helper prefixes

Rewrite installed Bash and PowerShell formatter return expressions so direct callers receive the selected integration's native prefix.

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

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

Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19

* fix(skills): use invocation-neutral hook guidance

Describe hook-derived references as command invocations so dollar-prefixed skills do not receive contradictory slash-command terminology.

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

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

Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19

* test(integrations): expect native fallback invocation

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

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

Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19

* refactor(integrations): centralize invocation prefix selection

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

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

Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19

* fix(integrations): add Kimi /skill: prefix and fix docstrings

- Add SKILL_COLON_AGENTS frozenset and get_invocation_prefix() to
  _invocation_style.py so Kimi resolves to '/skill:' in skills mode
- Switch invoke_prefix_for_integration() to use get_invocation_prefix()
  instead of the binary dollar/slash check
- Update post_process_skill_content docstring (base.py) to cover both
  slash and dollar native invocation forms
- Update _resolve_command_refs_in_skill docstring (presets/__init__.py)
  to document the dollar-prefixed result alongside slash forms

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

* fix(agents): use get_invocation_prefix for Kimi in register_commands

Replace the binary is_dollar_skills_agent ternary with get_invocation_prefix
so that __SPECKIT_COMMAND_*__ tokens in Kimi skill files resolve to
/skill:speckit-<name> rather than /speckit-<name>.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

* fix(agents): remove unused is_dollar_skills_agent import

Leftover from replacing the inline ternary with get_invocation_prefix.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

* fix(integrations): use get_invocation_prefix in post_process_skill_content

Replaces the binary is_dollar_skills_agent ternary with get_invocation_prefix
so that Kimi's hook-command note is injected as /skill:speckit-git-commit from
the start. This keeps _inject_hook_command_note idempotent for Kimi: the
previous note with its native prefix now matches on repeated passes, preventing
duplicate note injection.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

* fix(presets): use get_invocation_prefix in _resolve_skill_command_refs

Replace the binary is_dollar_skills_agent ternary with get_invocation_prefix
so Kimi tokens resolve to /skill:speckit-* directly rather than /speckit-*
(which previously relied on the broad post-process body replacement).

Also fix test_restore_skill_preserves_dollar_command_refs to write raw_core
with the unresolved __SPECKIT_COMMAND_PLAN__ token, exercising the resolver
rather than bypassing it with a pre-resolved string.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

* docs(presets): document /skill: form in _resolve_skill_command_refs

Add /skill:speckit-<cmd> to the docstring so the contract covers all
three native prefix forms returned by get_invocation_prefix.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

* test(integrations): add Kimi /skill: prefix coverage

- test_skill_colon_prefix_core_command: resolve_command_refs with /skill: prefix
- test_get_invocation_prefix_skill_colon: get_invocation_prefix returns /skill:
  for kimi (skills), / for kimi (non-skills), $ for codex, / for claude
- test_kimi_skill_post_processing_is_idempotent: verifies Kimi's hook-command
  note is injected with /skill: prefix and does not duplicate on re-runs
- test_installed_bash_formatter_uses_skill_colon_prefix: shared-infra bash
  formatter outputs /skill:speckit-plan when installed with /skill: prefix

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

* fix(kimi): use get_invocation_prefix in process_template, remove broad replacement

process_template() was still using a binary is_dollar_skills_agent ternary
to select between dollar and slash prefix, so Kimi tokens were emitted as
/speckit-* and then corrected by a broad .replace('/speckit-', '/skill:speckit-')
in KimiIntegration.post_process_skill_content(). That broad replacement would
also rewrite any literal /speckit-* text in generated skill content, contrary
to the PR's token-only behavior.

- Use get_invocation_prefix(agent_name, invoke_separator == '-') in
  process_template() so Kimi tokens are emitted as /skill:speckit-* directly.
- Remove the broad .replace() from KimiIntegration.post_process_skill_content();
  it is now a no-op (tokens are already correctly prefixed at source).
- Add test_process_template_kimi_uses_skill_colon_prefix to guard the fix.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19
Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28
2026-07-28 15:48:40 +01:00
github-actions[bot]
809b4c5e26 Update Intake Review Governance preset to v0.2.0 (#3796)
Update intake-review-governance preset submitted by @hindermath:
- presets/catalog.community.json (version, download_url, documentation, description, scripts, tags)
- docs/community/presets.md community presets table

Closes #3794

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-28 09:37:09 -05:00
github-actions[bot]
1354eade99 fix(constitution): stop propagating guidance into templates (#3737) (#3790)
Issue #3737 asked the /constitution command to synchronize constitutional
guidance into every effective task/plan/spec template, including active
preset-provided replacements. This changes the fix's direction: rather than
teach the command to discover and edit more template layers, it removes the
template-propagation behavior entirely.

Why this is the correct fix:

- The governed templates do not embed constitutional content. plan-template
  carries a runtime placeholder ("[Gates determined based on constitution
  file]") and spec-template/tasks-template reference no principles at all.
- The consuming commands read .specify/memory/constitution.md at runtime and
  derive their Constitution Check gates live (plan, tasks), and analyze is the
  dedicated drift checker that validates spec/plan/tasks against the
  constitution. Enforcement is therefore already automatic and always current.
- Statically editing template files fights the preset/override composition
  system: a replace preset shadows an edited core template entirely, and a
  hand-edited versioned preset file is clobbered on its next update. Presets and
  extensions are formalized, versioned artifacts the command must not mutate.

So the original bug (constitution edits missing active preset templates) is
resolved by not propagating at all: the runtime read is the single source of
truth. The /constitution command is scoped to its own artifact — it drafts and
writes the constitution and reports a Sync Impact Report changelog, and no
longer reads, edits, or reports on plan/spec/tasks/preset/extension templates.

Refs #3737

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

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5b80589c-74e8-42e5-b2cb-7a7e0d69a964
2026-07-28 09:17:32 -05:00
Manfred Riem
2a29b534ae chore: release 0.14.3, begin 0.14.4.dev0 development (#3795)
* chore: bump version to 0.14.3

* chore: begin 0.14.4.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-28 09:15:50 -05:00
112 changed files with 14467 additions and 677 deletions

View File

@@ -29,12 +29,20 @@ def _dependency_diff_refs() -> tuple[str, str]:
def _dependency_inputs_changed() -> bool:
base_ref, head_ref = _dependency_diff_refs()
try:
merge_base = subprocess.run(
["git", "merge-base", base_ref, head_ref],
check=True,
cwd=REPO_ROOT,
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
text=True,
).stdout.strip()
result = subprocess.run(
[
"git",
"diff",
"--name-only",
base_ref,
merge_base,
head_ref,
"--",
*DEPENDENCY_INPUTS,
@@ -77,6 +85,7 @@ def main() -> int:
generated_requirements = Path(generated_requirements_env)
generated_requirements.parent.mkdir(parents=True, exist_ok=True)
generated_requirements.write_bytes(COMMITTED_REQUIREMENTS.read_bytes())
subprocess.run(
[
@@ -87,7 +96,6 @@ def main() -> int:
"--extra",
"test",
"--universal",
"--upgrade",
"--generate-hashes",
"--quiet",
"--no-header",

View File

@@ -1,6 +1,6 @@
annotated-doc==0.0.4 \
--hash=sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320 \
--hash=sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4
annotated-doc==0.0.5 \
--hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 \
--hash=sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb
# via typer
click==8.4.2 \
--hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \

View File

@@ -35,7 +35,7 @@ jobs:
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"

View File

@@ -27,14 +27,14 @@ jobs:
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
- name: Check committed audit requirements are current
env:
DEPENDENCY_DIFF_BASE: ${{ github.event.pull_request.base.sha || github.event.before || '' }}
DEPENDENCY_DIFF_HEAD: ${{ github.sha }}
DEPENDENCY_DIFF_HEAD: ${{ github.event.pull_request.head.sha || github.sha }}
GENERATED_REQUIREMENTS: ${{ runner.temp }}/security-audit-requirements.txt
run: python .github/scripts/check_security_requirements.py
@@ -58,7 +58,7 @@ jobs:
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ matrix.python-version }}

View File

@@ -14,7 +14,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
with:
# Days of inactivity before an issue or PR becomes stale
days-before-stale: 150

View File

@@ -19,7 +19,7 @@ jobs:
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
@@ -40,7 +40,7 @@ jobs:
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ matrix.python-version }}

View File

@@ -2,6 +2,93 @@
<!-- insert new changelog below this comment -->
## [0.15.1] - 2026-07-31
### Changed
- fix: escape Rich markup in `workflow resolve` output (#3879)
- chore(deps): bump actions/stale from 10.4.0 to 11.0.0 (#3877)
- chore(deps): bump actions/setup-python from 6.3.0 to 7.0.0 (#3876)
- feat: support tar archives for installs (#3874)
- fix: eliminate TOCTOU race in file unlink calls (#3819)
- fix(scripts): tolerate an unusable integration.json in the Python helper (#3785)
- fix(catalogs): validate the port in the shared catalog-URL validator, like its mirrors do (#3804)
- feat(presets): add opt-in constitution-sync preset (#3873)
- fix: reject non-object workflow caches (#3860)
- Harden extension URL download cache against symlink and junction races (#3869)
- fix: escape workflow step metadata (#3863)
- [bug-fix] Fix bundle-update-force-mislead: add refresh() to DefaultPrimitiveInstaller (#3452)
- fix: use chunked read for extension manifest hash (#3841)
- fix: preserve unreadable event config files (#3861)
- fix(scripts): use a .NET Framework-safe trim in the PowerShell init-dir resolver (#3872)
- Add ContextForge MCP extension to community catalog (#3487)
- fix: normalize non-UTF-8 integration manifests (#3862)
- feat: bind gate verdict to workflow input via verdict_input (#3725)
- docs: use absolute image URLs in README for PyPI rendering (#3867)
- chore: release 0.15.0, begin 0.15.1.dev0 development (#3871)
## [0.15.0] - 2026-07-30
### Changed
- Add yolo to community workflow catalog (#3864)
- fix(workflows): guard the shell step's timeout check against OverflowError (#3865)
- Add Intent Reconciliation extension to community catalog (#3858)
- fix(workflows): validate prompt step 'timeout' like the shell step (#3847)
- fix: add utf-8 encoding to registry file open calls (#3816)
- fix: eliminate TOCTOU race in file unlink calls (#3815)
- test(workflows): name the condition-rejection tests for the real boundary (#3808)
- fix: eliminate TOCTOU race in file unlink calls (#3811)
- fix(presets): escape user-supplied catalog name/URL in add/remove output (#3806)
- fix: add missing utf-8 encoding to registry file open calls (#3810)
- [bug-fix] Fix upgrade-overwrites-copilot-skills: pass force=True to extension skill re-registration after upgrade (#3853)
- fix(integrations): don't abort uninstall when the manifest can't be deleted (#3805)
- test(extensions): update stale manifest validation message assertion (#3859)
- fix(agents): coerce a non-string description in TOML command rendering (#3799)
- fix(workflows): make security requirements sync deterministic (#3832)
- fix(cli): render the literal [suffix] in --tag help and rejection message (#3800)
- fix(integrations): preserve non-UTF-8 VS Code settings (#3833)
- fix(bundler): treat an explicit-null manifest field as missing, not the text "None" (#3798)
- feat: first-class agent-native runtime hooks for integrations (#3704)
- fix(extensions): guard the required manifest sections so one bad extension cannot break `extension list` (#3797)
- fix(presets): escape installed preset metadata in Rich output (#3826)
- fix(workflows): dispatch prompt steps via the resolved executable (#3793)
- chore: release 0.14.4, begin 0.14.5.dev0 development (#3850)
## [0.14.4] - 2026-07-29
### Changed
- fix(bundler): degrade non-UTF-8 config reads into BundlerError (#3784)
- fix(workflows): escape the step-progress line so step ids render (and `/` stops failing the run) (#3783)
- Update Agent Parity Governance preset to v0.4.1 (#3830)
- fix(integrations): reject empty --commands-dir in generic raw_options (#3714)
- fix(presets): guard non-list/non-mapping provides.templates in PresetManifest (#3712)
- fix(auth): resolve az via shutil.which so azure-cli token works on Windows (#3709)
- fix(workflows): reject falsy non-mapping workflow-catalogs.yml top level (#3707)
- fix(integrations): render hyphenated /speckit-<name> for Droid (always-slash agent) (#3688)
- [preset] Update A11Y Governance preset to v0.4.2 (#3828)
- [preset] Update Parallel Autonomous Run Governance to v0.2.4 (#3825)
- fix: correct Optional type annotation for _resolved_dir parameter (#3801)
- fix: add timeout to prompt step subprocess execution (#3768)
- fix: handle tags containing / in GitHub release asset URL resolution (#3767)
- fix(presets): escape catalog metadata in discovery output (#3773)
- Update Autonomous Run Governance preset to v0.3.3 (#3823)
- fix: use bounded read for integration catalog HTTP responses (#3763)
- docs: add Simplified Chinese translation of README (#3740)
- Update Intake Sequencing Governance preset to v0.2.2 (#3809)
- fix(workflows): reject non-string/non-boolean 'condition' in if/while/do-while steps (#3706)
- fix(bundle): escape catalog metadata in discovery output (#3774)
- fix(workflows,extensions): tolerate non-list catalog tags in search/info display (#3770)
- fix: correct nullable resolved directory annotation (#3771)
- fix(presets): tolerate non-string and non-list catalog fields in preset search/info (#3769)
- fix(integrations): escape catalog metadata in discovery output (#3772)
- Update Verify Review Ship extension to v0.4.2 (#3792)
- fix(integrations): preserve native skill invocation prefixes (#3663)
- Update Intake Review Governance preset to v0.2.0 (#3796)
- fix(constitution): stop propagating guidance into templates (#3737) (#3790)
- chore: release 0.14.3, begin 0.14.4.dev0 development (#3795)
## [0.14.3] - 2026-07-28
### Changed

View File

@@ -1,5 +1,5 @@
<div align="center">
<img src="./media/logo_large.webp" alt="Spec Kit Logo" width="200" height="200"/>
<img src="https://raw.githubusercontent.com/github/spec-kit/main/media/logo_large.webp" alt="Spec Kit Logo" width="200" height="200"/>
<h1>🌱 Spec Kit</h1>
<h3><em>Define what to build before building it — with any AI coding agent.</em></h3>
</div>
@@ -15,6 +15,11 @@
<a href="https://github.github.io/spec-kit/"><img src="https://img.shields.io/badge/docs-GitHub_Pages-blue" alt="Documentation"/></a>
</p>
<p align="center">
<strong>English</strong> ·
<a href="./README.zh-CN.md">简体中文</a>
</p>
---
## Table of Contents
@@ -131,7 +136,7 @@ For detailed step-by-step instructions, see our [comprehensive guide](./spec-dri
Want to see Spec Kit in action? Watch our [video overview](https://www.youtube.com/watch?v=a9eR1xsfvHg&pp=0gcJCckJAYcqIYzv)!
[![Spec Kit video header](/media/spec-kit-video-header.jpg)](https://www.youtube.com/watch?v=a9eR1xsfvHg&pp=0gcJCckJAYcqIYzv)
[![Spec Kit video header](https://raw.githubusercontent.com/github/spec-kit/main/media/spec-kit-video-header.jpg)](https://www.youtube.com/watch?v=a9eR1xsfvHg&pp=0gcJCckJAYcqIYzv)
## 🌍 Community

361
README.zh-CN.md Normal file
View File

@@ -0,0 +1,361 @@
<div align="center">
<img src="./media/logo_large.webp" alt="Spec Kit Logo" width="200" height="200"/>
<h1>🌱 Spec Kit</h1>
<h3><em>在动手编码之前,先定义要构建什么 —— 适配任意 AI 编码助手。</em></h3>
</div>
<p align="center">
<strong>一个开源工具套件,帮助你借助任意 AI 编码助手构建高质量软件 —— 内置开箱即用的规范驱动流程(也可自带流程),可无限扩展、由社区驱动,并为整个组织的协作而设计。</strong>
</p>
<p align="center">
<a href="https://github.com/github/spec-kit/releases/latest"><img src="https://img.shields.io/github/v/release/github/spec-kit" alt="Latest Release"/></a>
<a href="https://github.com/github/spec-kit/stargazers"><img src="https://img.shields.io/github/stars/github/spec-kit?style=social" alt="GitHub stars"/></a>
<a href="https://github.com/github/spec-kit/blob/main/LICENSE"><img src="https://img.shields.io/github/license/github/spec-kit" alt="License"/></a>
<a href="https://github.github.io/spec-kit/"><img src="https://img.shields.io/badge/docs-GitHub_Pages-blue" alt="Documentation"/></a>
</p>
<p align="center">
<a href="./README.md">English</a> ·
<strong>简体中文</strong>
</p>
---
## 目录
- [🤔 什么是规范驱动开发?](#-什么是规范驱动开发)
- [⚡ 快速开始](#-快速开始)
- [📽️ 视频概览](#-视频概览)
- [🌍 社区](#-社区)
- [🤖 支持的 AI 编码助手集成](#-支持的-ai-编码助手集成)
- [🔧 Specify CLI 参考](#-specify-cli-参考)
- [🧩 打造你自己的 Spec Kit扩展与预设](#-打造你自己的-spec-kit扩展与预设)
- [📦 捆绑包:面向角色的一键配置](#-捆绑包面向角色的一键配置)
- [📚 核心理念](#-核心理念)
- [🌟 开发阶段](#-开发阶段)
- [🎯 实验目标](#-实验目标)
- [🔧 环境要求](#-环境要求)
- [📖 深入了解](#-深入了解)
- [💬 支持](#-支持)
- [🙏 致谢](#-致谢)
- [📄 许可证](#-许可证)
## 🤔 什么是规范驱动开发?
规范驱动开发Spec-Driven Development**颠覆了**传统软件开发的思路。几十年来,代码一直是核心 —— 规范只是编码这项"正事"开始前搭起、随后就被丢弃的脚手架。规范驱动开发改变了这一点:**规范本身变得可执行**,它不再只是引导实现,而是直接生成可运行的实现。
## ⚡ 快速开始
### 1. 安装 Specify CLI
需要 **[uv](https://docs.astral.sh/uv/)**[安装 uv](./docs/install/uv.md))。将 `vX.Y.Z` 替换为 [Releases](https://github.com/github/spec-kit/releases) 中最新的发布标签 —— 记得保留开头的 `v`(例如 `v0.12.11`,而不是 `0.12.11`
```bash
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z
```
更倾向从 PyPI 安装?`specify-cli` 包同样发布在那里:
```bash
uv tool install specify-cli
```
其他安装方式、安装校验、升级以及故障排查,请参阅[安装指南](./docs/installation.md)。
### 2. 初始化项目
```bash
specify init my-project --integration copilot
cd my-project
```
要检查更新或升级已安装的 CLI可使用自管理命令。更详细的场景和自定义选项请参阅[升级指南](./docs/upgrade.md)。
```bash
# 检查是否有更新版本可用(只读操作 —— 不会修改任何内容)
specify self check
# 预览升级将执行的操作,但不实际升级
specify self upgrade --dry-run
# 就地升级到最新稳定版(自动识别 uv tool 与 pipx 安装方式)
specify self upgrade
# 或锁定到指定的发布标签(将 vX.Y.Z[suffix] 替换为你想要的标签)
specify self upgrade --tag vX.Y.Z[suffix]
```
直接运行 `specify self upgrade` 会立即执行,与 `pip install -U``npm update` 等命令一样无需额外确认。对于 `uv tool` 安装的情况,它在底层会执行 `uv tool install specify-cli --force --from <git ref>`,因此锁定的发布标签同样有效,包括 dev、alpha/beta/rc 或带构建元数据的后缀。`uvx`(临时运行)和源码检出会被自动识别,此时会给出针对具体路径的操作建议,而不会执行安装程序。可通过设置 `SPECIFY_UPGRADE_TIMEOUT_SECS` 来限制安装子进程的最长运行时间(默认无超时限制 —— 必要时用 `Ctrl+C` 中断)。
### 3. 确立项目准则
在项目目录下启动你的编码助手。大多数助手将 spec-kit 暴露为 `/speckit.*` 斜杠命令处于技能skills模式的 Codex CLI 则使用 `$speckit-*`GitHub Copilot CLI 使用 `/agents` 来选择助手,或直接在提示词中指定它。
使用 **`/speckit.constitution`** 命令来创建项目的治理准则和开发指南,它们将指导后续所有开发工作。
```bash
/speckit.constitution Create principles focused on code quality, testing standards, user experience consistency, and performance requirements
```
### 4. 编写规范
使用 **`/speckit.specify`** 命令描述你想构建什么。聚焦于**做什么**和**为什么做**,而不是技术栈。
```bash
/speckit.specify Build an application that can help me organize my photos in separate photo albums. Albums are grouped by date and can be re-organized by dragging and dropping on the main page. Albums are never in other nested albums. Within each album, photos are previewed in a tile-like interface.
```
### 5. 制定技术实现方案
使用 **`/speckit.plan`** 命令提供你的技术栈和架构选择。
```bash
/speckit.plan The application uses Vite with minimal number of libraries. Use vanilla HTML, CSS, and JavaScript as much as possible. Images are not uploaded anywhere and metadata is stored in a local SQLite database.
```
### 6. 拆解为任务
使用 **`/speckit.tasks`** 从实现方案生成一份可执行的任务清单。
```bash
/speckit.tasks
```
### 7. 执行实现
使用 **`/speckit.implement`** 执行所有任务,按方案构建你的功能。
```bash
/speckit.implement
```
详细的分步说明,请参阅我们的[完整指南](./spec-driven.md)。
## 📽️ 视频概览
想看看 Spec Kit 的实际效果?观看我们的[视频概览](https://www.youtube.com/watch?v=a9eR1xsfvHg&pp=0gcJCckJAYcqIYzv)
[![Spec Kit video header](/media/spec-kit-video-header.jpg)](https://www.youtube.com/watch?v=a9eR1xsfvHg&pp=0gcJCckJAYcqIYzv)
## 🌍 社区
在 [Spec Kit 文档站点](https://github.github.io/spec-kit/)上探索由社区贡献的资源:
- [扩展Extensions](https://github.github.io/spec-kit/community/extensions.html) —— 命令、钩子与各类能力
- [预设Presets](https://github.github.io/spec-kit/community/presets.html) —— 模板与术语覆盖
- [捆绑包Bundles](https://github.github.io/spec-kit/community/bundles.html) —— 由现有组件组合而成的角色与团队技术栈
- [实战演练Walkthroughs](https://github.github.io/spec-kit/community/walkthroughs.html) —— 端到端的 SDD 场景
- [伙伴项目Friends](https://github.github.io/spec-kit/community/friends.html) —— 扩展 Spec Kit 或基于它构建的项目
> [!NOTE]
> 社区贡献由各自的作者独立创建和维护。请在安装前审阅源代码,并自行斟酌使用。
想要参与贡献?请参阅[扩展发布指南](extensions/EXTENSION-PUBLISHING-GUIDE.md)、[预设发布指南](presets/PUBLISHING.md)或[社区捆绑包指南](docs/community/bundles.md)。
## 🤖 支持的 AI 编码助手集成
Spec Kit 可与 30 多个 AI 编码助手协作 —— 既包括 CLI 工具,也包括基于 IDE 的助手。完整列表以及相关说明和使用细节,请参阅[支持的 AI 编码助手集成](https://github.github.io/spec-kit/reference/integrations.html)指南。
运行 `specify integration list` 可查看当前安装版本中所有可用的集成。
## 可用的斜杠命令
运行 `specify init` 后,你的 AI 编码助手就能使用这些斜杠命令来进行结构化开发。对于支持技能模式的集成,传入 `--integration <agent> --integration-options="--skills"` 会安装助手技能,而不是斜杠命令的提示词文件。
### 核心命令
规范驱动开发工作流中必不可少的命令:
| 命令 | 助手技能 | 说明 |
| ------------------------ | ---------------------- | ---------------------------------------------------------- |
| `/speckit.constitution` | `speckit-constitution` | 创建或更新项目的治理准则和开发指南 |
| `/speckit.specify` | `speckit-specify` | 定义你想构建什么(需求与用户故事) |
| `/speckit.plan` | `speckit-plan` | 结合所选技术栈制定技术实现方案 |
| `/speckit.tasks` | `speckit-tasks` | 生成可执行的实现任务清单 |
| `/speckit.taskstoissues` | `speckit-taskstoissues`| 将生成的任务清单转换为 GitHub issue便于跟踪与执行 |
| `/speckit.implement` | `speckit-implement` | 执行所有任务,按方案构建功能 |
| `/speckit.converge` | `speckit-converge` | 对照规范/方案/任务评估代码库,并将剩余工作追加为新任务 |
### 可选命令
用于提升质量与做校验的额外命令:
| 命令 | 助手技能 | 说明 |
| -------------------- | ---------------------- | ------------------------------------------------------------------------------------------------- |
| `/speckit.clarify` | `speckit-clarify` | 澄清描述不充分的部分(建议在 `/speckit.plan` 之前使用;旧称 `/quizme` |
| `/speckit.analyze` | `speckit-analyze` | 跨制品的一致性与覆盖度分析(在 `/speckit.tasks` 之后、`/speckit.implement` 之前运行) |
| `/speckit.checklist` | `speckit-checklist` | 生成自定义质量清单,校验需求的完整性、清晰度与一致性(好比"为自然语言写单元测试" |
## 🔧 Specify CLI 参考
完整的命令详情、选项与示例,请参阅 [CLI 参考文档](https://github.github.io/spec-kit/reference/overview.html)。
## 🧩 打造你自己的 Spec Kit扩展与预设
Spec Kit 可通过两套互补的机制进行深度定制 —— **扩展extensions****预设presets** —— 以及面向单个项目的本地覆盖,用于临时性调整:
| 优先级 | 组件类型 | 位置 |
| -----: | ---------------------------------- | -------------------------------- |
| ⬆ 1 | 项目本地覆盖 | `.specify/templates/overrides/` |
| 2 | 预设 —— 定制核心与扩展 | `.specify/presets/templates/` |
| 3 | 扩展 —— 新增能力 | `.specify/extensions/templates/` |
| ⬇ 4 | Spec Kit 核心 —— 内置 SDD 命令与模板 | `.specify/templates/` |
- **模板**在**运行时**解析 —— Spec Kit 从高到低遍历优先级栈,使用第一个匹配项。
- 项目本地覆盖(`.specify/templates/overrides/`)允许对单个项目做一次性调整,无需创建完整的预设。
- **扩展/预设命令**在**安装时**生效 —— 当你运行 `specify extension add``specify preset add` 时,命令文件会被写入助手目录(如 `.claude/commands/`)。
- 若多个预设或扩展提供了同一命令,优先级最高的版本生效。移除时,次优先级的版本会自动恢复。
- 若不存在任何覆盖或自定义Spec Kit 使用核心默认配置。
### 扩展 —— 新增能力
当你需要 Spec Kit 核心之外的功能时,使用**扩展**。扩展可引入新命令和模板 —— 例如添加核心 SDD 命令未覆盖的领域特定工作流、集成外部工具,或新增全新的开发阶段。它们扩展了 *Spec Kit 能做什么*
```bash
# 搜索可用扩展
specify extension search
# 安装扩展
specify extension add <extension-name>
```
举例来说,扩展可以添加 Jira 集成、实现后代码审查、V 模型测试追溯性,或项目健康诊断等功能。
完整命令指南请参阅[扩展参考文档](https://github.github.io/spec-kit/reference/extensions.html)。浏览[社区扩展](https://github.github.io/spec-kit/community/extensions.html)了解现有资源。
### 预设 —— 定制现有工作流
当你想改变 Spec Kit 的*工作方式*而不是新增能力时,使用**预设**。预设会覆盖核心及已安装扩展中附带的模板和命令 —— 例如强制使用面向合规的规范格式、采用领域特定术语,或对方案和任务应用组织规范。预设定制的是 Spec Kit 及其扩展生成的制品与指令。
```bash
# 搜索可用预设
specify preset search
# 安装预设
specify preset add <preset-name>
```
举例来说,预设可以重构规范模板以要求监管追溯性,将工作流适配为你所用的方法论(如敏捷、看板、瀑布、用户任务驱动或领域驱动设计),在方案中添加强制安全审查关卡,强制要求测试优先的任务排序,或将整个工作流本地化为其他语言。[海盗语演示](https://github.com/mnriem/spec-kit-pirate-speak-preset-demo)充分展示了定制的深度。多个预设可按优先级叠加使用。
完整命令指南以及解析顺序和优先级叠加说明,请参阅[预设参考文档](https://github.github.io/spec-kit/reference/presets.html)。
## 📦 捆绑包:面向角色的一键配置
扩展和预设是独立的构建模块。而**捆绑包bundle**将一组精选的扩展、预设、步骤和工作流打包成一个带版本、面向角色的配置,从而可以用一条命令为整个团队角色(产品经理、业务分析师、安全研究员、开发者……)完成配置。
捆绑包由一份手写的 `bundle.yml` 清单描述。它将每个组件锁定到具体版本,并可选择性地面向特定集成;未指定 `integration` 的捆绑包是**中立的**,会沿用项目当前已使用的集成。
```bash
# 在当前激活的目录栈中发现捆绑包
specify bundle search [<query>]
# 查看捆绑包将添加的确切组件集合(与实际安装的内容一致)
specify bundle info <bundle-id>
# 一步安装捆绑包的完整组件集合
specify bundle install <bundle-id>
# 查看已安装内容,然后以非破坏性方式更新或移除
specify bundle list
specify bundle update <bundle-id> # 或 --all
specify bundle remove <bundle-id> # 仅移除此捆绑包的组件
```
捆绑包从一个**按优先级排序的目录栈**(项目 > 用户 > 内置)中解析。每个来源都带有安装策略:`install-allowed` 来源可用于安装,而 `discovery-only` 来源在 `search`/`info` 中可见但拒绝安装。可通过 `specify bundle catalog list|add|remove` 管理目录栈。
作者在本地校验并打包捆绑包。分发方式是托管构建产物并添加一个目录来源;社区捆绑包投稿请使用 [Bundle Submission](https://github.com/github/spec-kit/issues/new?template=bundle_submission.yml) issue 模板,以便对所需的组件目录和安装证据进行审阅:
```bash
specify bundle validate --path ./my-bundle # 结构与引用检查
specify bundle build --path ./my-bundle # 生成带版本的 .zip 产物
```
[`examples/bundles/`](examples/bundles/) 目录下有四份可直接阅读的示例清单(产品经理、业务分析师、安全研究员、开发者)。
关键保证:`info` 展示的内容与 `install` 添加的内容完全一致(透明性);安装是幂等的,且限定在项目根目录内;`remove` 绝不会触碰其他已安装捆绑包仍需要的组件;所有消费/创作命令都能针对本地或锁定的来源**离线**工作。
### 何时用哪个
| 目标 | 使用 |
| --- | --- |
| 添加全新的命令或工作流 | 扩展 |
| 定制规范、方案或任务的格式 | 预设 |
| 集成外部工具或服务 | 扩展 |
| 强制执行组织或监管规范 | 预设 |
| 交付可复用的领域特定模板 | 均可 —— 预设用于模板覆盖,扩展用于随新命令一起打包的模板 |
| 用一条命令完成完整的角色配置 | 捆绑包 |
## 📚 核心理念
规范驱动开发是一套结构化流程,它强调:
- **意图驱动开发** —— 让规范先定义"*做什么*",再谈"*怎么做*"
- **丰富的规范撰写** —— 借助护栏与组织准则来编写规范
- **多步精炼** —— 而非从提示词一次性生成代码
- **充分依赖**先进 AI 模型对规范的解读能力
## 🌟 开发阶段
| 阶段 | 侧重点 | 关键活动 |
| ----------------------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| **从 0 到 1 开发**"绿地/Greenfield" | 从零生成 | <ul><li>从高层需求出发</li><li>生成规范</li><li>规划实现步骤</li><li>构建生产就绪的应用</li></ul> |
| **创意探索** | 并行实现 | <ul><li>探索多样化的解决方案</li><li>支持多种技术栈与架构</li><li>试验不同的用户体验模式</li></ul> |
| **迭代增强**"棕地/Brownfield" | 存量系统现代化 | <ul><li>迭代式添加功能</li><li>现代化改造遗留系统</li><li>调整流程</li></ul> |
对于已有项目,请将 Spec Kit 工具本身的更新与功能制品的演进分开处理:升级时刷新受管理的项目文件,而在预期行为发生变化时更新 `specs/` 制品。[规范演进指南](./docs/guides/evolving-specs.md)介绍了推荐的棕地迭代循环。
## 🎯 实验目标
我们的研究与实验聚焦于:
### 技术无关性
- 使用多样化的技术栈构建应用
- 验证这一假设:规范驱动开发是一套流程,不与特定技术、编程语言或框架绑定
### 企业级约束
- 展示关键业务应用的开发
- 纳入组织层面的约束(云服务商、技术栈、工程实践)
- 支持企业设计系统与合规要求
### 以用户为中心的开发
- 为不同的用户群体和偏好构建应用
- 支持多种开发方式(从"氛围编码"到 AI 原生开发)
### 创意与迭代流程
- 验证并行实现探索的理念
- 提供稳健的迭代式功能开发工作流
- 将流程扩展到升级与现代化改造任务
## 🔧 环境要求
- **Linux/macOS/Windows**
- [受支持的](#-支持的-ai-编码助手集成) AI 编码助手。
- [uv](https://docs.astral.sh/uv/) 用于包管理(推荐),或 [pipx](https://pipx.pypa.io/) 用于持久化安装
- [Python 3.11+](https://www.python.org/downloads/)
- [Git](https://git-scm.com/downloads)
如果你在使用某个助手时遇到问题,欢迎提交 issue以便我们完善相应集成。
## 📖 深入了解
- **[完整的规范驱动开发方法论](./spec-driven.md)** —— 深入了解整个流程
- **[快速上手指南](https://github.github.io/spec-kit/quickstart.html)** —— 分步实现演练
---
## 💬 支持
如需帮助,请提交 [GitHub issue](https://github.com/github/spec-kit/issues/new)。我们欢迎缺陷报告、功能建议,以及关于使用规范驱动开发的各类问题。
## 🙏 致谢
本项目深受 [John Lam](https://github.com/jflam) 的工作与研究的影响,并在其基础上构建。
## 📄 许可证
本项目基于 MIT 开源许可证的条款授权。完整条款请参阅 [LICENSE](./LICENSE) 文件。

View File

@@ -50,6 +50,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Coding Standards Drift Control | Generate coding-standards drift reports and remediation tasks for active Spec Kit features | `code` | Read+Write | [spec-kit-coding-standards-drift-control](https://github.com/benizzio/spec-kit-coding-standards-drift-control) |
| Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) |
| Confluence Extension | Create a doc in Confluence summarizing the specifications and planning files | `integration` | Read+Write | [spec-kit-confluence](https://github.com/aaronrsun/spec-kit-confluence) |
| ContextForge MCP | Integrates codebase-memory-mcp + headroom into Spec Kit — graph-based code intelligence and context compression for the implement phase | `code` | Read+Write | [contextforge-mcp](https://github.com/capatinore/contextforge-mcp) |
| Cost Tracker | Track real LLM dollar cost across SDD workflows — per-feature budgets, per-integration comparison, and finance-ready exports | `visibility` | Read+Write | [spec-kit-cost](https://github.com/Quratulain-bilal/spec-kit-cost) |
| Data Model Diagram | Generates Mermaid ER diagrams from Spec Kit data models after planning | `docs` | Read+Write | [spec-kit-data-model-diagram](https://github.com/benizzio/spec-kit-data-model-diagram) |
| DocGuard — CDD Enforcement | The only doc-integrity engine with an MCP server, SARIF/JUnit output, and a deterministic zero-LLM core. Validates, scores, and traces documentation against code — 27 validators, stable finding codes, adoption baseline for legacy repos, compliance-evidence reports, GitHub Action with PR annotations, spec-kit hooks. Pure Node.js, one pinned dep. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
@@ -66,6 +67,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Improve Extension | Audits any codebase as a senior advisor and writes prioritized, self-contained spec prompts under specs/ that the spec-kit lifecycle can process | `process` | Read+Write | [spec-kit-improve](https://github.com/d0whc3r/spec-kit-improve) |
| Intake | Normalize PRD, design, HTML SSOT, and test-case evidence into SDD-ready intake artifacts. | `docs` | Read+Write | [spec-kit-intake](https://github.com/bigsmartben/spec-kit-intake) |
| Intelligent Agent Orchestrator | Cross-catalog agent discovery and intelligent prompt-to-command routing | `process` | Read+Write | [spec-kit-orchestrator](https://github.com/pragya247/spec-kit-orchestrator) |
| Intent Reconciliation | Reconcile implementation-discovered decisions against approved feature intent | `process` | Read+Write | [spec-kit-reconcile](https://github.com/SuhaibAslam/spec-kit-reconcile) |
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
| Jira Integration (Sync Engine) | Idempotent, drift-aware, fail-closed reconcile engine mirroring spec-kit specs into Jira (Epic per repo, Story per spec, Subtask per phase) | `integration` | Read+Write | [spec-kit-jira-sync](https://github.com/ashbrener/spec-kit-jira-sync) |

View File

@@ -7,11 +7,11 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Preset | Purpose | Provides | Requires | URL |
|--------|---------|----------|----------|-----|
| A11Y Governance | Adds WCAG 2.2 AA governance, accessible text/JSON status parity, bilingual DE/EN delivery, CEFR-B2 readability, inclusive content, didactic-comment review, and audit-ready evidence | 10 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
| Agent Parity Governance | Adds shared-guidance and generated-command parity, fleet-completion evidence, secret-free runner/status metadata, audit-ready evidence, and agent-neutral model routing. | 6 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
| A11Y Governance | Adds accessibility (WCAG 2.2 AA), accessible text and JSON status parity, bilingual DE/EN delivery, CEFR-B2 readability, inclusive-content governance, didactic inline-code-comment review, and audit-ready Spec-Kit run evidence to Spec Kit | 10 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
| Agent Parity Governance | Adds shared-guidance and generated-command parity, repository-fleet completion evidence, secret-free runner/status metadata, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across declared AI-agent surfaces. | 6 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
| Architecture Governance | Adds secure software architecture, resumable remote-transaction boundaries, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence | 13 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
| Autonomous Run Governance | Adds permission-bounded, evidence-first governance for complete autonomous Spec Kit delivery, including validated status, stop, explicit resume, exact-head proof, post-merge closeout, retrospective learning, and an optional policy-driven intake-review gate before feature creation. | 13 templates, 5 commands, 4 scripts | — | [spec-kit-preset-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-autonomous-run-governance) |
| Autonomous Run Governance | Adds permission-bounded autonomous delivery, an optional intake-review gate, and preservation of the project's learner and accessibility contract. | 13 templates, 5 commands, 4 scripts | — | [spec-kit-preset-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-autonomous-run-governance) |
| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) |
| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) |
| Command Density | Compacts the nine core Spec Kit command prompts while preserving scripts, handoffs, placeholders, hook output blocks, and rule structure | 9 commands | — | [spec-kit-preset-command-density](https://github.com/Xopoko/spec-kit-preset-command-density) |
@@ -20,13 +20,13 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose principles. Supports interactive elements like brainstorming, interview, roleplay, and extras like statistics, cover builder, illustration builder, and bio command. Export with templates for KDP, D2D, etc. | 26 templates, 34 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
| Game Narrative Writing | Preset for game narrative design and interactive storytelling. It adapts the Spec-Driven Development workflow for game narratives: features become story mechanics, specs become narrative briefs, plans become story maps, and tasks become dialogue and scene-writing tasks. Supports branching narratives, player agency systems, state machines, and interactive dialogue trees. | 37 templates, 34 commands, 5 scripts | — | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-writing) |
| Intake Authoring Governance | Governs traceable intake CRUD and language-aware requirements collections with atomic migrations, rollback evidence, and safe series authoring. | 12 templates, 5 commands, 7 scripts | — | [spec-kit-preset-intake-authoring-governance](https://github.com/hindermath/spec-kit-preset-intake-authoring-governance) |
| Intake Review Governance | Reviews single, series, and campaign intake files before Spec Kit execution and binds accepted outcomes to normalized content hashes. | 8 templates, 3 commands, 2 scripts | — | [spec-kit-preset-intake-review-governance](https://github.com/hindermath/spec-kit-preset-intake-review-governance) |
| Intake Sequencing Governance | Manages traceable intake-series order, typed dependencies, lifecycle, and safe next-candidate selection without executing downstream workflows. | 10 templates, 6 commands, 5 scripts | — | [spec-kit-preset-intake-sequencing-governance](https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance) |
| Intake Review Governance | Reviews single, series, campaign, and language-aware requirements collections before Spec Kit execution. | 8 templates, 3 commands, 4 scripts | — | [spec-kit-preset-intake-review-governance](https://github.com/hindermath/spec-kit-preset-intake-review-governance) |
| Intake Sequencing Governance | Manages language-aware intake-series order, typed dependencies, lifecycle, and authority-neutral next-candidate selection. | 11 templates, 6 commands, 8 scripts | — | [spec-kit-preset-intake-sequencing-governance](https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance) |
| iSAQB Architecture Governance | Adds iSAQB/CPSA-F and arc42 architecture governance with audit-ready evidence for goals, views, resumability, partial-failure scenarios, ADRs, risks, and technical debt. | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
| Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) |
| Model Driven Engineering | Focuses on streamlined commands, app repository support, cross-spec support, and capability-aware project memory for model-driven engineering workflows | 6 templates, 11 commands | MDE extension | [spec-kit-preset-mde](https://github.com/AI-MDE/spec-kit-preset-mde) |
| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) |
| Parallel Autonomous Run Governance | Coordinates isolated autonomous Spec Kit campaigns with bounded concurrency, mixed agents, resumable consolidation, governed post-merge closeout, schema 1.2, and an optional current intake-review gate before worker scheduling. | 9 templates, 5 commands, 2 scripts | autonomous-run-governance >=0.3.2; optional: intake-review-governance >=0.1.0 | [spec-kit-preset-parallel-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance) |
| Parallel Autonomous Run Governance | Coordinates permission-bounded autonomous campaigns while preserving the project's learner and accessibility contract across workers and consolidation. | 9 templates, 5 commands, 2 scripts | autonomous-run-governance >=0.2.2; optional: intake-review-governance >=0.1.0 | [spec-kit-preset-parallel-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance) |
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) |
| Security Governance | Adds memory-safe-language and secure-coding governance, exact-head and security-gate evidence, provider-failure classification, ASVS, supply-chain transparency, and EU regulatory screening. | 14 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |

View File

@@ -39,6 +39,21 @@ specify workflow run my-pipeline.yml --json
`workflow_id` is the `workflow.id` declared inside the YAML, not the file name. The object is printed exactly as shown — pretty-printed with two-space indentation, on plain stdout with no Rich markup — so it always parses. While the workflow runs under `--json`, any progress a step would print (for example a gate prompt, or output from a prompt step's CLI subprocess) is redirected to stderr, so stdout carries only the JSON object. Read the object from stdout; leave stderr attached to the terminal or capture it separately.
For `failed` and `aborted` runs, the payload includes an `error` field carrying the terminal step's error message:
```json
{
"run_id": "662bf791",
"workflow_id": "build-and-review",
"status": "failed",
"current_step_id": "boom",
"current_step_index": 0,
"error": "Command exited with code 3"
}
```
`completed` and `paused` runs omit the `error` field. The error is persisted in the run's `state.json`, so `specify workflow status <run_id> --json` surfaces the same message after the fact.
> **Note:** Most workflow commands require a project already initialized with `specify init`. The exception is `specify workflow run <local-file.{yml,yaml}>`, which can run outside a project; in that case, run state is stored under the current directory's `.specify/workflows/runs/<run_id>/`.
## Resume a Workflow
@@ -88,10 +103,17 @@ specify workflow add <source>
| Option | Description |
| --------------- | ------------------------------------------------------ |
| `--dev` | Install from a local workflow YAML file or directory |
| `--dev` | Install from a local YAML file, package directory, or archive |
| `--from <url>` | Install from a custom URL (`<source>` names the expected workflow ID) |
Installs a workflow from the catalog, a URL (HTTPS required), a local YAML file, or a local directory containing `workflow.yml`.
Installs a workflow from the catalog, an HTTPS URL, a local YAML file, a
directory containing `workflow.yml`, or a `.zip`, `.tar.gz`, or `.tgz`
archive. Archives may contain `workflow.yml` at the root or inside one
top-level directory.
Directory and archive installs preserve the complete workflow package,
including scripts and other companion files. ZIP, `.tar.gz`, and `.tgz`
archives follow the same validation and installation behavior.
## Workflow Overlays
@@ -266,7 +288,9 @@ Lower priority values have higher precedence. Change this overlay to `priority:
### Interaction with Bundles and Updates
`specify workflow add <local-directory>` installs `workflow.yml` from the local directory into `.specify/workflows/<id>/`.
`specify workflow add <local-directory>` installs the complete local workflow
package into `.specify/workflows/<id>/`. Archive installs preserve the same
package contents.
When an installed workflow is refreshed or reinstalled, project overlays in `.specify/workflows/overlays/<id>/` are preserved because they live outside the installed workflow directory.
@@ -554,6 +578,64 @@ Each workflow run persists its state at `.specify/workflows/runs/<run_id>/`:
This enables `specify workflow resume` to continue from the exact step where a run was paused (e.g., at a gate) or failed.
### Gate Verdict Inputs
`verdict_input` binds a gate's verdict to a named workflow input. The input must be declared in the workflow's `inputs` block; `specify workflow validate` reports an undeclared reference.
`verdict_input` is not supported inside a `fan-out` template. Fan-out items
share workflow inputs, while workflow state can represent only one paused
gate. Place a gate before the fan-out to approve the whole batch, or after a
fan-in to review the aggregated results.
**Input value semantics:**
| Value | Behavior |
|---|---|
| Non-empty string, matches an option (case-insensitive) | Gate auto-decides; `output.choice` is set to the configured option spelling |
| Non-empty string, no match | Gate fails immediately |
| Non-string | Gate fails immediately |
| Missing or empty | Gate prompts on a TTY; pauses otherwise |
**Default value semantics:** A non-empty `default` is consumed as a verdict on the first run — matching an option auto-decides the gate, not matching fails it immediately.
```yaml
inputs:
spec_verdict:
type: string
default: ""
steps:
- id: review-spec
type: gate
message: "Approve the specification?"
options: [approve, reject]
on_reject: retry
verdict_input: spec_verdict
```
Supply a verdict when resuming:
```bash
specify workflow resume <run_id> --input spec_verdict=approve
```
For `on_reject: retry`, a bound reject verdict is consumed before the gate
pauses: the named stored input is reset to `""`. A later resume therefore
prompts or pauses again until another verdict is supplied. Approve, abort, and
skip outcomes leave the input unchanged.
Because of that reset, a verdict input used with `on_reject: retry` must accept
`""`. If it declares an `enum`, include the empty string — otherwise the reset
value violates the input's own `enum` and the run can no longer be resumed with
any input. `specify workflow add` reports this as a validation error.
```yaml
inputs:
spec_verdict:
type: string
enum: ["", approve, reject]
default: ""
```
## FAQ
### What happens when a workflow hits a gate step?

View File

@@ -208,6 +208,74 @@ Restart your IDE to refresh the command list.
---
## Behavior change: `/constitution` no longer propagates into templates
The `/constitution` command ([#3790](https://github.com/github/spec-kit/pull/3790)) is scoped to
its own artifact. It updates
`.specify/memory/constitution.md` and writes a Sync Impact Report, and **no longer edits**
`plan-template.md`, `spec-template.md`, `tasks-template.md`, installed command files, or
guidance docs.
### Why
Spec Kit uses **runtime resolution**: `plan`, `tasks`, and `analyze` read
`.specify/memory/constitution.md` live on every run, and `analyze` is the dedicated drift
checker. The governed templates carry a pointer, not a copy — `plan-template.md` ships
`[Gates determined based on constitution file]`, and `/plan` fills that section from the live
constitution each run. Propagation duplicated the single source of truth and fought the
preset/override composition system (a `replace` preset shadows an edited core template).
More broadly, presets and extensions — not in-place file edits — are how Spec Kit now governs
shared assets. Composing policy through the resolution stack keeps it centrally owned, versioned,
and auditable across repositories, instead of frozen into per-repo copies no core team can see.
### Is this a breaking change for existing projects?
**No — your workflow keeps working.** You would only notice a difference if you relied on
`/constitution` editing those files in place. The templates are scaffolds, not authorities. When you
run `/plan`, it copies the template into a per-feature `plan.md` and re-derives the Constitution
Check from the live constitution; `/analyze` validates against it. Even if a previous
`/constitution` run materialized concrete gate text into `.specify/templates/plan-template.md`,
the live constitution remains the source of truth at runtime.
On a **non-forced upgrade**, a materialized template is *preserved* (its hash diverges from the
recorded managed copy, so the refresh treats it as a customization and does not overwrite it).
Nothing regresses.
### Optional cleanup — return to the runtime pointer
A frozen, pre-filled Constitution Check is a slightly misleading scaffold and can bias the first
`/plan` pass. To move fully back to runtime resolution, reset the section body in
`.specify/templates/plan-template.md` to the pointer:
```text
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
[Gates determined based on constitution file]
```
Leave the rest of the file untouched. This is cleanup, not a required migration.
### Keeping the old behavior (opt-in)
If your team treats the materialized templates as **reviewed, committed artifacts** and wants
`/constitution` to keep propagating, install the bundled **`constitution-sync`** preset:
```bash
specify preset add constitution-sync
```
It wraps the core `/constitution` command and re-adds the propagation pass. It does **not** edit
versioned preset- or extension-provided templates or command files (those are owned by their
packages and are recomposed on reconciliation). Note that this edit-in-place propagation model
conflicts with the composition model used by the rest of the SDD commands when they are
preset/extension-managed — see the "Interaction with the resolution stack" section in
`presets/constitution-sync/README.md` for the tradeoffs and when to prefer the default instead.
---
## Common Scenarios
### Scenario 1: "I just want new slash commands"

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-27T00:00:00Z",
"updated_at": "2026-07-29T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -1070,6 +1070,41 @@
"created_at": "2026-03-29T00:00:00Z",
"updated_at": "2026-03-29T00:00:00Z"
},
"contextforge-mcp": {
"name": "ContextForge MCP",
"id": "contextforge-mcp",
"description": "Integrates codebase-memory-mcp + headroom into Spec Kit — graph-based code intelligence and context compression for the implement phase.",
"author": "capatinore",
"version": "0.1.0",
"download_url": "https://github.com/capatinore/contextforge-mcp/releases/download/ext-v0.1.0/contextforge-mcp-speckit-extension.zip",
"repository": "https://github.com/capatinore/contextforge-mcp",
"homepage": "https://github.com/capatinore/contextforge-mcp",
"documentation": "https://github.com/capatinore/contextforge-mcp/blob/main/README.md",
"changelog": "",
"license": "MIT",
"category": "code",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.10.0"
},
"provides": {
"commands": 4,
"hooks": 0
},
"tags": [
"mcp",
"code-intelligence",
"context-compression",
"tokens",
"claude",
"spec-driven-development"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-13T00:00:00Z",
"updated_at": "2026-07-13T00:00:00Z"
},
"cost": {
"name": "Cost Tracker",
"id": "cost",
@@ -1861,6 +1896,40 @@
"created_at": "2026-06-23T00:00:00Z",
"updated_at": "2026-06-30T00:00:00Z"
},
"intent": {
"name": "Intent Reconciliation",
"id": "intent",
"description": "Reconcile implementation-discovered decisions against approved feature intent",
"author": "SuhaibAslam",
"version": "1.0.2",
"download_url": "https://github.com/SuhaibAslam/spec-kit-reconcile/archive/refs/tags/v1.0.2.zip",
"repository": "https://github.com/SuhaibAslam/spec-kit-reconcile",
"homepage": "https://github.com/SuhaibAslam/spec-kit-reconcile",
"documentation": "https://github.com/SuhaibAslam/spec-kit-reconcile/blob/main/README.md",
"changelog": "https://github.com/SuhaibAslam/spec-kit-reconcile/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.12.0"
},
"provides": {
"commands": 3,
"hooks": 0
},
"tags": [
"intent",
"decisions",
"reconciliation",
"drift",
"workflow"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-29T00:00:00Z",
"updated_at": "2026-07-29T00:00:00Z"
},
"issue": {
"name": "GitHub Issues Integration 2",
"id": "issue",
@@ -4820,9 +4889,9 @@
"id": "verify-review-ship",
"description": "Post-convergence operational verification, technical review, learning governance, and transactional delivery.",
"author": "Carlos Eduardo Gevaerd Araujo",
"version": "0.4.1",
"download_url": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/archive/refs/tags/v0.4.1.zip",
"sha256": "cfa89b405fcf4857745653e923dfab92f101fbdda15e1e8757ad9f2ea55ae5e2",
"version": "0.4.2",
"download_url": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/archive/refs/tags/v0.4.2.zip",
"sha256": "71dceef5bf81d7ac54faa26bb5cf279554815a4928ee8d0c8e9bfb4c3e2bb0ab",
"repository": "https://github.com/cadugevaerd/spec-kit-verify-review-ship",
"homepage": "https://github.com/cadugevaerd/spec-kit-verify-review-ship",
"documentation": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/blob/main/README.md",
@@ -4851,7 +4920,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-07-10T00:00:00Z",
"updated_at": "2026-07-27T00:00:00Z"
"updated_at": "2026-07-28T00:00:00Z"
},
"verify-tasks": {
"name": "Verify Tasks Extension",

View File

@@ -7,13 +7,13 @@
"a11y-governance": {
"name": "A11Y Governance",
"id": "a11y-governance",
"version": "0.4.1",
"description": "Adds WCAG 2.2 AA governance, accessible text/JSON status parity, bilingual DE/EN delivery, CEFR-B2 readability, inclusive content, didactic-comment review, and audit-ready evidence.",
"version": "0.4.2",
"description": "Adds accessibility (WCAG 2.2 AA), accessible text and JSON status parity, bilingual DE/EN delivery, CEFR-B2 readability, inclusive-content governance, didactic inline-code-comment review, and audit-ready Spec-Kit run evidence to Spec Kit.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.4.1.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.4.2.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/v0.4.1/README.md",
"documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/v0.4.2/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.0"
@@ -34,18 +34,18 @@
"didactic-comments"
],
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-07-23T00:00:00Z"
"updated_at": "2026-07-28T00:00:00Z"
},
"agent-parity-governance": {
"name": "Agent Parity Governance",
"id": "agent-parity-governance",
"version": "0.4.0",
"description": "Adds shared-guidance and generated-command parity, fleet-completion evidence, secret-free runner/status metadata, audit-ready evidence, and agent-neutral model routing.",
"version": "0.4.1",
"description": "Adds shared-guidance and generated-command parity, repository-fleet completion evidence, secret-free runner/status metadata, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across declared AI-agent surfaces.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.4.0.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.4.1.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/blob/v0.4.0/README.md",
"documentation": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/blob/v0.4.1/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.0"
@@ -64,7 +64,7 @@
"multi-agent"
],
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-07-23T00:00:00Z"
"updated_at": "2026-07-28T00:00:00Z"
},
"aide-in-place": {
"name": "AIDE In-Place Migration",
@@ -135,13 +135,13 @@
"autonomous-run-governance": {
"name": "Autonomous Run Governance",
"id": "autonomous-run-governance",
"version": "0.3.2",
"description": "Adds permission-bounded, evidence-first governance for complete autonomous Spec Kit delivery, including validated status, stop, explicit resume, exact-head proof, post-merge closeout, retrospective learning, and an optional policy-driven intake-review gate before feature creation.",
"version": "0.3.3",
"description": "Adds permission-bounded autonomous delivery, an optional intake-review gate, and preservation of the project's learner and accessibility contract.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/archive/refs/tags/v0.3.2.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/archive/refs/tags/v0.3.3.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/blob/v0.3.2/README.md",
"documentation": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/blob/v0.3.3/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.3"
@@ -156,11 +156,10 @@
"governance",
"evidence",
"permissions",
"resume",
"intake-review"
"accessibility"
],
"created_at": "2026-07-13T00:00:00Z",
"updated_at": "2026-07-21T00:00:00Z"
"updated_at": "2026-07-28T00:00:00Z"
},
"canon-core": {
"name": "Canon Core",
@@ -397,13 +396,13 @@
"intake-review-governance": {
"name": "Intake Review Governance",
"id": "intake-review-governance",
"version": "0.1.1",
"description": "Reviews single, series, and campaign intake files before Spec Kit execution and binds accepted outcomes to normalized content hashes.",
"version": "0.2.0",
"description": "Reviews single, series, campaign, and language-aware requirements collections before Spec Kit execution.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-intake-review-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/archive/refs/tags/v0.1.1.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/archive/refs/tags/v0.2.0.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-intake-review-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/blob/v0.1.1/README.md",
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/blob/v0.2.0/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.3"
@@ -411,36 +410,36 @@
"provides": {
"templates": 8,
"commands": 3,
"scripts": 2
"scripts": 4
},
"tags": [
"intake",
"review",
"governance",
"quality-gate",
"autonomous"
"requirements",
"quality-gate"
],
"created_at": "2026-07-21T00:00:00Z",
"updated_at": "2026-07-24T00:00:00Z"
"updated_at": "2026-07-28T00:00:00Z"
},
"intake-sequencing-governance": {
"name": "Intake Sequencing Governance",
"id": "intake-sequencing-governance",
"version": "0.1.0",
"description": "Manages traceable intake-series order, typed dependencies, lifecycle, and safe next-candidate selection without executing downstream workflows.",
"version": "0.2.2",
"description": "Manages language-aware intake-series order, typed dependencies, lifecycle, and authority-neutral next-candidate selection.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance/archive/refs/tags/v0.1.0.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance/archive/refs/tags/v0.2.2.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance/blob/v0.1.0/README.md",
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance/blob/v0.2.2/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.3"
},
"provides": {
"templates": 10,
"templates": 11,
"commands": 6,
"scripts": 5
"scripts": 8
},
"tags": [
"intake",
@@ -450,7 +449,7 @@
"lifecycle"
],
"created_at": "2026-07-27T00:00:00Z",
"updated_at": "2026-07-27T00:00:00Z"
"updated_at": "2026-07-28T00:00:00Z"
},
"isaqb-architecture-governance": {
"name": "iSAQB Architecture Governance",
@@ -572,16 +571,16 @@
"parallel-autonomous-run-governance": {
"name": "Parallel Autonomous Run Governance",
"id": "parallel-autonomous-run-governance",
"version": "0.2.3",
"description": "Coordinates isolated autonomous Spec Kit campaigns with bounded concurrency, mixed agents, resumable consolidation, governed post-merge closeout, schema 1.2, and an optional current intake-review gate before worker scheduling.",
"version": "0.2.4",
"description": "Coordinates permission-bounded autonomous campaigns while preserving the project's learner and accessibility contract across workers and consolidation.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/archive/refs/tags/v0.2.3.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/archive/refs/tags/v0.2.4.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/blob/v0.2.3/README.md",
"documentation": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/blob/v0.2.4/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.3"
"speckit_version": ">=0.8.3"
},
"provides": {
"templates": 9,
@@ -592,12 +591,11 @@
"parallel",
"autonomous",
"governance",
"orchestration",
"resume",
"intake-review"
"accessibility",
"orchestration"
],
"created_at": "2026-07-22T00:00:00Z",
"updated_at": "2026-07-22T00:00:00Z"
"updated_at": "2026-07-28T00:00:00Z"
},
"pirate": {
"name": "Pirate Speak (Full)",

View File

@@ -25,6 +25,29 @@
"workflow",
"core"
]
},
"constitution-sync": {
"name": "Constitution Template Sync",
"id": "constitution-sync",
"version": "1.0.0",
"description": "Opt-in: restores /constitution propagation of amended guidance into plan/spec/tasks templates and installed command files, for teams that treat materialized templates as reviewed artifacts.",
"author": "github",
"repository": "https://github.com/github/spec-kit",
"license": "MIT",
"bundled": true,
"requires": {
"speckit_version": ">=0.14.4"
},
"provides": {
"commands": 1,
"templates": 0
},
"tags": [
"constitution",
"governance",
"templates",
"compatibility"
]
}
}
}

View File

@@ -0,0 +1,126 @@
# Constitution Template Sync
An **opt-in** preset that restores `/constitution`'s ability to propagate amended guidance into your
project's own templates and command files. After you update the constitution, it aligns
`plan-template.md`, `spec-template.md`, `tasks-template.md`, project-local command files, and
guidance docs so they reflect the current principles.
This propagation used to be built into `/constitution`; it was dropped when the command moved to the
preset model. Installing this preset opts you back into it: you get the guidance materialized into
reviewed, committed artifacts instead of relying on runtime resolution alone.
> **What you're opting into.** Propagation was removed deliberately — it duplicates the constitution
> as the source of truth and can fight the composition stack (materialized edits get shadowed or
> clobbered on the next recompose). This preset knowingly **reintroduces** that behavior, and those
> tradeoffs, for teams that want it. Read the [caveats](#caveats-you-take-on) before installing.
For most projects the default composable stack is the **recommended** approach, and at organization
scale it is usually the stronger governance model. Runtime resolution keeps the live constitution as
the single source of truth (nothing to re-sync, so nothing drifts), and the stack composes the
**entire** Spec Kit ecosystem — not just the SDD commands, but every command, template, script and
extension — with explicit priority levels, strategies, and independent versioning. It is a
capability, not automatic governance: a core team authors its own organizational presets and
extensions, then owns, versions, and audits that policy in one place and rolls it across many
repositories, instead of scattering frozen, per-repo copies no central team can see. This preset is
a supported escape hatch for teams whose workflow depends on reviewing materialized artifacts
directly — useful as a bridge, though for org-wide policy the better long-term path is usually a
versioned preset a core team maintains.
## What it does
Ships a single `wrap`-strategy override of `speckit.constitution`. It composes on top of the
current core command (via `{CORE_TEMPLATE}`), so it stays forward-compatible with core changes, and
appends a propagation pass that, after the constitution is written:
- Aligns `plan/spec/tasks-template.md` in `.specify/templates/` with the updated principles.
- Updates **project-local** command files and guidance docs to correct stale references.
- Extends the Sync Impact Report in `.specify/memory/constitution.md` with the files it touched.
## What it does not do
- It does **not** change behavior for anyone who does not install it — the default runtime
resolution model is untouched.
- It does **not** disable runtime resolution. `plan`, `tasks`, and `analyze` still read the live
constitution every run; this preset adds materialized copies on top — it does not replace the
source of truth.
- It does **not** edit versioned, package-owned files — templates or command files provided or
wrapped by another preset or extension. Those are recomposed from the resolution stack, so it
only ever writes into your project's own `.specify/templates/` scaffolds and command files that
are not managed by a preset/extension.
## When to use it
Install it **only** if your team treats the materialized templates and commands as
**reviewed, committed artifacts** — for example, if `plan-template.md`'s Constitution Check is
read in PRs as "here are our current gates" and is expected to track the constitution.
If you rely on the default runtime-resolution model, you do **not** need this preset: the live
constitution is already the single source of truth and there is nothing to sync.
## Caveats you take on
The preset resolution stack is how Spec Kit composes templates and commands going forward: they are
**layered, package-owned artifacts recomposed on demand**, not frozen files you edit in place.
Propagation is the opposite idea — it **materializes** guidance into files and freezes it. That
tension is the main thing to understand before installing:
- **Materialized copies can drift.** Anything propagated is a snapshot; if you amend the
constitution and do not re-run `/constitution`, the copies fall out of sync. The default runtime
model has no drift because it reads the live constitution every run.
- **Edits to composed files do not survive reconciliation.** If the rest of your SDD flow is
preset/extension-managed, the commands it materializes (`speckit.plan`, `speckit.specify`,
`speckit.tasks`, `speckit.analyze`, `speckit.implement`, …) are recomputed from the stack. Any
guidance propagated into them is clobbered the next time the stack reconciles — on
`specify integration use <key>` / `switch`, `specify integration upgrade`, or any preset/extension
install or remove. The same applies to templates owned by another preset/extension. This is why
the preset restricts itself to project-local files; propagation is reliable **only** for
artifacts you own outright.
- **A pre-filled Constitution Check can bias `/plan`.** Materializing concrete gates into
`plan-template.md` replaces the runtime pointer, so the first `/plan` pass may anchor on the
frozen text. Keep the pointer unless you specifically want committed gates.
**Bottom line:** this preset fits projects whose governed templates and commands are project-local
artifacts they review, with the rest of the SDD flow on the plain bundled core. If your
`plan`/`specify`/`tasks`/`analyze` commands or templates come from other presets or extensions,
prefer the default runtime-resolution model.
## Installation
```bash
# constitution-sync is a bundled preset — no download needed
specify preset add constitution-sync
```
## Development
```bash
# Test from local directory
specify preset add --dev ./presets/constitution-sync
# Verify the wrapped command resolves
specify preset resolve speckit.constitution
# Remove when done
specify preset remove constitution-sync
```
## Migrating back to the default
To move back to runtime resolution, reset each materialized `## Constitution Check` section in
`.specify/templates/plan-template.md` to the pointer:
```text
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
[Gates determined based on constitution file]
```
Then remove this preset. See `docs/upgrade.md` for details.
## License
MIT

View File

@@ -0,0 +1,54 @@
---
description: Create or update the project constitution, then propagate the amended guidance into dependent templates and installed command files (opt-in template sync).
strategy: wrap
handoffs:
- label: Build Specification
agent: speckit.specify
prompt: Implement the feature specification based on the updated constitution. I want to build...
---
{CORE_TEMPLATE}
## Constitution Template Sync
> **This section supersedes the "Scope Guard" above for template and command propagation.**
> The core Scope Guard states that dependent templates and commands "are not modified here" —
> that restriction is intentionally lifted by this preset. Propagating amended guidance into the
> project's own dependent scaffolds and installed command files (as described below) **is in
> scope** whenever this preset is installed. The Scope Guard's other constraints still apply:
> do not implement features, generate application code, or touch artifacts unrelated to the
> constitution/template workflow.
After you have written the updated constitution above, perform a consistency propagation pass
so the dependent artifacts reflect the amended principles:
1. Read `.specify/templates/plan-template.md` and ensure any "Constitution Check" or rules align
with the updated principles. Only materialize concrete gate text here if your team intends to
review it as committed content; otherwise leave the runtime pointer
`[Gates determined based on constitution file]` in place so `/plan` fills it from the live
constitution.
2. Read `.specify/templates/spec-template.md` for scope/requirements alignment — update if the
constitution adds/removes mandatory sections or constraints.
3. Read `.specify/templates/tasks-template.md` and ensure task categorization reflects new or
removed principle-driven task types (e.g., observability, versioning, testing discipline).
4. Read each installed Spec Kit command file for your agent (including this one) — named
`speckit.*` or `speckit-*` (dot or hyphen depending on the agent), or laid out as
`speckit-<name>/SKILL.md` for skills-based integrations, e.g. in `.github/agents/`,
`.github/skills/`, `.claude/skills/`, or your agent's equivalent commands directory — to verify
no outdated references (CLAUDE-only or other agent-specific names) remain when generic guidance
is required. **Only hand-edit a command file if it is a project-local file not managed by a
preset or extension.** Command files that are composed from the resolution stack (anything
provided or wrapped by a preset/extension) must be regenerated through the stack — do **not**
edit them in place, because reconciliation (`specify integration use`, `specify integration
upgrade`, or any preset/extension install/remove) will clobber the edits.
5. Read any runtime guidance docs (e.g., `README.md`, `docs/quickstart.md`, or agent-specific
guidance files if present) and update references to principles that changed.
Then extend the Sync Impact Report at the top of `.specify/memory/constitution.md` with:
- Templates requiring updates (✅ updated / ⚠ pending) with file paths.
**Do not edit versioned preset- or extension-provided template or command files directly.** Those
artifacts are owned by their packages and are recomposed on the package's next update or on stack
reconciliation — hand edits are clobbered. Limit propagation to the project's own
`.specify/templates/` scaffolds and to command files that are not managed by a preset or extension.

View File

@@ -0,0 +1,30 @@
schema_version: "1.0"
preset:
id: "constitution-sync"
name: "Constitution Template Sync"
version: "1.0.0"
description: "Opt-in: restores /constitution propagation of amended guidance into plan/spec/tasks templates and installed command files, for teams that treat materialized templates as reviewed artifacts."
author: "github"
repository: "https://github.com/github/spec-kit"
license: "MIT"
requires:
# Requires the runtime-resolution baseline (#3790, shipped in 0.14.4) where the
# core /constitution command no longer propagates. Installing this preset on an
# older core would double-apply propagation.
speckit_version: ">=0.14.4"
provides:
templates:
- type: "command"
name: "speckit.constitution"
file: "commands/speckit.constitution.md"
description: "Wrap /constitution to also propagate guidance into dependent templates and command files"
strategy: "wrap"
tags:
- "constitution"
- "governance"
- "templates"
- "compatibility"

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.14.3"
version = "0.15.2.dev0"
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"
@@ -49,6 +49,7 @@ packages = ["src/specify_cli"]
"workflows/speckit" = "specify_cli/core_pack/workflows/speckit"
# Bundled presets (installable via `specify preset add <name>` or `specify init --preset <name>`)
"presets/lean" = "specify_cli/core_pack/presets/lean"
"presets/constitution-sync" = "specify_cli/core_pack/presets/constitution-sync"
# Community bundle catalog snapshot (used for offline discovery)
"bundles/catalog.community.json" = "specify_cli/core_pack/bundles/catalog.community.json"

View File

@@ -55,9 +55,17 @@ function Resolve-SpecifyInitDir {
}
# Resolve-Path echoes back any trailing separator from the input; trim it so
# the returned root matches the bash resolver, whose `cd && pwd` never yields
# one. TrimEndingDirectorySeparator is a no-op on a bare root and on a path
# that already has no trailing separator.
$initRoot = [System.IO.Path]::TrimEndingDirectorySeparator($resolved.Path)
# one. TrimEnd (not [Path]::TrimEndingDirectorySeparator, which is .NET Core
# only) keeps this working on Windows PowerShell 5.1 / .NET Framework, as
# Get-FeaturePathsEnv already does below. Unlike a bare TrimEnd, the
# GetPathRoot check preserves a path that *is* its own root ('C:\' must not
# become 'C:', which every later API re-resolves against the current
# directory instead of the drive root). No-op on a path with no trailing
# separator.
$initRoot = $resolved.Path.TrimEnd('/', '\')
if ($initRoot.Length -lt [System.IO.Path]::GetPathRoot($resolved.Path).Length) {
$initRoot = $resolved.Path
}
if (-not (Test-Path -LiteralPath (Join-Path $initRoot '.specify') -PathType Container)) {
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $initRoot")
if ($ReturnNullOnError) { return $null }

View File

@@ -258,16 +258,27 @@ def get_invoke_separator(repo_root: Path) -> str:
integration_json = repo_root / ".specify" / "integration.json"
if not integration_json.is_file():
return "."
# Split the parse out of the lookup and guard the top-level shape, matching
# read_feature_json_feature_directory above and the bash/PowerShell twins,
# which both fall back to "." for any unusable integration.json:
# * a non-mapping top level ([], "forge", 42, null) is valid JSON, so
# json.JSONDecodeError never fires and state.get(...) raised
# AttributeError;
# * a non-UTF-8 file raises UnicodeDecodeError, which is a ValueError --
# not an OSError -- so it escaped the except tuple. Realistic on
# Windows, where PowerShell 5.1's Out-File/`>` default to UTF-16.
try:
state = json.loads(integration_json.read_text(encoding="utf-8"))
key = state.get("default_integration") or state.get("integration") or ""
settings = state.get("integration_settings")
if isinstance(key, str) and isinstance(settings, dict):
entry = settings.get(key)
if isinstance(entry, dict) and entry.get("invoke_separator") in {".", "-"}:
return entry["invoke_separator"]
except (OSError, json.JSONDecodeError):
pass
except (OSError, UnicodeError, json.JSONDecodeError):
return "."
if not isinstance(state, dict):
return "."
key = state.get("default_integration") or state.get("integration") or ""
settings = state.get("integration_settings")
if isinstance(key, str) and isinstance(settings, dict):
entry = settings.get(key)
if isinstance(entry, dict) and entry.get("invoke_separator") in {".", "-"}:
return entry["invoke_separator"]
return "."

View File

@@ -114,6 +114,7 @@ def _refresh_shared_templates(
project_path: Path,
*,
invoke_separator: str,
invoke_prefix: str = "/",
force: bool = False,
) -> None:
"""Refresh default-sensitive shared templates without touching scripts."""
@@ -124,6 +125,7 @@ def _refresh_shared_templates(
repo_root=_repo_root(),
console=console,
invoke_separator=invoke_separator,
invoke_prefix=invoke_prefix,
force=force,
)
@@ -134,6 +136,7 @@ def _install_shared_infra(
tracker: StepTracker | None = None,
force: bool = False,
invoke_separator: str = ".",
invoke_prefix: str = "/",
refresh_managed: bool = False,
refresh_hint: str | None = None,
) -> bool:
@@ -177,6 +180,7 @@ def _install_shared_infra(
console=console,
force=force,
invoke_separator=invoke_separator,
invoke_prefix=invoke_prefix,
refresh_managed=refresh_managed,
refresh_hint=refresh_hint,
)
@@ -188,6 +192,7 @@ def _install_shared_infra_or_exit(
tracker: StepTracker | None = None,
force: bool = False,
invoke_separator: str = ".",
invoke_prefix: str = "/",
refresh_managed: bool = False,
refresh_hint: str | None = None,
) -> bool:
@@ -198,6 +203,7 @@ def _install_shared_infra_or_exit(
tracker=tracker,
force=force,
invoke_separator=invoke_separator,
invoke_prefix=invoke_prefix,
refresh_managed=refresh_managed,
refresh_hint=refresh_hint,
)
@@ -508,6 +514,11 @@ _register_extension_cmds(app)
from .integrations._commands import register as _register_integration_cmds # noqa: E402
_register_integration_cmds(app)
# ===== Event Commands =====
from .commands.event import register as _register_event_cmds # noqa: E402
_register_event_cmds(app)
# Re-export selected helpers to preserve the public import surface.
from .integrations._helpers import ( # noqa: E402
_clear_init_options_for_integration as _clear_init_options_for_integration,

View File

@@ -7,6 +7,7 @@ import re
import socket
import stat
import struct
import tarfile
import unicodedata
import zipfile
from collections.abc import Iterator
@@ -14,11 +15,12 @@ from contextlib import ExitStack, contextmanager
from ipaddress import IPv4Address, IPv6Address, ip_address
from itertools import pairwise
from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import NoReturn, TypeVar
from typing import BinaryIO, Literal, NoReturn, TypeVar
from urllib.parse import ParseResult, urlparse
ErrorT = TypeVar("ErrorT", bound=Exception)
ArchiveFormat = Literal["zip", "tar.gz"]
MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024
MAX_ZIP_ENTRIES = 512
@@ -67,6 +69,130 @@ _ZIP_MAX_COMMENT_BYTES = (1 << 16) - 1
_BOUNDED_ZIP_COMPRESSION_METHODS = frozenset(
(zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED)
)
_ARCHIVE_CONTENT_TYPES: dict[str, ArchiveFormat] = {
"application/gzip": "tar.gz",
"application/x-gzip": "tar.gz",
"application/x-tar+gzip": "tar.gz",
"application/zip": "zip",
"application/x-zip-compressed": "zip",
}
def archive_format_from_name(name: str) -> ArchiveFormat | None:
"""Return the supported archive format declared by a path or URL."""
try:
path = urlparse(name).path.lower()
except (TypeError, ValueError):
return None
if path.endswith(".tar.gz") or path.endswith(".tgz"):
return "tar.gz"
if path.endswith(".zip"):
return "zip"
return None
def archive_format_from_content_type(content_type: str | None) -> ArchiveFormat | None:
"""Return the supported archive format declared by an HTTP Content-Type."""
if not isinstance(content_type, str):
return None
media_type = content_type.partition(";")[0].strip().lower()
return _ARCHIVE_CONTENT_TYPES.get(media_type)
def archive_suffix(archive_format: ArchiveFormat) -> str:
"""Return the canonical filename suffix for *archive_format*."""
if archive_format == "zip":
return ".zip"
if archive_format == "tar.gz":
return ".tar.gz"
raise ValueError(f"Unsupported archive format: {archive_format!r}")
def detect_archive_format(
archive_path: Path,
*,
archive_file: BinaryIO | None = None,
source_name: str | None = None,
content_type: str | None = None,
error_type: type[ErrorT] = ValueError,
) -> ArchiveFormat:
"""Validate the declared archive format against the file contents.
A recognized path/URL suffix is authoritative. For remote responses whose
final URL has no archive suffix, a recognized Content-Type may declare the
format instead. When both declarations are recognized they must agree, and
the resulting declaration must match the archive bytes.
"""
archive_path = Path(archive_path)
name_format = archive_format_from_name(
source_name if source_name is not None else str(archive_path)
)
content_format = archive_format_from_content_type(content_type)
if (
name_format is not None
and content_format is not None
and name_format != content_format
):
_raise(
error_type,
f"Archive format mismatch: filename declares {name_format} but "
f"Content-Type declares {content_format}",
)
declared_format = name_format or content_format
with ExitStack() as stack:
if archive_file is None:
try:
archive_file = stack.enter_context(archive_path.open("rb"))
except OSError as exc:
_raise_from(error_type, f"Invalid archive: {archive_path}", exc)
try:
archive_file.seek(0)
is_zip = zipfile.is_zipfile(archive_file)
archive_file.seek(0)
signature = archive_file.read(4)
# Let the bounded ZIP preflight report structural errors such as
# impossible entry counts. ``is_zipfile`` rejects those before the
# extractor can produce the established security diagnostic.
is_zip = is_zip or signature in {
b"PK\x03\x04",
b"PK\x05\x06",
b"PK\x07\x08",
}
is_gzip = signature[:2] == b"\x1f\x8b"
archive_file.seek(0)
is_tar_gz = False
if is_gzip:
try:
with tarfile.open(fileobj=archive_file, mode="r:gz"):
is_tar_gz = True
except tarfile.TarError:
pass
archive_file.seek(0)
except OSError as exc:
_raise_from(error_type, f"Invalid archive: {archive_path}", exc)
actual_format: ArchiveFormat | None
if is_zip and not is_tar_gz:
actual_format = "zip"
elif is_tar_gz and not is_zip:
actual_format = "tar.gz"
else:
actual_format = None
if declared_format is None:
if actual_format is None:
_raise(
error_type,
"Unsupported archive format; expected .zip, .tar.gz, or .tgz",
)
declared_format = actual_format
if actual_format != declared_format:
actual_label = actual_format or "invalid/unsupported data"
_raise(
error_type,
f"Archive format mismatch: expected {declared_format}, got {actual_label}",
)
return declared_format
def _ip_address_without_scope(
@@ -292,6 +418,7 @@ def build_safe_download_path(
*,
error_type: type[ErrorT] = ValueError,
label: str = "archive",
suffix: str = ".zip",
) -> Path:
"""Build a portable single-component archive path inside *target_dir*."""
if not isinstance(identifier, str) or not isinstance(version, str):
@@ -301,7 +428,9 @@ def build_safe_download_path(
f"{identifier!r} and {version!r}",
)
filename = f"{identifier}-{version}.zip"
if suffix not in {".zip", ".tar.gz", ".tgz"}:
_raise(error_type, f"Unsupported archive download suffix: {suffix!r}")
filename = f"{identifier}-{version}{suffix}"
try:
filename_too_long = (
len(filename.encode("utf-8")) > MAX_ZIP_COMPONENT_BYTES
@@ -378,24 +507,25 @@ def read_zip_member_limited(
)
def normalize_zip_member_name(
def normalize_archive_member_name(
name: str,
*,
archive_label: str = "archive",
error_type: type[ErrorT] = ValueError,
) -> str:
"""Return a normalized, portable ZIP member name or raise if unsafe."""
"""Return a normalized, portable archive member name or raise if unsafe."""
if "\x00" in name:
_raise(error_type, f"Unsafe path in ZIP archive: {name!r}")
_raise(error_type, f"Unsafe path in {archive_label} archive: {name!r}")
normalized = name.replace("\\", "/")
try:
encoded_name = normalized.encode("utf-8")
except UnicodeEncodeError:
_raise(error_type, f"Unsafe path in ZIP archive: {name!r}")
_raise(error_type, f"Unsafe path in {archive_label} archive: {name!r}")
if len(encoded_name) > MAX_ZIP_PATH_BYTES:
_raise(
error_type,
f"Unsafe path in ZIP archive: {name!r} "
f"Unsafe path in {archive_label} archive: {name!r} "
"(not portable across supported filesystems)",
)
path = PurePosixPath(normalized)
@@ -415,7 +545,8 @@ def normalize_zip_member_name(
):
_raise(
error_type,
f"Unsafe path in ZIP archive: {name!r} (potential path traversal)",
f"Unsafe path in {archive_label} archive: {name!r} "
"(potential path traversal)",
)
for part in raw_parts:
reserved_stem = part.partition(".")[0].partition(":")[0].rstrip(" ")
@@ -432,13 +563,26 @@ def normalize_zip_member_name(
):
_raise(
error_type,
f"Unsafe path in ZIP archive: {name!r} "
f"Unsafe path in {archive_label} archive: {name!r} "
"(not portable across supported filesystems)",
)
return normalized
def portable_zip_path_key(name: str) -> tuple[str, ...]:
def normalize_zip_member_name(
name: str,
*,
error_type: type[ErrorT] = ValueError,
) -> str:
"""Return a normalized, portable ZIP member name or raise if unsafe."""
return normalize_archive_member_name(
name,
archive_label="ZIP",
error_type=error_type,
)
def portable_archive_path_key(name: str) -> tuple[str, ...]:
"""Return a comparison key for filesystems with case/Unicode folding."""
normalized_name = name.replace("\\", "/")
return tuple(
@@ -447,6 +591,11 @@ def portable_zip_path_key(name: str) -> tuple[str, ...]:
)
def portable_zip_path_key(name: str) -> tuple[str, ...]:
"""Backward-compatible ZIP-specific alias for portable archive keys."""
return portable_archive_path_key(name)
def _raise_zip64(error_type: type[ErrorT]) -> NoReturn:
_raise(
error_type,
@@ -705,6 +854,7 @@ def _preflight_zip_central_directory(
def open_zip_bounded(
zip_path: Path,
*,
archive_file: BinaryIO | None = None,
error_type: type[ErrorT] = ValueError,
max_entries: int = MAX_ZIP_ENTRIES,
) -> Iterator[zipfile.ZipFile]:
@@ -712,10 +862,11 @@ def open_zip_bounded(
_validate_non_negative_int(max_entries, "max_entries")
zip_path = Path(zip_path)
with ExitStack() as stack:
try:
archive_file = stack.enter_context(zip_path.open("rb"))
except OSError as exc:
_raise_from(error_type, f"Invalid ZIP archive: {zip_path}", exc)
if archive_file is None:
try:
archive_file = stack.enter_context(zip_path.open("rb"))
except OSError as exc:
_raise_from(error_type, f"Invalid ZIP archive: {zip_path}", exc)
try:
_preflight_zip_central_directory(
archive_file,
@@ -737,6 +888,7 @@ def safe_extract_zip(
zip_path: Path,
target_dir: Path,
*,
archive_file: BinaryIO | None = None,
error_type: type[ErrorT] = ValueError,
max_entries: int = MAX_ZIP_ENTRIES,
max_member_bytes: int = MAX_ZIP_MEMBER_BYTES,
@@ -752,6 +904,7 @@ def safe_extract_zip(
with open_zip_bounded(
zip_path,
archive_file=archive_file,
error_type=error_type,
max_entries=max_entries,
) as zf:
@@ -774,7 +927,7 @@ def safe_extract_zip(
error_type=error_type,
)
is_dir = member.is_dir() or normalized_name.endswith("/")
path_key = portable_zip_path_key(normalized_name)
path_key = portable_archive_path_key(normalized_name)
existing = validated_paths.get(path_key)
if existing is not None:
@@ -894,3 +1047,211 @@ def safe_extract_zip(
)
if limit_error is not None:
_raise(error_type, limit_error)
def safe_extract_tar(
archive_path: Path,
target_dir: Path,
*,
archive_file: BinaryIO | None = None,
error_type: type[ErrorT] = ValueError,
max_entries: int = MAX_ZIP_ENTRIES,
max_member_bytes: int = MAX_ZIP_MEMBER_BYTES,
max_total_bytes: int = MAX_ZIP_TOTAL_BYTES,
) -> None:
"""Extract a gzip-compressed tar after ZIP-equivalent safety validation."""
_validate_non_negative_int(max_entries, "max_entries")
_validate_non_negative_int(max_member_bytes, "max_member_bytes")
_validate_non_negative_int(max_total_bytes, "max_total_bytes")
archive_path = Path(archive_path)
try:
target_root = target_dir.resolve()
except OSError as exc:
_raise_from(error_type, f"Invalid tar extraction target: {target_dir}", exc)
try:
if archive_file is not None:
archive_file.seek(0)
archive = tarfile.open(
archive_path if archive_file is None else None,
mode="r:gz",
fileobj=archive_file,
)
except (tarfile.TarError, OSError) as exc:
_raise_from(error_type, f"Invalid tar.gz archive: {archive_path}", exc)
with archive:
validated: list[tuple[tarfile.TarInfo, str, bool]] = []
validated_paths: dict[tuple[str, ...], tuple[str, bool]] = {}
total_size = 0
try:
for index, member in enumerate(archive, start=1):
if index > max_entries:
_raise(
error_type,
f"tar.gz archive contains too many entries "
f"({index} > {max_entries})",
)
normalized_name = normalize_archive_member_name(
member.name,
archive_label="tar.gz",
error_type=error_type,
)
is_dir = member.isdir()
if member.issym():
_raise(
error_type,
f"Unsafe symlink in tar.gz archive: {member.name}",
)
if member.islnk():
_raise(
error_type,
f"Unsafe hard link in tar.gz archive: {member.name}",
)
if not is_dir and not member.isreg():
_raise(
error_type,
f"Unsafe member type in tar.gz archive: {member.name}",
)
path_key = portable_archive_path_key(normalized_name)
existing = validated_paths.get(path_key)
if existing is not None:
_raise(
error_type,
f"Conflicting path in tar.gz archive: {member.name} "
f"conflicts with {existing[0]}",
)
validated_paths[path_key] = (member.name, is_dir)
member_path = (target_dir / normalized_name).resolve()
try:
member_path.relative_to(target_root)
except ValueError:
_raise(
error_type,
f"Unsafe path in tar.gz archive: {member.name} "
"(potential path traversal)",
)
if not is_dir:
if member.size > max_member_bytes:
_raise(
error_type,
f"tar.gz member {member.name} exceeds maximum size "
f"of {max_member_bytes} bytes",
)
total_size += member.size
if total_size > max_total_bytes:
_raise(
error_type,
f"tar.gz archive exceeds maximum uncompressed size "
f"of {max_total_bytes} bytes",
)
validated.append((member, normalized_name, is_dir))
except (tarfile.TarError, OSError) as exc:
_raise_from(
error_type,
f"Invalid tar.gz archive: {archive_path}",
exc,
)
for (
(path_key, (original, is_dir)),
(next_key, (next_original, _next_is_dir)),
) in pairwise(sorted(validated_paths.items())):
if (
not is_dir
and len(next_key) > len(path_key)
and next_key[: len(path_key)] == path_key
):
_raise(
error_type,
f"Conflicting path in tar.gz archive: {original} conflicts "
f"with {next_original}",
)
total_written = 0
for member, normalized_name, is_dir in validated:
member_path = target_dir / normalized_name
if is_dir:
try:
member_path.mkdir(parents=True, exist_ok=True)
except OSError as exc:
_raise_from(
error_type,
f"Failed to create tar.gz directory {member.name}: {exc}",
exc,
)
continue
try:
member_path.parent.mkdir(parents=True, exist_ok=True)
source = archive.extractfile(member)
if source is None:
_raise(
error_type,
f"Failed to read tar.gz member {member.name}",
)
written = 0
limit_error: str | None = None
with source, member_path.open("wb") as dest:
while True:
chunk = source.read(READ_CHUNK_SIZE)
if not chunk:
break
written += len(chunk)
if written > max_member_bytes:
limit_error = (
f"tar.gz member {member.name} exceeds maximum size "
f"of {max_member_bytes} bytes"
)
break
total_written += len(chunk)
if total_written > max_total_bytes:
limit_error = (
f"tar.gz archive exceeds maximum uncompressed size "
f"of {max_total_bytes} bytes"
)
break
dest.write(chunk)
except Exception as exc:
_raise_from(
error_type,
f"Failed to extract tar.gz member {member.name}: {exc}",
exc,
)
if limit_error is not None:
_raise(error_type, limit_error)
def safe_extract_archive(
archive_path: Path,
target_dir: Path,
*,
archive_file: BinaryIO | None = None,
source_name: str | None = None,
content_type: str | None = None,
error_type: type[ErrorT] = ValueError,
max_entries: int = MAX_ZIP_ENTRIES,
max_member_bytes: int = MAX_ZIP_MEMBER_BYTES,
max_total_bytes: int = MAX_ZIP_TOTAL_BYTES,
) -> ArchiveFormat:
"""Detect and securely extract a supported archive."""
archive_format = detect_archive_format(
archive_path,
archive_file=archive_file,
source_name=source_name,
content_type=content_type,
error_type=error_type,
)
extractor = safe_extract_zip if archive_format == "zip" else safe_extract_tar
extractor(
archive_path,
target_dir,
archive_file=archive_file,
error_type=error_type,
max_entries=max_entries,
max_member_bytes=max_member_bytes,
max_total_bytes=max_total_bytes,
)
return archive_format

View File

@@ -159,8 +159,9 @@ def resolve_github_release_asset_api_url(
if len(parts) < 6 or parts[2:4] != ["releases", "download"]:
return None
owner, repo, tag = parts[0], parts[1], parts[4]
asset_name = "/".join(parts[5:])
owner, repo = parts[0], parts[1]
tag = "/".join(parts[4:-1])
asset_name = parts[-1]
encoded_tag = quote(tag, safe="")
release_url = f"{api_base}/repos/{owner}/{repo}/releases/tags/{encoded_tag}"

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", "grok", "trae", "zed"})
ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset({"devin", "droid", "grok", "trae", "zed"})
# Agents that render /speckit-<name> only when ai_skills is enabled.
CONDITIONAL_SLASH_AGENTS: frozenset[str] = frozenset(
@@ -29,6 +29,9 @@ CONDITIONAL_SLASH_AGENTS: frozenset[str] = frozenset(
}
)
# Agents that render /skill:<name> (skill-colon invocation) when in skills mode.
SKILL_COLON_AGENTS: frozenset[str] = frozenset({"kimi"})
def is_dollar_skills_agent(selected_ai: str | None, ai_skills_enabled: bool) -> bool:
"""Return ``True`` if *selected_ai* uses ``$speckit-<name>`` invocations.
@@ -41,6 +44,21 @@ def is_dollar_skills_agent(selected_ai: str | None, ai_skills_enabled: bool) ->
return selected_ai in DOLLAR_SKILLS_AGENTS and ai_skills_enabled
def get_invocation_prefix(selected_ai: str | None, ai_skills_enabled: bool) -> str:
"""Return the native invocation prefix for *selected_ai* in skills mode.
Returns ``"$"`` for dollar-skills agents (Codex, ZCode),
``"/skill:"`` for skill-colon agents (Kimi), and ``"/"`` for all others.
"""
if not isinstance(selected_ai, str):
return "/"
if selected_ai in DOLLAR_SKILLS_AGENTS and ai_skills_enabled:
return "$"
if selected_ai in SKILL_COLON_AGENTS and ai_skills_enabled:
return "/skill:"
return "/"
def is_slash_skills_agent(selected_ai: str | None, ai_skills_enabled: bool) -> bool:
"""Return ``True`` if *selected_ai* uses ``/speckit-<name>`` invocations.

View File

@@ -27,6 +27,7 @@ from pathlib import Path
import typer
from packaging.version import InvalidVersion, Version
from rich.markup import escape as _escape_markup
from ._download_security import MAX_JSON_METADATA_BYTES, read_response_limited
from ._console import console
@@ -1230,7 +1231,10 @@ def self_upgrade(
tag: str | None = typer.Option(
None,
"--tag",
help="Pin the target version (vX.Y.Z[suffix]). Without --tag, the "
# Typer renders help through Rich, so escape the literal bracket (\[)
# or `[suffix]` is parsed as a style tag and dropped -- `--help` then
# advertises only `(vX.Y.Z)`, contradicting docs/upgrade.md and README.
help="Pin the target version (vX.Y.Z\\[suffix]). Without --tag, the "
"latest stable release is resolved via GitHub Releases.",
),
) -> None:
@@ -1270,7 +1274,14 @@ def self_upgrade(
try:
tag = _validate_tag(tag)
except typer.BadParameter as exc:
console.print(str(exc), soft_wrap=True)
# Escape at the print site rather than baking `\[` into
# _INVALID_TAG_MESSAGE: the message is also raised through
# typer.BadParameter, which Click renders without Rich, so the
# constant must stay plain text. Unescaped, Rich parses the literal
# `[suffix]` as a style tag and drops it, leaving the user with
# "expected vMAJOR.MINOR.PATCH" -- implying a bare vX.Y.Z is the only
# accepted form when -rc1 / .dev0 / +build.42 are all valid.
console.print(_escape_markup(str(exc)), soft_wrap=True)
raise typer.Exit(1) from exc
plan, failure_reason = _build_upgrade_plan(target_tag_override=tag)

View File

@@ -15,6 +15,7 @@ from typing import Any, Dict, Iterable, List, Optional
import yaml
from ._init_options import is_ai_skills_enabled, load_init_options
from ._invocation_style import get_invocation_prefix
from ._toml_string import escape_toml_basic as _escape_toml_basic
from ._toml_string import has_illegal_toml_control as _has_illegal_toml_control
from ._utils import relative_extension_path_violation
@@ -301,8 +302,20 @@ class CommandRegistrar:
toml_lines = []
if "description" in frontmatter:
# Frontmatter comes from ``yaml.safe_load``, so ``description`` can
# be any YAML type: ``description:`` with no value yields None,
# ``description: 2`` an int, an unquoted ``true`` a bool.
# ``_render_basic_toml_string`` iterates the value and calls ord()
# on each character, so a non-string raises a raw TypeError -- and a
# list of single-character items is silently concatenated into a
# wrong value (``["a", "b"]`` -> ``"ab"``). Coerce first, matching
# ``render_yaml_command`` below and ``TomlIntegration
# ._extract_description``, which both normalise it already.
description = frontmatter["description"]
if not isinstance(description, str):
description = str(description) if description is not None else ""
toml_lines.append(
f"description = {self._render_basic_toml_string(frontmatter['description'])}"
f"description = {self._render_basic_toml_string(description)}"
)
toml_lines.append("")
@@ -598,7 +611,7 @@ class CommandRegistrar:
source_dir: Path,
project_root: Path,
context_note: Optional[str] = None,
_resolved_dir: Path = None,
_resolved_dir: Optional[Path] = None,
link_outputs: bool = False,
extension_id: Optional[str] = None,
) -> List[str]:
@@ -659,17 +672,16 @@ class CommandRegistrar:
# correct when a stale ``.bob/skills`` directory coexists with
# ``.bob/commands``.
_sep = agent_config.get("invoke_separator", ".")
registrar_writes_skills = agent_config.get("extension") == "/SKILL.md"
try:
from specify_cli.integrations import get_integration # noqa: PLC0415
_integ = get_integration(agent_name)
if _integ is not None:
registrar_writes_skills = (
agent_config.get("extension") == "/SKILL.md"
)
_sep = _integ.invoke_separator_for_mode(registrar_writes_skills)
except Exception:
pass
_prefix = get_invocation_prefix(agent_name, registrar_writes_skills)
for cmd_info in commands:
cmd_name = cmd_info["name"]
@@ -772,7 +784,7 @@ class CommandRegistrar:
# (base.py itself imports CommandRegistrar lazily).
from specify_cli.integrations.base import IntegrationBase # noqa: PLC0415
body = IntegrationBase.resolve_command_refs(body, _sep)
body = IntegrationBase.resolve_command_refs(body, _sep, _prefix)
output_name = self._compute_output_name(agent_name, cmd_name, agent_config)

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import base64
import json as _json
import os
import shutil
import subprocess
from typing import TYPE_CHECKING
@@ -71,9 +72,27 @@ class AzureDevOpsAuth(AuthProvider):
def _acquire_via_az_cli() -> str | None:
"""Run ``az account get-access-token`` and return the access token."""
try:
# Windows: ``subprocess.run`` calls ``CreateProcess``, which does
# not consult ``PATHEXT``, so a bare ``"az"`` (installed as
# ``az.cmd``) fails with ``WinError 2`` even after ``az login``.
# Resolve via ``shutil.which`` (which honors ``PATHEXT``) so the
# ``.cmd`` shim works. On POSIX this is a harmless lookup that
# returns the same executable.
#
# Require an ABSOLUTE result: on Windows ``shutil.which`` prepends
# the current directory to the search path (unless
# ``NoDefaultCurrentDirectoryInExePath`` is set), so a stray
# ``.\az.cmd`` in the working directory would otherwise be resolved
# ahead of the real Azure CLI and run for a credential operation. A
# legitimate install always resolves to an absolute path, so this
# costs nothing; falling back to the bare ``"az"`` preserves the
# prior behavior (and the existing OSError path) when ``az`` is
# absent.
resolved = shutil.which("az")
az = resolved if resolved and os.path.isabs(resolved) else "az"
result = subprocess.run( # noqa: S603, S607
[
"az",
az,
"account",
"get-access-token",
"--resource",

View File

@@ -58,7 +58,12 @@ def load_yaml(path: Path) -> Any:
raise BundlerError(f"File not found: {path}")
try:
text = path.read_text(encoding="utf-8")
except OSError as exc:
except (OSError, UnicodeError) as exc:
# A non-UTF-8 file raises UnicodeDecodeError, which is a ValueError --
# NOT an OSError -- so it escaped this module's "IO failures degrade
# into actionable BundlerError" contract as a raw traceback. Realistic
# on Windows, where PowerShell 5.1's `Out-File`/`>` default to UTF-16.
# Matches the sibling catalog readers (catalogs.py, workflows/catalog.py).
raise BundlerError(f"Could not read {path}: {exc}") from exc
try:
has_node = yaml.compose(text) is not None
@@ -98,9 +103,15 @@ def load_json(path: Path) -> Any:
try:
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
# JSONDecodeError stays FIRST: it and UnicodeDecodeError are sibling
# ValueError subclasses (neither subsumes the other), so malformed-but-
# decodable JSON keeps its more specific "Invalid JSON" message while a
# decode failure falls through to the read-error clause below.
except json.JSONDecodeError as exc:
raise BundlerError(f"Invalid JSON in {path}: {exc}") from exc
except OSError as exc:
except (OSError, UnicodeError) as exc:
# See load_yaml: a non-UTF-8 file raises UnicodeDecodeError, which is
# not an OSError, and previously escaped as a raw traceback.
raise BundlerError(f"Could not read {path}: {exc}") from exc

View File

@@ -96,19 +96,19 @@ class BundleManifest:
if not isinstance(data, dict):
raise BundlerError("Manifest must be a YAML mapping at the top level.")
schema_version = str(data.get("schema_version", "")).strip()
schema_version = _text(data.get("schema_version"))
bundle_raw = data.get("bundle")
if not isinstance(bundle_raw, dict):
raise BundlerError("Manifest is missing the required 'bundle' mapping.")
meta = BundleMeta(
id=str(bundle_raw.get("id", "")).strip(),
name=str(bundle_raw.get("name", "")).strip(),
version=str(bundle_raw.get("version", "")).strip(),
role=str(bundle_raw.get("role", "")).strip(),
description=str(bundle_raw.get("description", "")).strip(),
author=str(bundle_raw.get("author", "")).strip(),
license=str(bundle_raw.get("license", "")).strip(),
id=_text(bundle_raw.get("id")),
name=_text(bundle_raw.get("name")),
version=_text(bundle_raw.get("version")),
role=_text(bundle_raw.get("role")),
description=_text(bundle_raw.get("description")),
author=_text(bundle_raw.get("author")),
license=_text(bundle_raw.get("license")),
)
requires_raw = data.get("requires")
@@ -117,7 +117,7 @@ class BundleManifest:
elif not isinstance(requires_raw, dict):
raise BundlerError("'requires' must be a mapping when present.")
requires = Requires(
speckit_version=str(requires_raw.get("speckit_version", "")).strip(),
speckit_version=_text(requires_raw.get("speckit_version")),
tools=_parse_str_list(requires_raw.get("tools"), "requires.tools"),
mcp=_parse_str_list(requires_raw.get("mcp"), "requires.mcp"),
)
@@ -220,6 +220,22 @@ class BundleManifest:
return self.integration is None
def _text(raw: Any) -> str:
"""Coerce a manifest scalar into stripped text, mapping an explicit null to ``""``.
A ``.get(key, "")`` default only covers a *missing* key. A key that is
present but null -- how YAML spells an empty field (``author:`` with nothing
after it) -- yields ``None``, and ``str(None)`` is the literal ``"None"``.
That text is non-empty, so it sailed past the ``if not value`` required-field
checks in :meth:`BundleManifest.structural_errors`: an empty required field
was silently accepted and the bundle shipped ``"None"`` as its
author/license/description.
"""
if raw is None:
return ""
return str(raw).strip()
def _parse_str_list(raw: Any, field_name: str) -> tuple[str, ...]:
"""Coerce a manifest list-of-strings field into a tuple of strings.
@@ -247,7 +263,7 @@ def _parse_refs(kind: str, raw: Any) -> list[ComponentRef]:
refs.append(
ComponentRef(
kind=kind,
id=str(item.get("id", "")).strip(),
id=_text(item.get("id")),
version=(str(item["version"]).strip() if item.get("version") else None),
source=(str(item["source"]).strip() if item.get("source") else None),
priority=priority,

View File

@@ -18,7 +18,7 @@ from urllib.request import url2pathname
from ..._assets import _locate_core_pack, _repo_root
from ..._download_security import MAX_JSON_CATALOG_BYTES, read_response_limited
from .. import BundlerError
from ..lib.yamlio import loads_json
from ..lib.yamlio import load_json, loads_json
from ..models.catalog import CatalogSource
from ..models.manifest import ComponentRef
@@ -145,13 +145,13 @@ def make_catalog_fetcher(*, allow_network: bool = True):
path = _file_url_to_path(parsed)
if not path.exists():
raise BundlerError(f"Catalog file not found: {path}")
return loads_json(path.read_text(encoding="utf-8"), origin=str(path))
return load_json(path)
if scheme == "" or _is_windows_drive_path(url):
path = Path(url)
if not path.exists():
raise BundlerError(f"Catalog file not found: {path}")
return loads_json(path.read_text(encoding="utf-8"), origin=str(path))
return load_json(path)
if scheme in ("http", "https"):
if not allow_network:
@@ -227,6 +227,10 @@ class DefaultPrimitiveInstaller:
manager = self._manager_for(component, project_root)
manager.install(component)
def refresh(self, project_root: Path, component: ComponentRef) -> None:
manager = self._manager_for(component, project_root)
manager.refresh(component)
def remove(self, project_root: Path, component: ComponentRef) -> None:
manager = self._manager_for(component, project_root)
manager.remove(component)

View File

@@ -93,9 +93,11 @@ def build_bundle(
# extraction, but collapse to two canonical modes (0755 when any
# execute bit is set on the source, otherwise 0644) so identical
# inputs yield a byte-for-byte identical artifact.
mode = 0o755 if file_path.stat().st_mode & 0o111 else 0o644
info.external_attr = mode << 16
archive.writestr(info, file_path.read_bytes())
with file_path.open("rb") as fh:
st = os.fstat(fh.fileno())
mode = 0o755 if st.st_mode & 0o111 else 0o644
info.external_attr = mode << 16
archive.writestr(info, fh.read())
return BuildResult(artifact_path=artifact_path, file_count=len(files))

View File

@@ -85,11 +85,17 @@ def _bundled_manifest_version(manifest_path: Path, root_key: str) -> str | None:
class _KindManager(Protocol):
def is_installed(self, component: ComponentRef) -> bool: ...
def is_installed(self, component: ComponentRef) -> bool:
pass
def install(self, component: ComponentRef) -> None: ...
def install(self, component: ComponentRef) -> None:
pass
def remove(self, component: ComponentRef) -> None: ...
def refresh(self, component: ComponentRef) -> None:
pass
def remove(self, component: ComponentRef) -> None:
pass
def primitive_manager(
@@ -151,6 +157,12 @@ class _PresetKindManager:
return False
def install(self, component: ComponentRef) -> None:
self._do_install(component, force=False)
def refresh(self, component: ComponentRef) -> None:
self._do_install(component, force=True)
def _do_install(self, component: ComponentRef, *, force: bool) -> None:
from ... import get_speckit_version
from ..._assets import _locate_bundled_preset
@@ -168,7 +180,9 @@ class _PresetKindManager:
component.version,
_bundled_manifest_version(bundled / "preset.yml", "preset"),
)
self._manager.install_from_directory(bundled, speckit_version, priority)
self._manager.install_from_directory(
bundled, speckit_version, priority, **({"force": True} if force else {})
)
return
if not self._allow_network:
@@ -194,7 +208,9 @@ class _PresetKindManager:
)
zip_path = catalog.download_pack(component.id)
try:
self._manager.install_from_zip(zip_path, speckit_version, priority)
self._manager.install_from_zip(
zip_path, speckit_version, priority, **({"force": True} if force else {})
)
finally:
with contextlib.suppress(Exception):
if zip_path.exists():
@@ -224,6 +240,12 @@ class _ExtensionKindManager:
return False
def install(self, component: ComponentRef) -> None:
self._do_install(component, force=False)
def refresh(self, component: ComponentRef) -> None:
self._do_install(component, force=True)
def _do_install(self, component: ComponentRef, *, force: bool) -> None:
from ... import get_speckit_version
from ..._assets import _locate_bundled_extension
@@ -242,7 +264,7 @@ class _ExtensionKindManager:
_bundled_manifest_version(bundled / "extension.yml", "extension"),
)
self._manager.install_from_directory(
bundled, speckit_version, priority=priority
bundled, speckit_version, priority=priority, force=force
)
return
@@ -272,7 +294,7 @@ class _ExtensionKindManager:
zip_path = catalog.download_extension(component.id)
try:
self._manager.install_from_zip(
zip_path, speckit_version, priority=priority
zip_path, speckit_version, priority=priority, force=force
)
finally:
with contextlib.suppress(Exception):
@@ -318,6 +340,11 @@ class _WorkflowKindManager:
lambda: workflow_add(component.id),
)
def refresh(self, component: ComponentRef) -> None:
# workflow_add is idempotent for already-installed workflows; delegate
# to the standard install path which handles version refresh correctly.
self.install(component)
def _assert_pinned_version(self, component: ComponentRef) -> None:
if not component.version:
return
@@ -378,6 +405,35 @@ class _StepKindManager:
lambda: workflow_step_add(component.id),
)
def refresh(self, component: ComponentRef) -> None:
# Preserve an existing step until we've validated we can perform refresh.
# For already-installed steps, keep a backup and restore it if the
# remove+reinstall path fails.
if not (self._allow_network and self.is_installed(component)):
self.install(component)
return
import shutil
import tempfile
step_dir = self._registry.steps_dir / component.id
metadata = self._registry.get(component.id)
backup_dir = Path(tempfile.mkdtemp(prefix="speckit-step-refresh-")) / component.id
try:
if step_dir.exists():
shutil.copytree(step_dir, backup_dir)
self.remove(component)
try:
self.install(component)
except BundlerError:
if backup_dir.exists():
shutil.copytree(backup_dir, step_dir, dirs_exist_ok=True)
if metadata is not None and not self._registry.is_installed(component.id):
self._registry.add(component.id, metadata)
raise
finally:
shutil.rmtree(backup_dir.parent, ignore_errors=True)
def remove(self, component: ComponentRef) -> None:
from ... import workflow_step_remove

View File

@@ -74,6 +74,13 @@ class CatalogStackBase:
try:
parsed = urlparse(url)
hostname = parsed.hostname
# Accessing ``port`` performs urllib's syntax/range validation;
# ``hostname`` alone does not, so a non-numeric or out-of-range
# port would otherwise pass validation here and only fail later,
# at fetch time, as an error this module does not translate --
# a raw http.client.InvalidURL for a non-numeric port, and a
# socket-layer failure for one that is merely out of range.
_ = parsed.port
except ValueError:
raise cls._error(f"Catalog URL is malformed: {url}") from None
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")

View File

@@ -12,6 +12,7 @@ import re
from pathlib import Path
import typer
from rich.markup import escape as _escape_markup
from ..._console import console, err_console
from ..._download_security import MAX_DOWNLOAD_BYTES, read_response_limited
@@ -118,6 +119,8 @@ def _run_init(integration: str, *, script_type: str, offline: bool = False) -> N
preset=None,
integration=integration,
integration_options=None,
extensions=None,
trust_extension_urls=False,
)
except typer.Exit as exc:
if exc.exit_code:
@@ -185,11 +188,16 @@ def bundle_search(
else ""
)
console.print(
f" [bold]{r.entry.id}[/bold] v{r.entry.version}{r.entry.name} "
f"[dim]({r.entry.role})[/dim] {_trust_badge(r.entry.verified)} {policy}"
f" [bold]{_escape_markup(str(r.entry.id))}[/bold] "
f"v{_escape_markup(str(r.entry.version))} "
f"{_escape_markup(str(r.entry.name))} "
f"[dim]({_escape_markup(str(r.entry.role))})[/dim] "
f"{_trust_badge(r.entry.verified)} {policy}"
)
console.print(f" {_escape_markup(str(r.entry.description))}")
console.print(
f" [dim]source: {_escape_markup(str(r.source.id))}[/dim]"
)
console.print(f" {r.entry.description}")
console.print(f" [dim]source: {r.source.id}[/dim]")
@bundle_app.command("info")
@@ -242,16 +250,31 @@ def bundle_info(
print(_json.dumps(payload, indent=2))
return
console.print(f"\n[bold cyan]{entry.id}[/bold cyan] v{entry.version}{entry.name}")
console.print(f" Role: {entry.role}")
console.print(f" {entry.description}")
console.print(f" Author: {entry.author} License: {entry.license}")
console.print(f" Source: {resolved.source.id} ({resolved.source.install_policy.value})")
console.print(
f"\n[bold cyan]{_escape_markup(str(entry.id))}[/bold cyan] "
f"v{_escape_markup(str(entry.version))}"
f"{_escape_markup(str(entry.name))}"
)
console.print(f" Role: {_escape_markup(str(entry.role))}")
console.print(f" {_escape_markup(str(entry.description))}")
console.print(
f" Author: {_escape_markup(str(entry.author))} "
f"License: {_escape_markup(str(entry.license))}"
)
console.print(
f" Source: {_escape_markup(str(resolved.source.id))} "
f"({resolved.source.install_policy.value})"
)
console.print(f" Trust: {_trust_badge(entry.verified)}")
if entry.requires_speckit_version:
console.print(f" Requires Spec Kit: {entry.requires_speckit_version}")
console.print(
f" Requires Spec Kit: "
f"{_escape_markup(str(entry.requires_speckit_version))}"
)
if manifest and manifest.integration:
console.print(f" Integration: {manifest.integration.id}")
console.print(
f" Integration: {_escape_markup(str(manifest.integration.id))}"
)
if components:
console.print("\n [bold]Components[/bold] (added on install):")
@@ -261,18 +284,22 @@ def bundle_info(
continue
console.print(f" [bold]{kind}:[/bold]")
for item in items:
console.print(f" - {_format_component(item)}")
console.print(
f" - {_escape_markup(_format_component(item))}"
)
else:
console.print("\n [bold]Provides:[/bold]")
for kind in ("extensions", "presets", "steps", "workflows"):
count = entry.provides.get(kind, 0)
if count:
console.print(f" {kind}: {count}")
console.print(f" {kind}: {_escape_markup(str(count))}")
if overlaps:
console.print("\n [yellow]Overlaps with already-installed bundles:[/yellow]")
for overlap in overlaps:
console.print(f" [yellow]-[/yellow] {overlap}")
console.print(
f" [yellow]-[/yellow] {_escape_markup(str(overlap))}"
)
if not resolved.install_allowed:
console.print(

View File

@@ -0,0 +1,39 @@
"""specify event * command handlers."""
from __future__ import annotations
from pathlib import Path
import sys
import typer
event_app = typer.Typer(
name="event",
help="Manage and execute event-driven commands",
add_completion=False,
)
@event_app.command("run")
def event_run(
command_name: str = typer.Argument(..., help="Name of the command to execute"),
event_name: str = typer.Argument(..., help="Canonical event name (e.g., session_start)"),
timeout: int = typer.Argument(
120, help="Per-handler timeout in seconds (passed through from the native hook config)"
),
):
"""Resolve and run an event-driven command script with stdin payload."""
from ..events import resolve_and_run_event_command
# Read payload from stdin if available
payload = sys.stdin.read() if not sys.stdin.isatty() else "{}"
# Run the event command
project_root = Path.cwd() # The agent runs events from project root
exit_code = resolve_and_run_event_command(
command_name, event_name, payload, project_root, timeout=timeout
)
raise typer.Exit(code=exit_code)
def register(app: typer.Typer) -> None:
app.add_typer(event_app, name="event")

View File

@@ -30,6 +30,145 @@ def _stdin_is_interactive() -> bool:
return sys.stdin.isatty()
def _ext_spec_is_url(ext_spec: str) -> bool:
"""Return True when *ext_spec* is an http(s) URL rather than a name/path."""
from urllib.parse import urlparse
try:
return urlparse(ext_spec).scheme in ("http", "https")
except ValueError:
return False
def _confirm_extension_url_trust(
url_specs: list[str], *, trust_override: bool
) -> dict[str, bool]:
"""Resolve trust for each URL-based extension before the Live display.
URL installs pull an arbitrary external extension, so they get the same
default-deny confirmation as ``extension add --from``. Returns a mapping of
``url_spec -> approved``. With *trust_override* every URL is pre-approved.
In a non-interactive session without the override, every URL is denied
(the prompt cannot be answered), mirroring the default-deny posture.
"""
from rich.markup import escape as _escape_markup
from rich.panel import Panel
approvals: dict[str, bool] = {}
interactive = _stdin_is_interactive()
for spec in url_specs:
if trust_override:
approvals[spec] = True
continue
if not interactive:
approvals[spec] = False
continue
console.print()
console.print(
Panel(
"[bold]You are installing an extension from an external URL that is not\n"
"listed in any of your configured extension catalogs.[/bold]\n\n"
f"URL: {_escape_markup(spec)}\n\n"
"Only install extensions from sources you trust.",
title="[bold yellow]⚠ Untrusted Source[/bold yellow]",
border_style="yellow",
padding=(1, 2),
)
)
console.print()
approvals[spec] = typer.confirm(
f"Install extension from {spec}?", default=False
)
return approvals
def _install_extension_during_init(project_path: Path, ext_spec: str, speckit_version: str) -> str:
"""Install a single extension during ``specify init``.
Handles bundled extension names, local directory paths, and HTTPS URLs.
Returns a short status message on success.
Raises ``ValueError`` on failure so the caller can convert it to a
tracker error without aborting the entire init.
"""
from urllib.parse import urlparse
from .._assets import _locate_bundled_extension
from ..extensions import ExtensionCatalog, ExtensionError, ExtensionManager
from ..extensions._commands import (
_resolve_catalog_extension,
install_extension_from_url,
)
manager = ExtensionManager(project_path)
# --- URL ---
parsed = urlparse(ext_spec)
if parsed.scheme in ("http", "https"):
try:
manifest = install_extension_from_url(
manager, project_path, ext_spec, speckit_version
)
except ExtensionError as exc:
raise ValueError(str(exc)) from exc
return f"{manifest.name} v{manifest.version} installed"
# --- Local path ---
if ext_spec.startswith(("./", "../", "/", "~/", ".\\", "..\\")) or Path(ext_spec).is_absolute():
source_path = Path(ext_spec).expanduser().resolve()
if not source_path.exists():
raise ValueError(f"Directory not found: {source_path}")
if not (source_path / "extension.yml").exists():
raise ValueError(f"No extension.yml found in {source_path}")
manifest = manager.install_from_directory(source_path, speckit_version)
return f"{manifest.name} v{manifest.version} installed"
# --- Bundled extension name or catalog ID ---
bundled_path = _locate_bundled_extension(ext_spec)
if bundled_path is not None:
if manager.registry.is_installed(ext_spec):
return "already installed"
manifest = manager.install_from_directory(bundled_path, speckit_version)
return f"{manifest.name} v{manifest.version} installed"
# Fall back to catalog
catalog = ExtensionCatalog(project_path)
ext_info, catalog_error = _resolve_catalog_extension(ext_spec, catalog, "add")
if catalog_error:
raise ValueError(f"Could not query extension catalog: {catalog_error}")
if not ext_info:
raise ValueError(f"Extension '{ext_spec}' not found in bundled extensions or catalog")
resolved_id = ext_info["id"]
if resolved_id != ext_spec:
bundled_path = _locate_bundled_extension(resolved_id)
if bundled_path is not None:
if manager.registry.is_installed(resolved_id):
return "already installed"
manifest = manager.install_from_directory(bundled_path, speckit_version)
return f"{manifest.name} v{manifest.version} installed"
if ext_info.get("bundled") and not ext_info.get("download_url"):
from ..extensions import REINSTALL_COMMAND
raise ValueError(
f"Extension '{resolved_id}' is bundled with spec-kit but not found in the installed package. "
f"Try reinstalling spec-kit: {REINSTALL_COMMAND}"
)
if not ext_info.get("_install_allowed", True):
catalog_name = ext_info.get("_catalog_name", "community")
raise ValueError(
f"Extension '{ext_spec}' is in the '{catalog_name}' catalog but installation is not allowed from that catalog"
)
zip_path = catalog.download_extension(resolved_id)
try:
manifest = manager.install_from_zip(zip_path, speckit_version)
finally:
zip_path.unlink(missing_ok=True)
return f"{manifest.name} v{manifest.version} installed"
def ensure_constitution_from_template(
project_path: Path, tracker: StepTracker | None = None
) -> None:
@@ -142,6 +281,16 @@ def register(app: typer.Typer) -> None:
"--integration-options",
help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")',
),
extensions: list[str] | None = typer.Option(
None,
"--extension",
help="Install an extension during initialization (bundled name, local path, or HTTPS URL). Repeatable.",
),
trust_extension_urls: bool = typer.Option(
False,
"--trust-extension-urls",
help="Pre-authorize installing extensions from external URLs without the interactive trust prompt (required for non-interactive URL installs).",
),
):
"""
Initialize a new Specify project.
@@ -174,6 +323,10 @@ def register(app: typer.Typer) -> None:
specify init --here --integration gemini
specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/" # Bring your own agent; requires --commands-dir
specify init my-project --integration claude --preset healthcare-compliance # With preset
specify init my-project --integration copilot --extension git # With bundled extension
specify init my-project --extension git --extension selftest # Multiple extensions
specify init my-project --extension ./my-extensions/custom-ext # Local path extension
specify init my-project --extension https://example.com/extensions/my-ext.zip --trust-extension-urls # URL extension (non-interactive)
"""
# Lazy imports to avoid circular dependency — __init__.py imports this module
from .. import (
@@ -183,6 +336,7 @@ def register(app: typer.Typer) -> None:
save_init_options,
)
from ..integration_runtime import (
invoke_prefix_for_integration as _invoke_prefix_for_integration,
with_integration_setting as _with_integration_setting,
)
from ..integrations._commands import (
@@ -412,10 +566,31 @@ def register(app: typer.Typer) -> None:
("chmod", "Ensure scripts executable"),
("constitution", "Constitution setup"),
("workflow", "Install bundled workflow"),
("final", "Finalize"),
]:
tracker.add(key, label)
if extensions:
from rich.markup import escape as _escape_markup
for i, ext_spec in enumerate(extensions):
tracker.add(
f"extension-{i}", f"Install extension: {_escape_markup(ext_spec)}"
)
tracker.add("final", "Finalize")
# Resolve trust for URL-based extensions BEFORE entering the Live
# display: the confirmation prompt cannot be shown/answered underneath
# the Rich Live spinner. URL installs are default-deny unless the user
# confirms interactively or passes --trust-extension-urls.
extension_url_approvals: dict[str, bool] = {}
if extensions:
url_specs = [e for e in extensions if _ext_spec_is_url(e)]
if url_specs:
extension_url_approvals = _confirm_extension_url_trust(
url_specs, trust_override=trust_extension_urls
)
# Disable transient mode on Windows: PowerShell 5.1's legacy console
# hangs when Rich tries to restore cursor state via VT escape sequences.
_transient = sys.platform != "win32"
@@ -442,12 +617,20 @@ def register(app: typer.Typer) -> None:
if extra:
integration_parsed_options.update(extra)
from ..events import resolve_events
events_map = resolve_events(
resolved_integration.key,
resolved_integration.config,
project_path,
integration_parsed_options or None,
)
resolved_integration.setup(
project_path,
manifest,
parsed_options=integration_parsed_options or None,
script_type=selected_script,
raw_options=integration_options,
events=events_map,
)
manifest.save()
@@ -481,6 +664,12 @@ def register(app: typer.Typer) -> None:
invoke_separator=resolved_integration.effective_invoke_separator(
integration_parsed_options, project_root=project_path
),
invoke_prefix=_invoke_prefix_for_integration(
resolved_integration,
resolved_integration.key,
integration_parsed_options,
project_path,
),
)
tracker.complete(
"shared-infra", f"scripts ({selected_script}) + templates"
@@ -611,6 +800,46 @@ def register(app: typer.Typer) -> None:
continuing="Continuing without the optional preset.",
)
# Install extensions specified via --extension
if extensions:
from rich.markup import escape as _escape_markup
from ..extensions._commands import _refresh_events_and_warn
speckit_ver = get_speckit_version()
any_extension_installed = False
for i, ext_spec in enumerate(extensions):
tracker.start(f"extension-{i}")
# Skip URL extensions the user did not confirm as trusted
# (default-deny; resolved before the Live display).
if _ext_spec_is_url(ext_spec) and not extension_url_approvals.get(
ext_spec, False
):
tracker.error(
f"extension-{i}",
"skipped: untrusted URL not confirmed "
"(use --trust-extension-urls)",
)
continue
try:
status_msg = _install_extension_during_init(
project_path, ext_spec, speckit_ver
)
tracker.complete(f"extension-{i}", status_msg)
any_extension_installed = True
except Exception as ext_err:
sanitized_ext = str(ext_err).replace("\n", " ").strip()
tracker.error(
f"extension-{i}",
f"failed: {_escape_markup(sanitized_ext[:120])}",
)
# Refresh native event configuration once after the batch so
# that an extension declaring ``events:`` has its hooks
# activated, mirroring the ``extension add`` path.
if any_extension_installed:
_refresh_events_and_warn(project_path)
# Seed the constitution AFTER preset installation so that a
# preset-provided constitution-template (resolved via the
# priority stack) wins over the core template.

2098
src/specify_cli/events.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -20,7 +20,7 @@ import tempfile
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Set
from typing import Any, BinaryIO, Callable, Dict, List, Optional, Set
import pathspec
import yaml
@@ -29,11 +29,14 @@ from packaging.specifiers import InvalidSpecifier, SpecifierSet
from .._assets import _locate_core_pack, _repo_root
from .._download_security import (
archive_format_from_name,
archive_suffix,
MAX_JSON_CATALOG_BYTES,
build_safe_download_path,
detect_archive_format,
is_https_or_localhost_http,
read_response_limited,
safe_extract_zip,
safe_extract_archive,
)
from .._init_options import is_ai_skills_enabled
from .._invocation_style import is_dollar_skills_agent, is_slash_skills_agent
@@ -263,8 +266,25 @@ class ExtensionManifest:
f"(expected {self.SCHEMA_VERSION})"
)
# The REQUIRED_FIELDS loop above only checks key PRESENCE, so a section
# that is written but left empty (``provides:`` -> None) or given the
# wrong shape (``provides: []``) passes it and then fails on first use:
# ``field not in None`` raises TypeError and ``None.get(...)`` raises
# AttributeError. Neither is a ValidationError, so both escape the
# callers that already handle malformed manifests -- list_installed()'s
# "Corrupted extension" fallback catches ValidationError only, so one bad
# extension made ``specify extension list`` exit 1 with a raw
# AttributeError instead of listing the rest. Guard each required
# section's shape, mirroring the nested guards below ("Invalid
# provides.commands: expected a list", "Invalid hooks: expected a
# mapping") and _load_yaml's document-root check.
# Validate extension metadata
ext = self.data["extension"]
if not isinstance(ext, dict):
raise ValidationError(
f"Invalid extension: expected a mapping, got {type(ext).__name__}"
)
for field in ["id", "name", "version", "description"]:
if field not in ext:
raise ValidationError(f"Missing extension.{field}")
@@ -299,24 +319,37 @@ class ExtensionManifest:
# Validate requires section
requires = self.data["requires"]
if not isinstance(requires, dict):
raise ValidationError(
f"Invalid requires: expected a mapping, got {type(requires).__name__}"
)
if "speckit_version" not in requires:
raise ValidationError("Missing requires.speckit_version")
# Validate provides section
provides = self.data["provides"]
if not isinstance(provides, dict):
raise ValidationError(
f"Invalid provides: expected a mapping, got {type(provides).__name__}"
)
commands = provides.get("commands", [])
hooks = self.data.get("hooks")
events = self.data.get("events")
if "commands" in provides and not isinstance(commands, list):
raise ValidationError("Invalid provides.commands: expected a list")
if "hooks" in self.data and not isinstance(hooks, dict):
raise ValidationError("Invalid hooks: expected a mapping")
if "events" in self.data:
from ..events import validate_events
validate_events(self.data)
has_commands = bool(commands)
has_hooks = bool(hooks)
has_events = bool(events)
if not has_commands and not has_hooks:
raise ValidationError("Extension must provide at least one command or hook")
if not has_commands and not has_hooks and not has_events:
raise ValidationError("Extension must provide at least one command, hook, or event")
# Validate hook values (if present).
# Each event is a single mapping or a list of mappings.
@@ -440,6 +473,33 @@ class ExtensionManifest:
f"The extension author should update the manifest."
)
# C11: apply the same rename + alias-lift canonicalization to event
# command references. Without this, an event referencing a command
# that was auto-corrected (e.g. speckit.boot -> speckit.<id>.boot)
# keeps the obsolete name, dispatch reports no command, and the event
# silently no-ops.
events_data = self.data.get("events", {})
if isinstance(events_data, dict):
for event_name, event_config in events_data.items():
if not isinstance(event_config, dict):
continue
command_ref = event_config.get("command")
if not isinstance(command_ref, str):
continue
after_rename = rename_map.get(command_ref, command_ref)
parts = after_rename.split(".")
if len(parts) == 2 and parts[0] == ext["id"]:
final_ref = f"speckit.{ext['id']}.{parts[1]}"
else:
final_ref = after_rename
if final_ref != command_ref:
event_config["command"] = final_ref
self.warnings.append(
f"Event '{event_name}' referenced command '{command_ref}'; "
f"updated to canonical form '{final_ref}'. "
f"The extension author should update the manifest."
)
@staticmethod
def _try_correct_command_name(name: str, ext_id: str) -> Optional[str]:
"""Try to auto-correct a non-conforming command name to the required pattern.
@@ -509,8 +569,11 @@ class ExtensionManifest:
def get_hash(self) -> str:
"""Calculate SHA256 hash of manifest file."""
h = hashlib.sha256()
with open(self.path, "rb") as f:
return f"sha256:{hashlib.sha256(f.read()).hexdigest()}"
for chunk in iter(lambda: f.read(8192), b""):
h.update(chunk)
return f"sha256:{h.hexdigest()}"
class ExtensionRegistry:
@@ -535,7 +598,7 @@ class ExtensionRegistry:
return {"schema_version": self.SCHEMA_VERSION, "extensions": {}}
try:
with open(self.registry_path, "r") as f:
with open(self.registry_path, "r", encoding="utf-8") as f:
data = json.load(f)
# Validate loaded data is a dict (handles corrupted registry files)
if not isinstance(data, dict):
@@ -551,7 +614,7 @@ class ExtensionRegistry:
def _save(self):
"""Save registry to disk."""
self.extensions_dir.mkdir(parents=True, exist_ok=True)
with open(self.registry_path, "w") as f:
with open(self.registry_path, "w", encoding="utf-8") as f:
json.dump(self.data, f, indent=2)
def add(self, extension_id: str, metadata: dict):
@@ -1267,6 +1330,7 @@ class ExtensionManager:
manifest: ExtensionManifest,
extension_dir: Path,
link_outputs: bool = False,
force: bool = False,
) -> List[str]:
"""Generate SKILL.md files for extension commands as agent skills.
@@ -1280,6 +1344,11 @@ class ExtensionManager:
extension_dir: Installed extension directory.
link_outputs: If True, create dev-mode symlinks for rendered
skill files when supported by the OS.
force: If True, overwrite existing SKILL.md files even when they
are not dev-mode symlinks. Use in the upgrade path, where
``setup()`` has just freshly regenerated core-template skill
files and the skip guard would otherwise prevent extension
content from being layered on top.
Returns:
List of skill names that were created (for registry storage).
@@ -1367,13 +1436,16 @@ class ExtensionManager:
)
# Do not overwrite user-customized skills, but allow dev-mode
# symlinks that point back to this extension's generated cache
# to be refreshed on a subsequent dev install.
if not is_expected_dev_symlink:
# to be refreshed on a subsequent dev install. In the upgrade
# path (force=True) the file was just written by setup(), so
# overwriting it with the composed extension content is correct.
if not is_expected_dev_symlink and not force:
continue
elif skill_dir_preexists:
elif skill_dir_preexists and not force:
# Never add files to a pre-existing user directory. Without a
# verifiable SKILL.md ownership marker, rollback/removal cannot
# distinguish our output from unrelated user artifacts.
# Skipped when force=True (upgrade path).
continue
# Create skill directory; track whether we created it so we can clean
@@ -2334,7 +2406,7 @@ class ExtensionManager:
pass # Best-effort; install already committed to the registry.
# Restore execute bits on shipped POSIX scripts. copytree here (and the
# zipfile.extractall in install_from_zip, which delegates to this method) does
# archive extraction in install_from_archive, which delegates here, does
# not restore a stripped Unix mode, so a bundled *.sh would land non-executable
# and a documented `.specify/extensions/<id>/scripts/...` invocation would fail
# with "Permission denied". This is the single sink every install route funnels
@@ -2353,21 +2425,27 @@ class ExtensionManager:
return manifest
def install_from_zip(
def install_from_archive(
self,
zip_path: Path,
archive_path: Path,
speckit_version: str,
priority: int = 10,
force: bool = False,
*,
archive_file: BinaryIO | None = None,
source_name: str | None = None,
content_type: str | None = None,
) -> ExtensionManifest:
"""Install extension from ZIP file.
"""Install an extension from a supported archive.
Args:
zip_path: Path to extension ZIP file
archive_path: Path to a .zip, .tar.gz, or .tgz archive
speckit_version: Current spec-kit version
priority: Resolution priority (lower = higher precedence, default 10)
force: If True and extension is already installed, remove it first
before proceeding with installation
archive_file: Already-open archive stream to consume instead of
reopening ``zip_path``
Returns:
Installed extension manifest
@@ -2383,7 +2461,14 @@ class ExtensionManager:
with tempfile.TemporaryDirectory() as tmpdir:
temp_path = Path(tmpdir)
safe_extract_zip(zip_path, temp_path, error_type=ValidationError)
safe_extract_archive(
archive_path,
temp_path,
archive_file=archive_file,
source_name=source_name,
content_type=content_type,
error_type=ValidationError,
)
# Find extension directory (may be nested)
extension_dir = temp_path
@@ -2397,13 +2482,35 @@ class ExtensionManager:
manifest_path = extension_dir / "extension.yml"
if not manifest_path.exists():
raise ValidationError("No extension.yml found in ZIP file")
raise ValidationError("No extension.yml found in archive")
# Install from extracted directory
return self.install_from_directory(
extension_dir, speckit_version, priority=priority, force=force
)
def install_from_zip(
self,
zip_path: Path,
speckit_version: str,
priority: int = 10,
force: bool = False,
*,
archive_file: BinaryIO | None = None,
source_name: str | None = None,
content_type: str | None = None,
) -> ExtensionManifest:
"""Backward-compatible wrapper for archive installation."""
return self.install_from_archive(
zip_path,
speckit_version,
priority=priority,
force=force,
archive_file=archive_file,
source_name=source_name,
content_type=content_type,
)
def remove(self, extension_id: str, keep_config: bool = False) -> bool:
"""Remove an installed extension.
@@ -2612,7 +2719,7 @@ class ExtensionManager:
if updates:
self.registry.update(ext_id, updates)
def register_enabled_extensions_for_agent(self, agent_name: str) -> None:
def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool = False) -> None:
"""Register installed, enabled extensions for ``agent_name``.
Command-file registration is scoped to the explicit ``agent_name``
@@ -2730,7 +2837,7 @@ class ExtensionManager:
if agent_name == active_agent:
try:
registered_skills = self._register_extension_skills(
manifest, ext_dir
manifest, ext_dir, force=force
)
except Exception as skills_err:
# Skills are a companion artifact. If command registration
@@ -3721,14 +3828,14 @@ class ExtensionCatalog(CatalogStackBase):
def download_extension(
self, extension_id: str, target_dir: Optional[Path] = None
) -> Path:
"""Download extension ZIP from catalog.
"""Download an extension archive from a catalog.
Args:
extension_id: ID of the extension to download
target_dir: Directory to save ZIP file (defaults to temp directory)
target_dir: Directory to save the archive
Returns:
Path to downloaded ZIP file
Path to the downloaded archive
Raises:
ExtensionError: If extension not found or download fails
@@ -3787,52 +3894,93 @@ class ExtensionCatalog(CatalogStackBase):
target_dir = self.cache_dir / "downloads"
target_dir = Path(target_dir)
version = ext_info.get("version", "unknown")
zip_path = build_safe_download_path(
declared_format = archive_format_from_name(download_url)
build_safe_download_path(
target_dir,
extension_id,
version,
error_type=ExtensionError,
label="extension",
suffix=archive_suffix(declared_format or "tar.gz"),
)
target_dir.mkdir(parents=True, exist_ok=True)
original_download_url = download_url
extra_headers = None
resolved_download_url = self._resolve_github_release_asset_api_url(download_url)
if resolved_download_url:
download_url = resolved_download_url
extra_headers = {"Accept": "application/octet-stream"}
# Download the ZIP file
staging_path: Path | None = None
try:
with self._open_url(
download_url, timeout=60, extra_headers=extra_headers
) as response:
zip_data = read_response_limited(
archive_data = read_response_limited(
response,
error_type=ExtensionError,
label=f"extension '{extension_id}' download",
)
final_url = (
response.geturl()
if hasattr(response, "geturl")
else download_url
)
content_type = (
response.getheader("Content-Type")
if hasattr(response, "getheader")
else None
)
verify_archive_sha256(
zip_data, ext_info.get("sha256"), extension_id, ExtensionError
archive_data, ext_info.get("sha256"), extension_id, ExtensionError
)
zip_path.write_bytes(zip_data)
return zip_path
with tempfile.NamedTemporaryFile(
prefix="extension-download-",
suffix=".archive",
dir=target_dir,
delete=False,
) as staging_file:
staging_path = Path(staging_file.name)
staging_file.write(archive_data)
archive_format = detect_archive_format(
staging_path,
source_name=(
final_url
if archive_format_from_name(final_url) is not None
else original_download_url
),
content_type=content_type,
error_type=ExtensionError,
)
archive_path = build_safe_download_path(
target_dir,
extension_id,
version,
error_type=ExtensionError,
label="extension",
suffix=archive_suffix(archive_format),
)
os.replace(staging_path, archive_path)
staging_path = None
return archive_path
except urllib.error.URLError as e:
raise ExtensionError(
f"Failed to download extension from {download_url}: {e}"
)
except IOError as e:
raise ExtensionError(f"Failed to save extension ZIP: {e}")
raise ExtensionError(f"Failed to save extension archive: {e}")
finally:
if staging_path is not None:
staging_path.unlink(missing_ok=True)
def clear_cache(self):
"""Clear the catalog cache (both legacy and URL-hash-based files)."""
if self.cache_file.exists():
self.cache_file.unlink()
if self.cache_metadata_file.exists():
self.cache_metadata_file.unlink()
self.cache_file.unlink(missing_ok=True)
self.cache_metadata_file.unlink(missing_ok=True)
# Also clear any per-URL hash-based cache files
if self.cache_dir.exists():
for extra_cache in self.cache_dir.glob("catalog-*.json"):

View File

@@ -8,11 +8,12 @@ which re-fetch from the parent package at call time so test monkeypatching of
"""
from __future__ import annotations
import errno
import hashlib
import os
import shutil
import stat
import tempfile
import zipfile
from pathlib import Path
from typing import Optional
from uuid import uuid4
@@ -26,12 +27,11 @@ from rich.table import Table
from .._console import console
from .._assets import get_speckit_version
from .._download_security import (
archive_format_from_name,
detect_archive_format,
is_https_or_localhost_http,
normalize_zip_member_name,
open_zip_bounded,
portable_zip_path_key,
read_response_limited,
read_zip_member_limited,
safe_extract_archive,
)
from .._init_options import is_ai_skills_enabled
@@ -71,6 +71,165 @@ def _display_project_path(*args, **kwargs):
return _f(*args, **kwargs)
def _refresh_events_and_warn(project_root: Path) -> None:
"""Refresh native event config and surface failures (R3).
The extension has already been added/removed/enabled/disabled by the time
this runs, so a refresh failure must not abort the command — but it must
be surfaced, because a stale native hook may still be active (e.g. a
disabled extension's hook still resolves and runs). Prints a warning with
the per-integration failures so the user knows deactivation was incomplete.
"""
from ..events import EventRefreshError, refresh_integration_events
try:
refresh_integration_events(project_root)
except EventRefreshError as exc:
console.print(
f"\n[yellow]⚠[/yellow] Extension updated, but event refresh failed "
f"for {len(exc.failures)} integration(s); a stale native hook may "
f"still be active. Re-run [cyan]specify integration upgrade "
f"<key>[cyan][/cyan][/cyan] to retry."
)
for key, detail in exc.failures:
console.print(f" {key}: {_escape_markup(detail)}")
def install_extension_from_url(
manager,
project_root: Path,
url: str,
speckit_version: str,
*,
priority: int = 10,
force: bool = False,
):
"""Download an archive from *url* and install it, reusing the hardened path.
Shares the same download hardening as ``extension add --from``:
HTTPS enforcement, the catalog's authenticated + redirect-guarded
``_open_url`` fetch, a bounded (50 MiB) response read, archive-format
detection (ZIP or tar.gz/tgz), and a TOCTOU-safe transient download file
consumed directly by ``install_from_zip``.
Returns the installed manifest. Raises ``ExtensionError`` on any failure so
callers can present a uniform message without a second downloader.
"""
import urllib.error
from . import ExtensionCatalog, ExtensionError
if not is_https_or_localhost_http(url):
raise ExtensionError(
"URL must use HTTPS (HTTP is only allowed for localhost)"
)
download_dir = _validate_safe_cache_dir(project_root)
archive_filename = f"extension-url-download-{uuid4().hex}.archive"
# Only used for diagnostic messages: the real archive is a transient inode
# (unlinked on POSIX, O_TEMPORARY on Windows) consumed via ``archive_file``
# below, so this path is never opened again.
archive_path = download_dir / archive_filename
try:
dl_catalog = ExtensionCatalog(project_root)
download_url = url
extra_headers = None
resolved_url = dl_catalog._resolve_github_release_asset_api_url(download_url)
if resolved_url:
download_url = resolved_url
extra_headers = {"Accept": "application/octet-stream"}
with dl_catalog._open_url(
download_url, timeout=60, extra_headers=extra_headers
) as response:
archive_data = read_response_limited(
response,
error_type=ExtensionError,
label=f"extension {url}",
)
final_url = (
response.geturl() if hasattr(response, "geturl") else download_url
)
content_type = (
response.getheader("Content-Type")
if hasattr(response, "getheader")
else None
)
except urllib.error.URLError as exc:
raise ExtensionError(f"Failed to download from {url}: {exc}") from exc
download_fd = -1
download_file = None
try:
try:
download_fd = _safe_open_download_zip(
project_root, download_dir, archive_filename
)
except OSError as exc:
raise ExtensionError(
f"Could not safely create download file: {exc}"
) from exc
try:
download_file = os.fdopen(download_fd, "w+b")
download_fd = -1
download_file.write(archive_data)
download_file.flush()
download_file.seek(0)
except OSError as exc:
raise ExtensionError(
f"Could not safely write download file: {exc}"
) from exc
format_source = (
final_url
if archive_format_from_name(final_url) is not None
else url
)
try:
detect_archive_format(
archive_path,
archive_file=download_file,
source_name=format_source,
content_type=content_type,
error_type=ExtensionError,
)
except ExtensionError as exc:
raise ExtensionError(
f"{url} did not return a ZIP archive or tar.gz/tgz archive "
f"(got {len(archive_data)} bytes). This usually means the request "
"was not authenticated and a login/HTML page was returned. "
"Verify the URL and configured credentials."
) from exc
# Consume the transient inode reserved above rather than reopening the
# cache pathname during extraction.
try:
return manager.install_from_zip(
archive_path,
speckit_version,
priority=priority,
force=force,
archive_file=download_file,
)
except OSError as exc:
raise ExtensionError(
f"Could not install extension from downloaded archive: {exc}"
) from exc
finally:
if download_file is not None:
try:
download_file.close()
except OSError:
pass
elif download_fd >= 0:
try:
os.close(download_fd)
except OSError:
pass
def _load_catalog_command_config(project_root: Path, config_path: Path) -> dict:
"""Load extension catalog CLI config with user-facing shape errors."""
try:
@@ -413,6 +572,278 @@ def catalog_remove(
console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]")
# Relative path, below the project root, of the extension URL download cache.
_CACHE_REL_PARTS = (".specify", "extensions", ".cache", "downloads")
def _has_secure_dir_fd() -> bool:
"""Whether this platform supports the strongest (POSIX) hardening path.
The descriptor-anchored walk needs ``O_NOFOLLOW`` plus ``dir_fd`` support
for ``os.open``/``os.mkdir``/``os.unlink``. When any of those is missing
(notably on Windows) the caller falls back to the portable path-wise walk,
which reproduces the same guarantees using symlink/reparse-point rejection,
resolve-under-root containment checks, and post-open inode-identity
verification instead of file descriptors.
"""
return bool(
getattr(os, "O_NOFOLLOW", 0)
and os.open in os.supports_dir_fd
and os.mkdir in os.supports_dir_fd
and os.unlink in os.supports_dir_fd
)
def _is_symlink_refusal_errno(exc: OSError) -> bool:
"""Whether an ``os.open``/``os.mkdir`` error means a component is a symlink.
Opening an ``O_NOFOLLOW`` path whose final component is a symlink raises
``ELOOP`` on Linux and ``EMLINK`` on some BSDs, while a symlinked component
that no longer resolves to a directory surfaces as ``ENOTDIR``.
"""
return exc.errno in (errno.ELOOP, errno.ENOTDIR, getattr(errno, "EMLINK", -1))
def _verify_leaf_identity(fd: int, path: Path) -> None:
"""Confirm ``fd`` still refers to the regular file at ``path``.
Mirrors the workflow installer's staged-file check: comparing the open
descriptor's ``fstat`` against a ``lstat`` of the pathname detects a leaf
that was swapped for a symlink/reparse point between creation and use, so
the portable (dir_fd-less) path is not vulnerable to an ancestor swap race.
"""
path_stat = path.stat(follow_symlinks=False)
open_stat = os.fstat(fd)
if (
not stat.S_ISREG(path_stat.st_mode)
or path_stat.st_dev != open_stat.st_dev
or path_stat.st_ino != open_stat.st_ino
):
raise OSError(
errno.ENOTDIR, "Download file changed between creation and open"
)
def _validate_safe_cache_dir(project_root: Path) -> Path:
"""Create and validate the extension URL download cache one component at a
time, refusing symlinked/junctioned components on every supported platform."""
download_dir = project_root.joinpath(*_CACHE_REL_PARTS)
try:
if _has_secure_dir_fd():
_validate_cache_dir_via_dir_fd(project_root, download_dir)
else:
_validate_cache_dir_via_paths(project_root, download_dir)
except typer.Exit:
raise
except FileExistsError:
console.print(
"[red]Error:[/red] Refusing to use symlinked download cache directory"
)
raise typer.Exit(1)
except OSError as exc:
if _is_symlink_refusal_errno(exc):
console.print(
"[red]Error:[/red] Refusing to use symlinked download cache directory"
)
raise typer.Exit(1)
console.print(
"[red]Error:[/red] Could not prepare download cache directory: "
f"{_escape_markup(str(exc))}"
)
raise typer.Exit(1)
return download_dir
def _validate_cache_dir_via_dir_fd(project_root: Path, download_dir: Path) -> None:
"""POSIX cache-dir walk anchored on ``dir_fd`` + ``O_NOFOLLOW`` descriptors."""
o_nofollow = getattr(os, "O_NOFOLLOW", 0)
o_directory = getattr(os, "O_DIRECTORY", 0)
o_cloexec = getattr(os, "O_CLOEXEC", 0)
walk_flags = os.O_RDONLY | o_directory | o_nofollow | o_cloexec
project_root_resolved = project_root.resolve()
parent_fd = os.open(project_root, walk_flags)
current_path = project_root
try:
for part in _CACHE_REL_PARTS:
current_path = current_path / part
try:
child_fd = os.open(part, walk_flags, dir_fd=parent_fd)
except FileNotFoundError:
try:
os.mkdir(part, dir_fd=parent_fd)
except FileExistsError:
pass
child_fd = os.open(part, walk_flags, dir_fd=parent_fd)
try:
current_path.resolve().relative_to(project_root_resolved)
except (OSError, ValueError):
try:
os.close(child_fd)
except OSError:
pass
console.print(
"[red]Error:[/red] Download cache directory escapes project root"
)
raise typer.Exit(1)
os.close(parent_fd)
parent_fd = child_fd
finally:
if parent_fd >= 0:
try:
os.close(parent_fd)
except OSError:
pass
def _validate_cache_dir_via_paths(project_root: Path, download_dir: Path) -> None:
"""Portable cache-dir walk for platforms without ``dir_fd`` (e.g. Windows).
Each component is created individually while a symlink/junction is rejected
both before and after creation, and every component is required to resolve
back under the project root so a mount-point alias or reparse point cannot
redirect the cache outside the project.
"""
project_root_resolved = project_root.resolve()
current_path = project_root
for part in _CACHE_REL_PARTS:
current_path = current_path / part
if current_path.is_symlink():
console.print(
"[red]Error:[/red] Refusing to use symlinked download cache directory"
)
raise typer.Exit(1)
try:
current_path.mkdir()
except FileExistsError:
pass
# Re-check after creation: a component swapped for a symlink/junction
# (or an existing non-directory) between the check and mkdir is caught
# here before the walk descends into it.
if current_path.is_symlink() or not current_path.is_dir():
console.print(
"[red]Error:[/red] Refusing to use symlinked download cache directory"
)
raise typer.Exit(1)
try:
current_path.resolve().relative_to(project_root_resolved)
except (OSError, ValueError):
console.print(
"[red]Error:[/red] Download cache directory escapes project root"
)
raise typer.Exit(1)
def _safe_open_download_zip(
project_root: Path, download_dir: Path, zip_filename: str
) -> int:
"""Exclusively create a download ZIP and return an owned descriptor.
The archive never persists as a nameable on-disk file: the POSIX path
unlinks the leaf immediately after exclusive creation (anonymous inode),
while the portable path opens it with ``O_TEMPORARY`` so the OS deletes it
when the last handle closes. Installation proceeds entirely through the
returned descriptor, removing the pathname-reopen and cleanup-walk TOCTOU
classes on every supported platform.
"""
if _has_secure_dir_fd():
return _open_download_zip_via_dir_fd(
project_root, download_dir, zip_filename
)
return _open_download_zip_via_paths(project_root, download_dir, zip_filename)
def _open_download_zip_via_dir_fd(
project_root: Path, download_dir: Path, zip_filename: str
) -> int:
"""POSIX leaf create: descriptor walk, ``O_EXCL`` create, immediate unlink."""
o_nofollow = getattr(os, "O_NOFOLLOW", 0)
o_directory = getattr(os, "O_DIRECTORY", 0)
o_cloexec = getattr(os, "O_CLOEXEC", 0)
walk_flags = os.O_RDONLY | o_directory | o_nofollow | o_cloexec
rel_parts = download_dir.relative_to(project_root).parts
parent_fd = os.open(project_root, walk_flags)
try:
for part in rel_parts:
new_fd = os.open(part, walk_flags, dir_fd=parent_fd)
os.close(parent_fd)
parent_fd = new_fd
download_fd = os.open(
zip_filename,
os.O_RDWR | os.O_CREAT | os.O_EXCL | o_nofollow | o_cloexec,
0o600,
dir_fd=parent_fd,
)
try:
os.unlink(zip_filename, dir_fd=parent_fd)
except OSError:
os.close(download_fd)
raise
return download_fd
finally:
os.close(parent_fd)
def _open_download_zip_via_paths(
project_root: Path, download_dir: Path, zip_filename: str
) -> int:
"""Portable leaf create for platforms without ``dir_fd`` (e.g. Windows).
The cache directory is re-validated (real directory, under the project
root) immediately before an exclusive create. ``O_EXCL`` guarantees an
attacker cannot pre-stage the leaf as a symlink/junction, ``O_TEMPORARY``
makes the OS delete it on close, and a post-open inode-identity check
detects a leaf swapped underneath us. The returned descriptor is the only
handle installation ever uses, so the cache pathname is never reopened.
"""
zip_path = download_dir / zip_filename
project_root_resolved = project_root.resolve()
if download_dir.is_symlink() or not download_dir.is_dir():
raise OSError(
errno.ENOTDIR, "Download cache directory is not a real directory"
)
try:
download_dir.resolve().relative_to(project_root_resolved)
except (OSError, ValueError):
raise OSError(errno.ENOTDIR, "Download cache directory escapes project root")
if zip_path.is_symlink():
raise OSError(errno.ELOOP, "Refusing to write through a symlinked download file")
flags = os.O_RDWR | os.O_CREAT | os.O_EXCL
flags |= getattr(os, "O_NOFOLLOW", 0)
flags |= getattr(os, "O_CLOEXEC", 0)
flags |= getattr(os, "O_BINARY", 0)
o_temporary = getattr(os, "O_TEMPORARY", 0)
flags |= o_temporary
download_fd = os.open(zip_path, flags, 0o600)
try:
_verify_leaf_identity(download_fd, zip_path)
except OSError:
os.close(download_fd)
# Without O_TEMPORARY the leaf is not auto-deleted, so remove the file
# we just exclusively created (best effort, never through a symlink).
if not o_temporary:
try:
if not zip_path.is_symlink():
zip_path.unlink()
except OSError:
pass
raise
return download_fd
@extension_app.command("add")
def extension_add(
extension: str = typer.Argument(help="Extension name or path"),
@@ -514,69 +945,19 @@ def extension_add(
)
elif from_url:
# Install from URL (ZIP file)
import io
import urllib.error
# Install from URL archive via the shared hardened downloader
# (HTTPS enforcement, authenticated redirect-guarded fetch,
# bounded read, archive-format detection, TOCTOU-safe transient
# archive). Same path used by ``specify init --extension <url>``.
console.print(f"Downloading from {safe_url}...")
# Download ZIP to temp location
download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads"
download_dir.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
prefix="extension-url-download-",
suffix=".zip",
dir=download_dir,
delete=False,
) as download_file:
zip_path = Path(download_file.name)
try:
# Use the catalog's authenticated fetch so configured
# credentials (incl. GitHub Enterprise Server) are applied
# and GHES release-asset URLs resolve via /api/v3 — keeping
# --from consistent with catalog-based installs.
dl_catalog = ExtensionCatalog(project_root)
download_url = from_url
extra_headers = None
resolved_url = dl_catalog._resolve_github_release_asset_api_url(download_url)
if resolved_url:
download_url = resolved_url
extra_headers = {"Accept": "application/octet-stream"}
with dl_catalog._open_url(
download_url, timeout=60, extra_headers=extra_headers
) as response:
zip_data = read_response_limited(
response,
error_type=ExtensionError,
label=f"extension {from_url}",
)
if not zipfile.is_zipfile(io.BytesIO(zip_data)):
console.print(
f"[red]Error:[/red] {safe_url} did not return a ZIP archive "
f"(got {len(zip_data)} bytes). This usually means the request "
f"was not authenticated and a login/HTML page was returned. "
f"Verify the URL is correct and that credentials for its host "
f"are configured in ~/.specify/auth.json."
)
raise typer.Exit(1)
zip_path.write_bytes(zip_data)
# Install from downloaded ZIP
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority, force=force)
except urllib.error.URLError as e:
console.print(
f"[red]Error:[/red] Failed to download from {safe_url}: "
f"{_escape_markup(str(e))}"
)
raise typer.Exit(1)
finally:
# Clean up downloaded ZIP
if zip_path.exists():
zip_path.unlink()
manifest = install_extension_from_url(
manager,
project_root,
from_url,
speckit_version,
priority=priority,
force=force,
)
else:
# Try bundled extensions first (shipped with spec-kit)
@@ -636,23 +1017,30 @@ def extension_add(
)
raise typer.Exit(1)
# Download extension ZIP (use resolved ID, not original argument which may be display name)
# Download extension archive (use the resolved catalog ID).
extension_id = ext_info['id']
console.print(f"Downloading {_escape_markup(str(ext_info['name']))} v{_escape_markup(str(ext_info.get('version', 'unknown')))}...")
zip_path = catalog.download_extension(extension_id)
archive_path = catalog.download_extension(extension_id)
try:
# Install from downloaded ZIP
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority, force=force)
manifest = manager.install_from_zip(
archive_path,
speckit_version,
priority=priority,
force=force,
)
finally:
# Clean up downloaded ZIP
if zip_path.exists():
zip_path.unlink()
if archive_path.exists():
archive_path.unlink()
console.print("\n[green]✓[/green] Extension installed successfully!")
console.print(f"\n[bold]{_escape_markup(str(manifest.name))}[/bold] (v{_escape_markup(str(manifest.version))})")
console.print(f" {_escape_markup(str(manifest.description))}")
# #1: regenerate native event config for installed event-capable
# integrations so the new extension's events take effect immediately.
_refresh_events_and_warn(project_root)
for warning in manifest.warnings:
console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {_escape_markup(str(warning))}")
@@ -759,6 +1147,10 @@ def extension_remove(
console.print(f"\nConfig files preserved in .specify/extensions/{safe_extension_id}/")
else:
console.print(f"\nConfig files backed up to .specify/extensions/.backup/{safe_extension_id}/")
# #1: regenerate native event config so the removed extension's events
# are stripped from installed integrations.
_refresh_events_and_warn(project_root)
console.print(f"\nTo reinstall: specify extension add {safe_extension_id}")
else:
console.print("[red]Error:[/red] Failed to remove extension")
@@ -801,8 +1193,9 @@ def extension_search(
# Metadata
console.print(f"\n [dim]Author:[/dim] {_escape_markup(str(ext.get('author', 'Unknown')))}")
if ext.get('tags'):
tags_str = ", ".join(str(t) for t in ext['tags'])
ext_tags = ext.get('tags', [])
if isinstance(ext_tags, list) and ext_tags:
tags_str = ", ".join(str(t) for t in ext_tags)
console.print(f" [dim]Tags:[/dim] {_escape_markup(tags_str)}")
# Source catalog
@@ -849,7 +1242,7 @@ def extension_search(
console.print(f"\n [yellow]⚠[/yellow] Not directly installable from '{catalog_name}'.")
console.print(
f" Add to an approved catalog with install_allowed: true, "
f"or install from a ZIP URL: specify extension add {safe_id} --from <zip-url>"
f"or install from an archive URL: specify extension add {safe_id} --from <archive-url>"
)
console.print()
@@ -1025,8 +1418,9 @@ def _print_extension_info(ext_info: dict, manager):
console.print()
# Tags
if ext_info.get('tags'):
tags_str = ", ".join(str(t) for t in ext_info['tags'])
info_tags = ext_info.get('tags', [])
if isinstance(info_tags, list) and info_tags:
tags_str = ", ".join(str(t) for t in info_tags)
console.print(f"[bold]Tags:[/bold] {_escape_markup(tags_str)}")
console.print()
@@ -1476,131 +1870,105 @@ def extension_update(
backup_hooks[hook_name] = ext_hooks
# 5. Download new version
zip_path = catalog.download_extension(extension_id)
archive_path = catalog.download_extension(extension_id)
try:
# 6. Validate extension ID from ZIP BEFORE modifying installation
# Handle both root-level and nested extension.yml (GitHub auto-generated ZIPs)
with open_zip_bounded(zip_path) as zf:
import yaml
manifest_data = None
manifest_bytes = None
namelist = zf.namelist()
# Read the manifest under a hard size cap: this happens
# before install_from_zip()'s safe_extract_zip(), so a
# raw zf.open().read() here would bypass that bound and
# let a zip-bomb extension.yml exhaust memory.
# Normalize separators before choosing the manifest so
# this pre-scan cannot approve one entry while extraction
# later overwrites it with a backslash alias.
manifest_candidates = []
archive_entries = []
for name in namelist:
normalized_name = normalize_zip_member_name(name)
parts = normalized_name.removesuffix("/").split(
"/"
)
path_key = portable_zip_path_key(normalized_name)
archive_entries.append(
(normalized_name, parts)
)
# 6. Validate the archive and extension ID before modifying
# the existing installation. The shared extractor applies
# the same bounded security checks to ZIP and tar archives.
with tempfile.TemporaryDirectory(
prefix="speckit-update-archive-"
) as archive_tmpdir:
extracted_root = Path(archive_tmpdir)
try:
safe_extract_archive(archive_path, extracted_root)
except ValueError as exc:
if (
len(parts) in {1, 2}
and path_key[-1] == "extension.yml"
"Conflicting path" in str(exc)
and "extension.yml" in str(exc).casefold()
):
manifest_candidates.append(
(name, normalized_name, path_key)
)
seen_manifest_keys = {}
for name, _normalized_name, path_key in manifest_candidates:
previous = seen_manifest_keys.get(path_key)
if previous is not None:
raise ValueError(
"Downloaded extension archive contains multiple "
"extension.yml manifests"
)
seen_manifest_keys[path_key] = name
for _name, normalized_name, _path_key in manifest_candidates:
if normalized_name.split("/")[-1] != "extension.yml":
raise ValueError(
"Downloaded extension archive manifest "
"filenames must use canonical "
"'extension.yml' casing"
)
root_manifest = next(
) from exc
raise
manifest_root = extracted_root
top_level = list(extracted_root.iterdir())
root_manifest_entries = [
entry
for entry in top_level
if entry.name.casefold() == "extension.yml"
]
if any(
entry.name != "extension.yml"
for entry in root_manifest_entries
):
raise ValueError(
"Archive must use canonical 'extension.yml' casing"
)
canonical_root_manifest = next(
(
name
for name, _normalized_name, path_key
in manifest_candidates
if path_key == ("extension.yml",)
entry
for entry in root_manifest_entries
if entry.name == "extension.yml"
),
None,
)
nested_manifests = [
(name, normalized_name)
for name, normalized_name, path_key
in manifest_candidates
if len(path_key) == 2
and path_key[-1] == "extension.yml"
]
manifest_path = root_manifest
if manifest_path is None and len(nested_manifests) == 1:
manifest_path, normalized_manifest_path = (
nested_manifests[0]
)
manifest_root = normalized_manifest_path.split(
"/", 1
)[0]
top_level_dirs = {
parts[0]
for normalized_name, parts in archive_entries
if (
len(parts) > 1
or normalized_name.endswith("/")
)
}
if top_level_dirs != {manifest_root}:
if canonical_root_manifest is not None:
manifest_path = canonical_root_manifest
else:
top_level_dirs = [
entry for entry in top_level if entry.is_dir()
]
if len(top_level_dirs) != 1:
raise ValueError(
"Downloaded extension archive with a "
"nested extension.yml must contain exactly "
"Downloaded extension archive must contain exactly "
"one top-level directory"
)
if manifest_path is not None:
manifest_bytes = read_zip_member_limited(
zf, manifest_path
manifest_root = top_level_dirs[0]
nested_manifest_entries = [
entry
for entry in manifest_root.iterdir()
if entry.name.casefold() == "extension.yml"
]
if any(
entry.name != "extension.yml"
for entry in nested_manifest_entries
):
raise ValueError(
"Archive must use canonical 'extension.yml' casing"
)
manifest_path = next(
(
entry
for entry in nested_manifest_entries
if entry.name == "extension.yml"
),
manifest_root / "extension.yml",
)
parsed_manifest = yaml.safe_load(
manifest_bytes
if not manifest_path.is_file():
raise ValueError(
"Downloaded extension archive is missing 'extension.yml'"
)
manifest_data = (
parsed_manifest
if parsed_manifest is not None
else {}
)
if manifest_data is None:
raise ValueError("Downloaded extension archive is missing 'extension.yml'")
manifest_bytes = manifest_path.read_bytes()
parsed_manifest = yaml.safe_load(manifest_bytes)
manifest_data = (
parsed_manifest if parsed_manifest is not None else {}
)
if not isinstance(manifest_data, dict):
raise ValueError(
"Invalid extension manifest in downloaded archive: expected YAML mapping"
"Invalid extension manifest in downloaded archive: "
"expected YAML mapping"
)
extension_data = manifest_data.get("extension", {})
if not isinstance(extension_data, dict):
raise ValueError(
"Invalid extension manifest in downloaded archive: expected 'extension' mapping"
"Invalid extension manifest in downloaded archive: "
"expected 'extension' mapping"
)
# Run the same manifest and compatibility validation as a
# normal install while the existing extension is still
# untouched. Reuse the exact bounded bytes selected above.
if manifest_bytes is None:
raise ValueError(
"Downloaded extension archive is missing 'extension.yml'"
)
with tempfile.TemporaryDirectory(
prefix="speckit-update-manifest-"
) as manifest_tmpdir:
@@ -1796,7 +2164,7 @@ def extension_update(
manager.remove(extension_id, keep_config=True)
# 8. Install new version
_ = manager.install_from_zip(zip_path, speckit_version)
_ = manager.install_from_zip(archive_path, speckit_version)
# Restore user config files from backup after successful install.
new_extension_dir = manager.extensions_dir / extension_id
@@ -1842,12 +2210,12 @@ def extension_update(
hook["enabled"] = False
hook_executor.save_project_config(config)
finally:
# ZIP cleanup is housekeeping: never replace an install
# Archive cleanup is housekeeping: never replace an install
# error or roll back an already committed update because a
# scanner temporarily locks the download on Windows.
if zip_path.exists():
if archive_path.exists():
try:
zip_path.unlink()
archive_path.unlink()
except OSError as error:
zip_cleanup_error = error
@@ -2124,6 +2492,13 @@ def extension_update(
console.print(f"{_escape_markup(str(ext_name))}: {_escape_markup(str(error))}")
raise typer.Exit(1)
# S4: regenerate native event config after a successful update. An
# update replaces the installed extension.yml, so any added/removed/
# changed event declarations would otherwise leave native configs
# stale until a manual integration upgrade.
if updated_extensions:
_refresh_events_and_warn(project_root)
except ValidationError as e:
console.print(f"\n[red]Validation Error:[/red] {_escape_markup(str(e))}")
raise typer.Exit(1)
@@ -2173,6 +2548,10 @@ def extension_enable(
console.print(f"[green]✓[/green] Extension '{_escape_markup(str(display_name))}' enabled")
# #1: regenerate native event config so the enabled extension's events
# are re-emitted in installed integrations.
_refresh_events_and_warn(project_root)
@extension_app.command("disable")
def extension_disable(
@@ -2217,6 +2596,10 @@ def extension_disable(
console.print("\nCommands will no longer be available. Hooks will not execute.")
console.print(f"To re-enable: specify extension enable {_escape_markup(str(extension_id))}")
# #1: regenerate native event config so the disabled extension's events
# are stripped from installed integrations.
_refresh_events_and_warn(project_root)
@extension_app.command("set-priority")
def extension_set_priority(

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from collections.abc import Callable
from typing import Any
from ._invocation_style import get_invocation_prefix
from .integration_state import integration_setting, integration_settings
@@ -99,3 +100,14 @@ def invoke_separator_for_integration(
return integration.effective_invoke_separator(stored_parsed, project_root)
return integration.effective_invoke_separator(None, project_root)
def invoke_prefix_for_integration(
integration: Any,
key: str,
parsed_options: dict[str, Any] | None = None,
project_root: Any = None,
) -> str:
"""Resolve the native invocation prefix for an integration's output mode."""
skills_mode = integration.is_skills_mode(parsed_options, project_root)
return get_invocation_prefix(key, skills_mode)

View File

@@ -11,6 +11,7 @@ from rich.markup import escape
from .._agent_config import SCRIPT_TYPE_CHOICES
from .._console import console
from ..integration_runtime import (
invoke_prefix_for_integration as _invoke_prefix_for_integration,
invoke_separator_for_integration as _invoke_separator_for_integration,
resolve_integration_options as _resolve_integration_options_impl,
with_integration_setting as _with_integration_setting,
@@ -120,8 +121,7 @@ def _clear_init_options_for_integration(project_root: Path, integration_key: str
def _remove_integration_json(project_root: Path) -> None:
"""Remove ``.specify/integration.json`` if it exists."""
path = project_root / INTEGRATION_JSON
if path.exists():
path.unlink()
path.unlink(missing_ok=True)
# ---------------------------------------------------------------------------
@@ -333,6 +333,9 @@ def _set_default_integration(
integration, {"integration_settings": settings}, key, parsed_options,
project_root=project_root,
),
invoke_prefix=_invoke_prefix_for_integration(
integration, key, parsed_options, project_root
),
force=refresh_templates_force,
refresh_managed=True,
refresh_hint=refresh_hint,
@@ -391,6 +394,7 @@ def _register_extensions_for_agent(
agent_key: str,
*,
continuing: str,
force: bool = False,
) -> None:
"""Register all enabled extensions' commands/skills for ``agent_key``.
@@ -404,6 +408,11 @@ def _register_extensions_for_agent(
before registering), so extension *skill* rendering — which is scoped to
the active ``ai`` / ``ai_skills`` init-options — matches ``agent_key``.
When ``force=True``, existing skill files are overwritten even when they
are not dev-mode symlinks. Pass ``force=True`` in the upgrade path so that
extension content is layered on top of the core-template files that
``setup()`` just regenerated (fixes the skip-guard bug for skills mode).
Best-effort: never aborts the surrounding integration operation. Callers
invoke it *after* the use/upgrade/switch transaction has committed so a
failure here cannot trigger a rollback.
@@ -411,7 +420,7 @@ def _register_extensions_for_agent(
_best_effort_extension_op(
project_root,
agent_key,
lambda mgr, key: mgr.register_enabled_extensions_for_agent(key),
lambda mgr, key: mgr.register_enabled_extensions_for_agent(key, force=force),
phase="register extension artifacts for",
continuing=continuing,
)

View File

@@ -8,6 +8,7 @@ import typer
from .._console import console
from .._utils import _display_project_path
from ..integration_runtime import (
invoke_prefix_for_integration as _invoke_prefix_for_integration,
invoke_separator_for_integration as _invoke_separator_for_integration,
with_integration_setting as _with_integration_setting,
)
@@ -130,6 +131,9 @@ def integration_install(
infra_integration, current, infra_key, infra_parsed,
project_root=project_root,
),
invoke_prefix=_invoke_prefix_for_integration(
infra_integration, infra_key, infra_parsed, project_root
),
)
if os.name != "nt":
from .. import ensure_executable_scripts
@@ -139,12 +143,21 @@ def integration_install(
integration.key, project_root, version=_get_speckit_version()
)
from ..events import resolve_events
events_map = resolve_events(
integration.key,
integration.config,
project_root,
parsed_options,
)
try:
integration.setup(
project_root, manifest,
parsed_options=parsed_options,
script_type=selected_script,
raw_options=raw_options,
events=events_map,
)
manifest.save()
new_installed = _dedupe_integration_keys([*installed_keys, integration.key])

View File

@@ -9,6 +9,7 @@ import typer
from .._console import console
from ..integration_runtime import (
invoke_prefix_for_integration as _invoke_prefix_for_integration,
invoke_separator_for_integration as _invoke_separator_for_integration,
with_integration_setting as _with_integration_setting,
)
@@ -447,6 +448,9 @@ def integration_switch(
target_integration, current, target, parsed_options,
project_root=project_root,
),
invoke_prefix=_invoke_prefix_for_integration(
target_integration, target, parsed_options, project_root
),
refresh_hint=(
"To overwrite customizations, re-run with "
"[cyan]specify integration switch ... --refresh-shared-infra[/cyan]."
@@ -462,12 +466,20 @@ def integration_switch(
target_integration.key, project_root, version=_get_speckit_version()
)
from ..events import resolve_events
events_map = resolve_events(
target_integration.key,
target_integration.config,
project_root,
parsed_options,
)
try:
target_integration.setup(
project_root, manifest,
parsed_options=parsed_options,
script_type=selected_script,
raw_options=raw_options,
events=events_map,
)
manifest.save()
_set_default_integration(
@@ -747,6 +759,9 @@ def integration_upgrade(
infra_integration, current, infra_key, infra_parsed,
project_root=project_root,
),
invoke_prefix=_invoke_prefix_for_integration(
infra_integration, infra_key, infra_parsed, project_root
),
)
if os.name != "nt":
from .. import ensure_executable_scripts
@@ -756,6 +771,13 @@ def integration_upgrade(
console.print(f"Upgrading integration: [cyan]{key}[/cyan]")
new_manifest = IntegrationManifest(key, project_root, version=_get_speckit_version())
from ..events import resolve_events
events_map = resolve_events(
key,
integration.config,
project_root,
parsed_options,
)
try:
integration.setup(
project_root,
@@ -763,6 +785,7 @@ def integration_upgrade(
parsed_options=parsed_options,
script_type=selected_script,
raw_options=raw_options,
events=events_map,
)
settings = _with_integration_setting(
current,
@@ -782,6 +805,9 @@ def integration_upgrade(
integration, {"integration_settings": settings}, key, parsed_options,
project_root=project_root,
),
invoke_prefix=_invoke_prefix_for_integration(
integration, key, parsed_options, project_root
),
force=force,
refresh_managed=True,
)
@@ -860,6 +886,7 @@ def integration_upgrade(
_register_extensions_for_agent(
project_root,
key,
force=True,
continuing="The integration was upgraded, but installed extensions may need re-registration.",
)
_register_presets_for_agent(

View File

@@ -318,22 +318,26 @@ def integration_search(
console.print(f"\n[green]Found {len(results)} integration(s):[/green]\n")
for integ in sorted(results, key=lambda e: e.get("id", "")):
iid = integ.get("id", "?")
name = integ.get("name", iid)
version = integ.get("version", "?")
iid_value = str(integ.get("id", "?"))
iid = _rich_escape(iid_value)
name = _rich_escape(str(integ.get("name", iid_value)))
version = _rich_escape(str(integ.get("version", "?")))
console.print(f"[bold]{name}[/bold] ({iid}) v{version}")
desc = integ.get("description", "")
if desc:
console.print(f" {desc}")
console.print(f" {_rich_escape(str(desc))}")
console.print(f"\n [dim]Author:[/dim] {integ.get('author', 'Unknown')}")
author_value = _rich_escape(str(integ.get("author", "Unknown")))
console.print(f"\n [dim]Author:[/dim] {author_value}")
tags = integ.get("tags", [])
if isinstance(tags, list) and tags:
console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}")
safe_tags = _rich_escape(", ".join(str(t) for t in tags))
console.print(f" [dim]Tags:[/dim] {safe_tags}")
cat_name = integ.get("_catalog_name", "")
cat_name_value = integ.get("_catalog_name", "")
cat_name = _rich_escape(str(cat_name_value))
install_allowed = integ.get("_install_allowed", True)
if cat_name:
if cat_name_value:
if install_allowed:
console.print(f" [dim]Catalog:[/dim] {cat_name}")
else:
@@ -342,9 +346,9 @@ def integration_search(
"[yellow](discovery only — not installable)[/yellow]"
)
if iid == installed_key:
if iid_value == installed_key:
console.print("\n [green]✓ Installed[/green] (currently active)")
elif iid in INTEGRATION_REGISTRY:
elif iid_value in INTEGRATION_REGISTRY:
console.print(f"\n [cyan]Install:[/cyan] specify integration install {iid}")
elif install_allowed:
console.print(
@@ -374,6 +378,7 @@ def integration_info(
project_root = _require_specify_project()
catalog = IntegrationCatalog(project_root)
installed_key = _default_integration_key(_read_integration_json(project_root))
safe_integration_id = _rich_escape(str(integration_id))
try:
info = catalog.get_integration_info(integration_id)
@@ -386,29 +391,38 @@ def integration_info(
catalog_error = None
if info:
name = info.get("name", integration_id)
version = info.get("version", "?")
console.print(f"\n[bold cyan]{name}[/bold cyan] ({integration_id}) v{version}")
name = _rich_escape(str(info.get("name", integration_id)))
version = _rich_escape(str(info.get("version", "?")))
console.print(
f"\n[bold cyan]{name}[/bold cyan] ({safe_integration_id}) v{version}"
)
if info.get("description"):
console.print(f" {info['description']}")
console.print(f" {_rich_escape(str(info['description']))}")
console.print()
console.print(f" [dim]Author:[/dim] {info.get('author', 'Unknown')}")
author_value = _rich_escape(str(info.get("author", "Unknown")))
console.print(f" [dim]Author:[/dim] {author_value}")
if info.get("license"):
console.print(f" [dim]License:[/dim] {info['license']}")
console.print(
f" [dim]License:[/dim] {_rich_escape(str(info['license']))}"
)
tags = info.get("tags", [])
if isinstance(tags, list) and tags:
console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}")
safe_tags = _rich_escape(", ".join(str(t) for t in tags))
console.print(f" [dim]Tags:[/dim] {safe_tags}")
cat_name = info.get("_catalog_name", "")
cat_name_value = info.get("_catalog_name", "")
cat_name = _rich_escape(str(cat_name_value))
install_allowed = info.get("_install_allowed", True)
if cat_name:
if cat_name_value:
install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]"
console.print(f" [dim]Source catalog:[/dim] {cat_name}{install_note}")
if info.get("repository"):
console.print(f" [dim]Repository:[/dim] {info['repository']}")
console.print(
f" [dim]Repository:[/dim] {_rich_escape(str(info['repository']))}"
)
if integration_id == installed_key:
console.print("\n [green]✓ Installed[/green] (currently active)")
@@ -444,7 +458,7 @@ def integration_info(
else:
console.print("\nTry again when online, or use a built-in integration ID directly.")
else:
console.print(f"[red]Error:[/red] Integration '{integration_id}' not found")
console.print(f"[red]Error:[/red] Integration '{safe_integration_id}' not found")
console.print("\nTry: specify integration search")
raise typer.Exit(1)

View File

@@ -27,14 +27,16 @@ from typing import TYPE_CHECKING, Any
import yaml
from .._invocation_style import get_invocation_prefix, is_dollar_skills_agent
from .._toml_string import escape_toml_basic as _escape_toml_basic
from .._toml_string import has_illegal_toml_control as _has_illegal_toml_control
from ..events import install_integration_events, remove_integration_events
if TYPE_CHECKING:
from .manifest import IntegrationManifest
_HOOK_COMMAND_NOTE = (
"- When constructing slash commands from hook command names, "
"- When constructing command invocations from hook command names, "
"replace dots (`.`) with hyphens (`-`). "
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
)
@@ -158,7 +160,17 @@ class IntegrationBase(ABC):
@classmethod
def options(cls) -> list[IntegrationOption]:
"""Return options this integration accepts. Default: none."""
return []
opts = []
if bool(getattr(cls, "CANONICAL_TO_NATIVE", None) and getattr(cls, "events_config_file", None)):
opts.append(
IntegrationOption(
"--events",
is_flag=False,
default="true",
help="Enable/disable runtime events (true|false, default: true)",
)
)
return opts
def effective_invoke_separator(
self,
@@ -479,7 +491,11 @@ class IntegrationBase(ABC):
tracking) would otherwise be deleted even though they are still
managed. Subclasses list such paths here to protect them.
"""
return set()
exclusions = set()
if self.supports_events():
from ..events import events_stale_exclusions
exclusions.update(events_stale_exclusions(self.key))
return exclusions
def commands_dest(self, project_root: Path) -> Path:
"""Return the absolute path to the commands output directory.
@@ -601,7 +617,9 @@ class IntegrationBase(ABC):
return created
@staticmethod
def resolve_command_refs(content: str, separator: str = ".") -> str:
def resolve_command_refs(
content: str, separator: str = ".", prefix: str = "/"
) -> str:
"""Replace ``__SPECKIT_COMMAND_<NAME>__`` placeholders with invocations.
Each placeholder encodes a command name in upper-case with
@@ -611,10 +629,16 @@ class IntegrationBase(ABC):
* ``separator="."`` → ``/speckit.plan``, ``/speckit.git.commit``
* ``separator="-"`` → ``/speckit-plan``, ``/speckit-git-commit``
*prefix* defaults to ``"/"`` but may be ``"$"`` for agents whose
native skills invocation uses dollar-prefixed chat commands.
"""
return re.sub(
r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__",
lambda m: "/speckit" + separator + m.group(1).lower().replace("_", separator),
lambda m: prefix
+ "speckit"
+ separator
+ m.group(1).lower().replace("_", separator),
content,
)
@@ -838,7 +862,12 @@ class IntegrationBase(ABC):
content = CommandRegistrar.rewrite_project_relative_paths(content)
# 8. Replace __SPECKIT_COMMAND_<NAME>__ with invocation strings
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
invocation_prefix = get_invocation_prefix(
agent_name, invoke_separator == "-"
)
content = IntegrationBase.resolve_command_refs(
content, invoke_separator, invocation_prefix
)
return content
@@ -902,8 +931,32 @@ class IntegrationBase(ABC):
Returns ``(removed, skipped)`` file lists.
"""
self.remove_events(project_root, manifest)
return manifest.uninstall(project_root, force=force)
def emit_events(
self,
project_root: Path,
manifest: IntegrationManifest,
events: dict[str, dict[str, Any]] | None = None,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Emit native event configuration for this integration."""
return install_integration_events(self, project_root, manifest, events or {})
def remove_events(
self,
project_root: Path,
manifest: IntegrationManifest,
) -> None:
"""Remove Specify-authored event entries from native config."""
remove_integration_events(self, project_root, manifest)
def supports_events(self) -> bool:
"""Return True if this integration supports agent-native events."""
return bool(getattr(self, "CANONICAL_TO_NATIVE", None) and getattr(self, "events_config_file", None))
# -- Convenience helpers for subclasses -------------------------------
def install(
@@ -1008,6 +1061,12 @@ class MarkdownIntegration(IntegrationBase):
created.append(dst_file)
# Install agent runtime events
event_files = self.emit_events(
project_root, manifest, events=opts.get("events"), parsed_options=parsed_options
)
created.extend(event_files)
return created
@@ -1215,6 +1274,12 @@ class TomlIntegration(IntegrationBase):
created.append(dst_file)
# Install agent runtime events
event_files = self.emit_events(
project_root, manifest, events=opts.get("events"), parsed_options=parsed_options
)
created.extend(event_files)
return created
@@ -1451,6 +1516,12 @@ class YamlIntegration(IntegrationBase):
created.append(dst_file)
# Install agent runtime events
event_files = self.emit_events(
project_root, manifest, events=opts.get("events"), parsed_options=parsed_options
)
created.extend(event_files)
return created
@@ -1520,18 +1591,21 @@ class SkillsIntegration(IntegrationBase):
return project_root / folder / subdir
def build_command_invocation(self, command_name: str, args: str = "") -> str:
"""Skills use ``/speckit-<stem>`` (hyphenated directory name)."""
"""Build the agent's native invocation for a hyphenated skill name."""
stem = command_name
if stem.startswith("speckit."):
stem = stem[len("speckit."):]
invocation = "/speckit-" + stem.replace(".", "-")
prefix = "$" if is_dollar_skills_agent(self.key, True) else "/"
invocation = prefix + "speckit-" + stem.replace(".", "-")
if args:
invocation = f"{invocation} {args}"
return invocation
@staticmethod
def _inject_hook_command_note(content: str) -> str:
def _inject_hook_command_note(
content: str, invocation_prefix: str = "/"
) -> str:
"""Insert a dot-to-hyphen note before each hook output instruction.
Targets the line ``- For each executable hook, output the following``
@@ -1540,6 +1614,11 @@ class SkillsIntegration(IntegrationBase):
above them.
"""
note = _HOOK_COMMAND_NOTE.rstrip("\n")
if invocation_prefix != "/":
note = note.replace(
"`/speckit-git-commit`",
f"`{invocation_prefix}speckit-git-commit`",
)
def repl(m: re.Match[str]) -> str:
indent = m.group(1)
@@ -1573,10 +1652,13 @@ class SkillsIntegration(IntegrationBase):
Called by external skill generators (presets, extensions) to let
the integration inject agent-specific frontmatter or body
transformations. The base implementation injects shared skills
guidance for converting dotted hook command names to hyphenated
slash commands. Subclasses may override — see ``ClaudeIntegration``.
guidance for converting dotted hook command names to the agent-native
hyphenated command invocation (e.g. ``/speckit-git-commit`` or
``$speckit-git-commit``). Subclasses may override -- see
``ClaudeIntegration``.
"""
return self._inject_hook_command_note(content)
invocation_prefix = get_invocation_prefix(self.key, True)
return self._inject_hook_command_note(content, invocation_prefix)
def setup(
self,
@@ -1627,13 +1709,27 @@ class SkillsIntegration(IntegrationBase):
command_name = src_file.stem # e.g. "plan"
skill_name = f"speckit-{command_name.replace('.', '-')}"
# Parse frontmatter for description
# Parse frontmatter for description. Locate the closing ``---`` on
# its own line rather than with ``raw.split("---", 2)`` — a bare
# substring split stops at the first ``---`` *anywhere*, including
# one inside a value such as ``description: Separate sections
# with ---``, which truncates the frontmatter and drops later keys.
# The block between the delimiters is parsed unstripped so trailing
# newlines in literal (``|``) block scalars survive.
frontmatter: dict[str, Any] = {}
if raw.startswith("---"):
parts = raw.split("---", 2)
if len(parts) >= 3:
fm_lines = raw.splitlines(keepends=True)
fm_close = next(
(
i
for i in range(1, len(fm_lines))
if fm_lines[i].rstrip() == "---"
),
None,
)
if fm_close is not None:
try:
fm = yaml.safe_load(parts[1])
fm = yaml.safe_load("".join(fm_lines[1:fm_close]))
if isinstance(fm, dict):
frontmatter = fm
except yaml.YAMLError:
@@ -1648,11 +1744,27 @@ class SkillsIntegration(IntegrationBase):
# Strip the processed frontmatter — we rebuild it for skills.
# Preserve leading whitespace in the body to match release ZIP
# output byte-for-byte (the template body starts with \n after
# the closing ---).
# the closing ---). Scan for the closing ``---`` on its own line
# rather than ``split("---", 2)`` so a ``---`` embedded in a value
# does not truncate the frontmatter and spill it into the body.
if processed_body.startswith("---"):
parts = processed_body.split("---", 2)
if len(parts) >= 3:
processed_body = parts[2]
body_lines = processed_body.splitlines(keepends=True)
close_idx = next(
(
i
for i in range(1, len(body_lines))
if body_lines[i].rstrip() == "---"
),
None,
)
if close_idx is not None:
# Keep whatever trails the ``---`` marker on the closing
# line (normally just the newline) so the body stays
# byte-for-byte identical to ``split("---", 2)[2]``. The
# line-anchored check guarantees ``---`` sits at index 0.
processed_body = body_lines[close_idx][3:] + "".join(
body_lines[close_idx + 1 :]
)
# Select description — use the original template description
# to stay byte-for-byte identical with release ZIP output.
@@ -1686,4 +1798,10 @@ class SkillsIntegration(IntegrationBase):
created.append(dst)
# Install agent runtime events
event_files = self.emit_events(
project_root, manifest, events=opts.get("events"), parsed_options=parsed_options
)
created.extend(event_files)
return created

View File

@@ -21,6 +21,7 @@ from typing import Any, Dict, List, Optional, Tuple
import yaml
from packaging import version as pkg_version
from .._download_security import MAX_JSON_METADATA_BYTES, read_response_limited
from ..catalogs import CatalogEntry, CatalogStackBase
@@ -200,7 +201,14 @@ class IntegrationCatalog(CatalogStackBase):
final_url = resp.geturl()
if final_url != entry.url:
self._validate_catalog_url(final_url)
catalog_data = json.loads(resp.read())
catalog_data = json.loads(
read_response_limited(
resp,
max_bytes=MAX_JSON_METADATA_BYTES,
error_type=IntegrationCatalogError,
label=f"catalog from {entry.url}",
)
)
shape_error = _catalog_shape_error(catalog_data)
if shape_error is not None:

View File

@@ -54,6 +54,17 @@ class ClaudeIntegration(SkillsIntegration):
}
multi_install_safe = True
CANONICAL_TO_NATIVE = {
"session_start": "SessionStart",
"pre_tool_use": "PreToolUse",
"post_tool_use": "PostToolUse",
"session_end": "SessionEnd",
"user_prompt_submit": "UserPromptSubmit",
"stop": "Stop",
}
events_config_file = ".claude/settings.json"
events_format = "json-nested"
@staticmethod
def inject_argument_hint(content: str, hint: str) -> str:
"""Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter.

View File

@@ -29,6 +29,17 @@ class CodexIntegration(SkillsIntegration):
dev_no_symlink = True
multi_install_safe = True
CANONICAL_TO_NATIVE = {
"session_start": "SessionStart",
"pre_tool_use": "PreToolUse",
"post_tool_use": "PostToolUse",
"session_end": "SessionEnd",
"user_prompt_submit": "UserPromptSubmit",
"stop": "Stop",
}
events_config_file = ".codex/config.toml"
events_format = "toml"
def build_exec_args(
self,
prompt: str,
@@ -49,11 +60,13 @@ class CodexIntegration(SkillsIntegration):
@classmethod
def options(cls) -> list[IntegrationOption]:
return [
opts = super().options()
opts.append(
IntegrationOption(
"--skills",
is_flag=True,
default=True,
help="Install as agent skills (default for Codex)",
),
]
)
)
return opts

View File

@@ -118,6 +118,19 @@ class CopilotIntegration(IntegrationBase):
"extension": ".agent.md",
}
CANONICAL_TO_NATIVE = {
"session_start": "sessionStart",
"pre_tool_use": "preToolUse",
"post_tool_use": "postToolUse",
"session_end": "sessionEnd",
"user_prompt_submit": "userPromptSubmitted",
# Copilot CLI supports the canonical per-turn stop lifecycle as native
# agentStop (U3); mapping it so an extension's stop handler fires.
"stop": "agentStop",
}
events_config_file = ".github/hooks/speckit.json"
events_format = "copilot-json"
# Mutable flag set by setup() — indicates the active scaffolding mode.
_skills_mode: bool = False
@@ -162,14 +175,19 @@ class CopilotIntegration(IntegrationBase):
@classmethod
def options(cls) -> list[IntegrationOption]:
return [
# Compose with super() so the base class declares --events for this
# event-capable integration; otherwise --integration-options
# "--events false" is rejected as unknown (#9).
opts = super().options()
opts.append(
IntegrationOption(
"--skills",
is_flag=True,
default=False,
help="Scaffold commands as agent skills (speckit-<name>/SKILL.md) instead of .agent.md files",
),
]
)
return opts
def _resolve_executable(self) -> str:
"""Return the Copilot CLI executable, respecting the env-var override.
@@ -328,7 +346,9 @@ class CopilotIntegration(IntegrationBase):
be flagged stale and deleted, destroying user settings (and the file
the integration still manages).
"""
return {".vscode/settings.json"}
exclusions = super().stale_cleanup_exclusions()
exclusions.add(".vscode/settings.json")
return exclusions
def post_process_skill_content(self, content: str) -> str:
"""Inject shared hook guidance into Copilot skill content.
@@ -355,10 +375,18 @@ class CopilotIntegration(IntegrationBase):
parsed_options = parsed_options or {}
self._skills_mode = bool(parsed_options.get("skills"))
if self._skills_mode:
return self._setup_skills(project_root, manifest, parsed_options, **opts)
if "skills" not in parsed_options:
_warn_legacy_markdown_default()
return self._setup_default(project_root, manifest, parsed_options, **opts)
created = self._setup_skills(project_root, manifest, parsed_options, **opts)
else:
if "skills" not in parsed_options:
_warn_legacy_markdown_default()
created = self._setup_default(project_root, manifest, parsed_options, **opts)
# Install agent runtime events
event_files = self.emit_events(
project_root, manifest, events=opts.get("events"), parsed_options=parsed_options
)
created.extend(event_files)
return created
def _setup_default(
self,
@@ -497,7 +525,7 @@ class CopilotIntegration(IntegrationBase):
"""
try:
existing = json.loads(dst.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
except (json.JSONDecodeError, UnicodeDecodeError, OSError):
# Cannot parse existing file (likely JSONC with comments).
# Skip merge to preserve the user's settings, but show
# what they should add manually.

View File

@@ -38,6 +38,17 @@ class CursorAgentIntegration(SkillsIntegration):
multi_install_safe = True
CANONICAL_TO_NATIVE = {
"session_start": "sessionStart",
"pre_tool_use": "preToolUse",
"post_tool_use": "postToolUse",
"session_end": "sessionEnd",
"user_prompt_submit": "beforeSubmitPrompt",
"stop": "stop",
}
events_config_file = ".cursor/hooks.json"
events_format = "json-flat"
def build_exec_args(
self,
prompt: str,
@@ -92,11 +103,13 @@ class CursorAgentIntegration(SkillsIntegration):
@classmethod
def options(cls) -> list[IntegrationOption]:
return [
opts = super().options()
opts.append(
IntegrationOption(
"--skills",
is_flag=True,
default=True,
help="Install as agent skills (recommended for Cursor)",
),
]
)
)
return opts

View File

@@ -31,6 +31,20 @@ class DevinIntegration(SkillsIntegration):
"extension": "/SKILL.md",
}
CANONICAL_TO_NATIVE = {
"session_start": "SessionStart",
"pre_tool_use": "PreToolUse",
"post_tool_use": "PostToolUse",
"session_end": "SessionEnd",
"user_prompt_submit": "UserPromptSubmit",
"stop": "Stop",
}
events_config_file = ".devin/hooks.v1.json"
# Devin's hooks.v1.json is a root event map ({"PreToolUse": [...]}) with no
# top-level "hooks" wrapper (U2), unlike the settings.json formats. The
# json-root-nested writer/remover operate directly on the root event keys.
events_format = "json-root-nested"
def build_exec_args(
self,
prompt: str,
@@ -55,11 +69,16 @@ class DevinIntegration(SkillsIntegration):
@classmethod
def options(cls) -> list[IntegrationOption]:
return [
# Compose with super() so the base class declares --events for this
# event-capable integration; otherwise --integration-options
# "--events false" is rejected as unknown (#8).
opts = super().options()
opts.append(
IntegrationOption(
"--skills",
is_flag=True,
default=True,
help="Install as agent skills (default for Devin)",
),
]
)
return opts

View File

@@ -19,3 +19,22 @@ class GeminiIntegration(TomlIntegration):
"extension": ".toml",
}
multi_install_safe = True
CANONICAL_TO_NATIVE = {
"session_start": "SessionStart",
"pre_tool_use": "BeforeTool",
"post_tool_use": "AfterTool",
"session_end": "SessionEnd",
# Gemini exposes BeforeAgent for the per-turn prompt-submit lifecycle
# point (S6); its own Claude-hook migration maps UserPromptSubmit to
# BeforeAgent. Mapping it so extension handlers fire.
"user_prompt_submit": "BeforeAgent",
"stop": "AfterAgent",
}
events_config_file = ".gemini/settings.json"
events_format = "json-nested"
# Gemini measures hook timeouts in milliseconds, unlike Claude/Cursor/Codex
# which use seconds. The shared formatter converts via _native_timeout (#7)
# so the default 60s becomes 60000ms instead of terminating the dispatcher
# after 60ms.
events_timeout_unit = "ms"

View File

@@ -53,8 +53,16 @@ class GenericIntegration(MarkdownIntegration):
"""
parsed_options = parsed_options or {}
# Accept a value only when it is non-BLANK. An empty value resolves to
# the project root (``project_root / ""``) and a whitespace-only one to
# a directory literally named " ", so either would silently scatter
# command files instead of failing with the documented "required"
# error. ``strip()`` is used ONLY to decide blankness -- the value
# itself is returned verbatim, so a deliberate (if unusual) padded
# directory name still targets exactly what the user asked for. Both
# branches below apply the same rule so they cannot drift apart.
commands_dir = parsed_options.get("commands_dir")
if commands_dir:
if commands_dir and (not isinstance(commands_dir, str) or commands_dir.strip()):
return commands_dir
# Fall back to raw_options (--integration-options="--commands-dir ...")
@@ -64,9 +72,13 @@ class GenericIntegration(MarkdownIntegration):
tokens = shlex.split(raw)
for i, token in enumerate(tokens):
if token == "--commands-dir" and i + 1 < len(tokens):
return tokens[i + 1]
candidate = tokens[i + 1]
if candidate.strip():
return candidate
if token.startswith("--commands-dir="):
return token.split("=", 1)[1]
candidate = token.split("=", 1)[1]
if candidate.strip():
return candidate
raise ValueError(
"--commands-dir is required for the generic integration"

View File

@@ -59,8 +59,7 @@ class KimiIntegration(SkillsIntegration):
def post_process_skill_content(self, content: str) -> str:
"""Ensure in-skill cross-command references use Kimi's `/skill:` syntax."""
content = super().post_process_skill_content(content)
return content.replace("/speckit-", "/skill:speckit-")
return super().post_process_skill_content(content)
@classmethod
def options(cls) -> list[IntegrationOption]:

View File

@@ -400,7 +400,19 @@ class IntegrationManifest:
# Remove the manifest file itself
manifest = root / ".specify" / "integrations" / f"{self.key}.manifest.json"
if remove_manifest and manifest.exists():
manifest.unlink()
try:
manifest.unlink()
except OSError:
# An undeletable manifest (read-only file, a directory left at
# the path, a Windows lock) must not abort the uninstall after
# the tracked files were already removed: the caller would lose
# the (removed, skipped) result and never run its post-uninstall
# bookkeeping. Report it like any other file we could not
# remove, mirroring the path.unlink() guard above. The
# empty-parent cleanup below is left unconditional: with the
# manifest still on disk its parent is non-empty, so the first
# rmdir() raises and breaks immediately.
skipped.append(manifest)
parent = manifest.parent
while parent != root:
try:
@@ -459,6 +471,10 @@ class IntegrationManifest:
path = inst.manifest_path
try:
data = json.loads(path.read_text(encoding="utf-8"))
except UnicodeDecodeError as exc:
raise ValueError(
f"Integration manifest at {path} is not valid UTF-8"
) from exc
except json.JSONDecodeError as exc:
raise ValueError(
f"Integration manifest at {path} contains invalid JSON"

View File

@@ -20,6 +20,15 @@ class OpencodeIntegration(MarkdownIntegration):
"extension": ".md",
}
CANONICAL_TO_NATIVE = {
"pre_tool_use": "tool.execute.before",
"post_tool_use": "tool.execute.after",
"session_start": "session.created",
"session_end": "session.deleted",
}
events_config_file = "opencode.json"
events_format = "ts-plugin"
def build_exec_args(
self,
prompt: str,

View File

@@ -19,3 +19,20 @@ class QwenIntegration(MarkdownIntegration):
"extension": ".md",
}
multi_install_safe = True
CANONICAL_TO_NATIVE = {
"session_start": "SessionStart",
"pre_tool_use": "PreToolUse",
"post_tool_use": "PostToolUse",
"session_end": "SessionEnd",
"user_prompt_submit": "UserPromptSubmit",
"stop": "Stop",
}
events_config_file = ".qwen/settings.json"
events_format = "json-nested"
# Qwen Code's command hooks measure timeout in milliseconds (default
# 60000), per the Qwen Code hooks documentation. Declaring the unit makes
# the shared formatter convert the 60s default to 60000ms instead of
# emitting timeout: 60 (60 ms), which would terminate the dispatcher
# before it starts (U1).
events_timeout_unit = "ms"

View File

@@ -19,3 +19,23 @@ class TabnineIntegration(TomlIntegration):
"extension": ".toml",
}
multi_install_safe = True
CANONICAL_TO_NATIVE = {
"session_start": "SessionStart",
"pre_tool_use": "BeforeTool",
"post_tool_use": "AfterTool",
"session_end": "SessionEnd",
# Tabnine's Gemini-compatible schema also provides BeforeAgent and
# AfterAgent (S7); mapping them so user_prompt_submit and stop
# extension handlers fire instead of being skipped.
"user_prompt_submit": "BeforeAgent",
"stop": "AfterAgent",
}
events_config_file = ".tabnine/agent/settings.json"
events_format = "json-nested"
# Tabnine mirrors Gemini's hook schema (BeforeTool/AfterTool) and, like
# Gemini, measures hook timeouts in milliseconds. Declaring the unit makes
# the shared formatter convert the 60s default to 60000ms instead of
# emitting timeout: 60 (60 ms), which would terminate the dispatcher
# before it starts (R5).
events_timeout_unit = "ms"

View File

@@ -27,11 +27,14 @@ from packaging import version as pkg_version
from packaging.specifiers import SpecifierSet, InvalidSpecifier
from .._download_security import (
archive_format_from_name,
archive_suffix,
MAX_JSON_CATALOG_BYTES,
build_safe_download_path,
detect_archive_format,
is_https_or_localhost_http,
read_response_limited,
safe_extract_zip,
safe_extract_archive,
)
from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority
from .._init_options import (
@@ -40,6 +43,7 @@ from .._init_options import (
load_init_options,
resolve_active_agent_for_registration,
)
from .._invocation_style import get_invocation_prefix
from ..integrations.base import IntegrationBase
from .._utils import dump_frontmatter, version_satisfies
from ..shared_infra import (
@@ -295,6 +299,12 @@ class PresetManifest:
f"(expected {self.SCHEMA_VERSION})"
)
for section in ("preset", "requires", "provides"):
if not isinstance(self.data[section], dict):
raise PresetValidationError(
f"Invalid {section}: expected a mapping"
)
# Validate preset metadata
pack = self.data["preset"]
for field in ["id", "name", "version", "description"]:
@@ -321,13 +331,37 @@ class PresetManifest:
# Validate provides section
provides = self.data["provides"]
if "templates" not in provides or not provides["templates"]:
if "templates" not in provides:
raise PresetValidationError(
"Preset must provide at least one template"
)
# Validate templates
for tmpl in provides["templates"]:
# Validate templates. Guard the container and each entry's shape so a
# malformed third-party preset.yml (e.g. ``templates: 5`` or
# ``templates: [null]``) raises a clean PresetValidationError the
# install handler already catches, instead of a raw TypeError
# ('int'/'NoneType' object is not iterable) that escapes to an
# unhandled traceback. Mirrors the sibling ExtensionManifest guards.
#
# Order matters: the container's TYPE is checked before its emptiness,
# so a FALSY non-list (``templates: 0``/``false``/``null``/``''``/``{}``)
# reports the accurate type error rather than the misleading "must
# provide at least one template". An empty list still reports the
# latter, since that genuinely is a list with no templates.
templates = provides["templates"]
if not isinstance(templates, list):
raise PresetValidationError(
"Invalid provides.templates: expected a list"
)
if not templates:
raise PresetValidationError(
"Preset must provide at least one template"
)
for tmpl in templates:
if not isinstance(tmpl, dict):
raise PresetValidationError(
"Each template entry in 'provides.templates' must be a mapping"
)
if "type" not in tmpl or "name" not in tmpl or "file" not in tmpl:
raise PresetValidationError(
"Template missing 'type', 'name', or 'file'"
@@ -456,7 +490,7 @@ class PresetRegistry:
}
try:
with open(self.registry_path, 'r') as f:
with open(self.registry_path, 'r', encoding='utf-8') as f:
data = json.load(f)
# Validate loaded data is a dict (handles corrupted registry files)
if not isinstance(data, dict):
@@ -477,7 +511,7 @@ class PresetRegistry:
def _save(self):
"""Save registry to disk."""
self.packs_dir.mkdir(parents=True, exist_ok=True)
with open(self.registry_path, 'w') as f:
with open(self.registry_path, 'w', encoding='utf-8') as f:
json.dump(self.data, f, indent=2)
def add(self, pack_id: str, metadata: dict):
@@ -2424,13 +2458,15 @@ class PresetManager:
Looks up the agent's invoke separator and rewrites each
``__SPECKIT_COMMAND_<NAME>__`` placeholder into the matching
slash-command invocation ``/speckit-<cmd>`` for a ``-`` separator,
``/speckit.<cmd>`` for ``.`` — the same rendering the command layer
applies via ``CommandRegistrar.register_commands()``.
agent-native invocation -- ``/speckit-<cmd>`` or ``$speckit-<cmd>`` for
a ``-`` separator, ``/speckit.<cmd>`` for ``.``, or
``/skill:speckit-<cmd>`` for skill-colon agents (e.g. Kimi) -- the
same rendering the command layer applies via
``CommandRegistrar.register_commands()``.
For dual-layout agents (e.g. Bob) the separator depends on the
project's persisted skills state, so when *project_root* is provided
the separator is resolved from the integration via
project's persisted skills state, so -- when *project_root* is provided
-- the separator is resolved from the integration via
``invoke_separator_for_mode`` rather than the single static
``AGENT_CONFIGS`` value.
"""
@@ -2451,7 +2487,8 @@ class PresetManager:
separator = registrar.AGENT_CONFIGS.get(selected_ai, {}).get(
"invoke_separator", "."
)
return IntegrationBase.resolve_command_refs(body, separator)
prefix = get_invocation_prefix(selected_ai, separator == "-")
return IntegrationBase.resolve_command_refs(body, separator, prefix)
def _build_extension_skill_restore_index(self) -> Dict[str, Dict[str, Any]]:
"""Index extension-backed skill restore data by skill directory name."""
@@ -3331,6 +3368,7 @@ class PresetManager:
source_dir: Path,
speckit_version: str,
priority: int = 10,
force: bool = False,
) -> PresetManifest:
"""Install preset from a local directory.
@@ -3338,6 +3376,7 @@ class PresetManager:
source_dir: Path to preset directory
speckit_version: Current spec-kit version
priority: Resolution priority (lower = higher precedence, default 10)
force: If True and the preset is already installed, remove it first
Returns:
Installed preset manifest
@@ -3356,10 +3395,12 @@ class PresetManager:
self.check_compatibility(manifest, speckit_version)
if self.registry.is_installed(manifest.id):
raise PresetError(
f"Preset '{manifest.id}' is already installed. "
f"Use 'specify preset remove {manifest.id}' first."
)
if not force:
raise PresetError(
f"Preset '{manifest.id}' is already installed. "
f"Use 'specify preset remove {manifest.id}' first."
)
self.remove(manifest.id)
dest_dir = self.presets_dir / manifest.id
if dest_dir.exists():
@@ -3502,18 +3543,20 @@ class PresetManager:
return
_materialize_constitution_template(self.project_root, memory_constitution)
def install_from_zip(
def install_from_archive(
self,
zip_path: Path,
archive_path: Path,
speckit_version: str,
priority: int = 10,
force: bool = False,
) -> PresetManifest:
"""Install preset from ZIP file.
"""Install a preset from a supported archive.
Args:
zip_path: Path to preset ZIP file
archive_path: Path to a .zip, .tar.gz, or .tgz archive
speckit_version: Current spec-kit version
priority: Resolution priority (lower = higher precedence, default 10)
force: If True and the preset is already installed, remove it first
Returns:
Installed preset manifest
@@ -3529,7 +3572,11 @@ class PresetManager:
with tempfile.TemporaryDirectory() as tmpdir:
temp_path = Path(tmpdir)
safe_extract_zip(zip_path, temp_path, error_type=PresetValidationError)
safe_extract_archive(
archive_path,
temp_path,
error_type=PresetValidationError,
)
pack_dir = temp_path
manifest_path = pack_dir / "preset.yml"
@@ -3542,10 +3589,25 @@ class PresetManager:
if not manifest_path.exists():
raise PresetValidationError(
"No preset.yml found in ZIP file"
"No preset.yml found in archive"
)
return self.install_from_directory(pack_dir, speckit_version, priority)
return self.install_from_directory(pack_dir, speckit_version, priority, force=force)
def install_from_zip(
self,
zip_path: Path,
speckit_version: str,
priority: int = 10,
force: bool = False,
) -> PresetManifest:
"""Backward-compatible wrapper for archive installation."""
return self.install_from_archive(
zip_path,
speckit_version,
priority,
force=force,
)
def remove(self, pack_id: str) -> bool:
"""Remove an installed preset.
@@ -4509,23 +4571,34 @@ class PresetCatalog:
results = []
for pack_id, pack_data in packs.items():
if author and pack_data.get("author", "").lower() != author.lower():
continue
if author:
author_val = pack_data.get("author", "")
if not isinstance(author_val, str):
author_val = str(author_val) if author_val is not None else ""
if author_val.lower() != author.lower():
continue
if tag and tag.lower() not in [
str(t).lower() for t in pack_data.get("tags", [])
]:
continue
if tag:
raw_tags = pack_data.get("tags", [])
tags_list = raw_tags if isinstance(raw_tags, list) else []
if tag.lower() not in [
str(t).lower() for t in tags_list
]:
continue
if query:
query_lower = query.lower()
raw_tags = pack_data.get("tags", [])
tags_list = raw_tags if isinstance(raw_tags, list) else []
name_val = pack_data.get("name", "")
desc_val = pack_data.get("description", "")
searchable_text = " ".join(
[
pack_data.get("name", ""),
pack_data.get("description", ""),
str(name_val) if name_val is not None else "",
str(desc_val) if desc_val is not None else "",
pack_id,
]
+ [str(t) for t in pack_data.get("tags", [])]
+ [str(t) for t in tags_list]
).lower()
if query_lower not in searchable_text:
@@ -4560,14 +4633,14 @@ class PresetCatalog:
def download_pack(
self, pack_id: str, target_dir: Optional[Path] = None
) -> Path:
"""Download preset ZIP from catalog.
"""Download a preset archive from a catalog.
Args:
pack_id: ID of the preset to download
target_dir: Directory to save ZIP file (defaults to cache directory)
target_dir: Directory to save the archive
Returns:
Path to downloaded ZIP file
Path to the downloaded archive
Raises:
PresetError: If pack not found or download fails
@@ -4636,42 +4709,86 @@ class PresetCatalog:
target_dir = self.cache_dir / "downloads"
target_dir = Path(target_dir)
version = pack_info.get("version", "unknown")
zip_path = build_safe_download_path(
declared_format = archive_format_from_name(download_url)
build_safe_download_path(
target_dir,
pack_id,
version,
error_type=PresetError,
label="preset",
suffix=archive_suffix(declared_format or "tar.gz"),
)
target_dir.mkdir(parents=True, exist_ok=True)
original_download_url = download_url
extra_headers = None
resolved_download_url = self._resolve_github_release_asset_api_url(download_url)
if resolved_download_url:
download_url = resolved_download_url
extra_headers = {"Accept": "application/octet-stream"}
staging_path: Path | None = None
try:
with self._open_url(download_url, timeout=60, extra_headers=extra_headers) as response:
zip_data = read_response_limited(
archive_data = read_response_limited(
response,
error_type=PresetError,
label=f"preset '{pack_id}' download",
)
final_url = (
response.geturl()
if hasattr(response, "geturl")
else download_url
)
content_type = (
response.getheader("Content-Type")
if hasattr(response, "getheader")
else None
)
verify_archive_sha256(
zip_data, pack_info.get("sha256"), pack_id, PresetError
archive_data, pack_info.get("sha256"), pack_id, PresetError
)
zip_path.write_bytes(zip_data)
return zip_path
with tempfile.NamedTemporaryFile(
prefix="preset-download-",
suffix=".archive",
dir=target_dir,
delete=False,
) as staging_file:
staging_path = Path(staging_file.name)
staging_file.write(archive_data)
archive_format = detect_archive_format(
staging_path,
source_name=(
final_url
if archive_format_from_name(final_url) is not None
else original_download_url
),
content_type=content_type,
error_type=PresetError,
)
archive_path = build_safe_download_path(
target_dir,
pack_id,
version,
error_type=PresetError,
label="preset",
suffix=archive_suffix(archive_format),
)
os.replace(staging_path, archive_path)
staging_path = None
return archive_path
except urllib.error.URLError as e:
raise PresetError(
f"Failed to download preset from {download_url}: {e}"
)
except IOError as e:
raise PresetError(f"Failed to save preset ZIP: {e}")
raise PresetError(f"Failed to save preset archive: {e}")
finally:
if staging_path is not None:
staging_path.unlink(missing_ok=True)
def clear_cache(self):
"""Clear all catalog cache files, including per-URL hashed caches."""

View File

@@ -17,6 +17,9 @@ from rich.markup import escape as _escape_markup
from .._console import console
from .._download_security import (
archive_format_from_name,
archive_suffix,
detect_archive_format,
is_https_or_localhost_http,
is_safe_download_redirect,
read_response_limited,
@@ -59,10 +62,14 @@ def preset_list():
for pack in installed:
status = "[green]enabled[/green]" if pack.get("enabled", True) else "[red]disabled[/red]"
pri = pack.get('priority', 10)
console.print(f" [bold]{pack['name']}[/bold] ({pack['id']}) v{pack['version']}{status} — priority {pri}")
console.print(f" {pack['description']}")
if pack.get("tags"):
tags_str = _escape_markup(", ".join(str(t) for t in pack["tags"]))
name = _escape_markup(str(pack['name']))
pack_id = _escape_markup(str(pack['id']))
version = _escape_markup(str(pack['version']))
console.print(f" [bold]{name}[/bold] ({pack_id}) v{version}{status} — priority {pri}")
console.print(f" {_escape_markup(str(pack['description']))}")
tags = pack.get("tags", [])
if isinstance(tags, list) and tags:
tags_str = _escape_markup(", ".join(str(t) for t in tags))
console.print(f" [dim]Tags: {tags_str}[/dim]")
console.print(f" [dim]Templates: {pack['template_count']}[/dim]")
console.print()
@@ -71,7 +78,11 @@ def preset_list():
@preset_app.command("add")
def preset_add(
preset_id: str = typer.Argument(None, help="Preset ID to install from catalog"),
from_url: str = typer.Option(None, "--from", help="Install from a URL (ZIP file)"),
from_url: str = typer.Option(
None,
"--from",
help="Install from a .zip, .tar.gz, or .tgz URL",
),
dev: str = typer.Option(None, "--dev", help="Install from local directory (development mode)"),
priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
):
@@ -138,7 +149,7 @@ def preset_add(
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
zip_path = Path(tmpdir) / "preset.zip"
archive_path = Path(tmpdir) / "preset.archive"
try:
from specify_cli.authentication.http import open_url as _open_url
from specify_cli.authentication.http import github_provider_hosts
@@ -166,13 +177,33 @@ def preset_add(
"or HTTP for localhost (127.0.0.1, ::1)."
)
raise typer.Exit(1)
zip_path.write_bytes(
read_response_limited(
response,
error_type=PresetError,
label=f"preset {from_url}",
)
archive_data = read_response_limited(
response,
error_type=PresetError,
label=f"preset {from_url}",
)
content_type = (
response.getheader("Content-Type")
if hasattr(response, "getheader")
else None
)
archive_path.write_bytes(archive_data)
format_source = (
final_url
if archive_format_from_name(final_url) is not None
else from_url
)
archive_format = detect_archive_format(
archive_path,
source_name=format_source,
content_type=content_type,
error_type=PresetError,
)
detected_path = archive_path.with_suffix(
archive_suffix(archive_format)
)
os.replace(archive_path, detected_path)
archive_path = detected_path
except (urllib.error.URLError, PresetError) as e:
console.print(
f"[red]Error:[/red] Failed to download: "
@@ -180,7 +211,11 @@ def preset_add(
)
raise typer.Exit(1)
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
manifest = manager.install_from_zip(
archive_path,
speckit_version,
priority,
)
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
@@ -223,12 +258,16 @@ def preset_add(
console.print(f"Installing preset [cyan]{pack_info.get('name', preset_id)}[/cyan]...")
try:
zip_path = catalog.download_pack(preset_id)
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
archive_path = catalog.download_pack(preset_id)
manifest = manager.install_from_zip(
archive_path,
speckit_version,
priority,
)
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
finally:
if 'zip_path' in locals() and zip_path.exists():
zip_path.unlink(missing_ok=True)
if 'archive_path' in locals() and archive_path.exists():
archive_path.unlink(missing_ok=True)
else:
console.print("[red]Error:[/red] Specify a preset ID, --from URL, or --dev path")
raise typer.Exit(1)
@@ -291,10 +330,16 @@ def preset_search(
console.print(f"\n[bold cyan]Presets ({len(results)} found):[/bold cyan]\n")
for pack in results:
console.print(f" [bold]{pack.get('name', pack['id'])}[/bold] ({pack['id']}) v{pack.get('version', '?')}")
console.print(f" {pack.get('description', '')}")
if pack.get("tags"):
tags_str = ", ".join(str(t) for t in pack["tags"])
name = _escape_markup(str(pack.get("name", pack["id"])))
pack_id = _escape_markup(str(pack["id"]))
version = _escape_markup(str(pack.get("version", "?")))
console.print(f" [bold]{name}[/bold] ({pack_id}) v{version}")
console.print(
f" {_escape_markup(str(pack.get('description', '')))}"
)
tags = pack.get("tags", [])
if isinstance(tags, list) and tags:
tags_str = _escape_markup(", ".join(str(t) for t in tags))
console.print(f" [dim]Tags: {tags_str}[/dim]")
console.print()
@@ -310,13 +355,20 @@ def preset_resolve(
project_root = _require_specify_project()
resolver = PresetResolver(project_root)
layers = resolver.collect_all_layers(template_name)
safe_template_name = _escape_markup(str(template_name))
if layers:
# Use the highest-priority layer for display because the final output
# may be composed and may not map to resolve_with_source()'s single path.
display_layer = layers[0]
console.print(f" [bold]{template_name}[/bold]: {display_layer['path']}")
console.print(f" [dim](top layer from: {display_layer['source']})[/dim]")
console.print(
f" [bold]{safe_template_name}[/bold]: "
f"{_escape_markup(str(display_layer['path']))}"
)
console.print(
f" [dim](top layer from: "
f"{_escape_markup(str(display_layer['source']))})[/dim]"
)
has_composition = (
layers[0]["strategy"] != "replace"
@@ -328,7 +380,10 @@ def preset_resolve(
composed = resolver.resolve_content(template_name)
except Exception as exc:
composed = None
console.print(f" [yellow]Warning: composition error: {exc}[/yellow]")
console.print(
f" [yellow]Warning: composition error: "
f"{_escape_markup(str(exc))}[/yellow]"
)
if composed is None:
console.print(" [yellow]Warning: composition cannot produce output (no base layer with 'replace' strategy)[/yellow]")
else:
@@ -351,15 +406,27 @@ def preset_resolve(
strategy_label = layer["strategy"]
if strategy_label == "replace" and i == 0:
strategy_label = "base"
console.print(f" {i + 1}. [{strategy_label}] {layer['source']}{layer['path']}")
# Escape the literal bracket (\[) so Rich renders `[<strategy>]`
# instead of parsing it as a style tag and swallowing the label,
# mirroring `workflow info`'s step-graph line.
console.print(
f" {i + 1}. \\[{_escape_markup(str(strategy_label))}] "
f"{_escape_markup(str(layer['source']))}"
f"{_escape_markup(str(layer['path']))}"
)
else:
# No layers found — fall back to resolve_with_source for non-composition cases
result = resolver.resolve_with_source(template_name)
if result:
console.print(f" [bold]{template_name}[/bold]: {result['path']}")
console.print(f" [dim](from: {result['source']})[/dim]")
console.print(
f" [bold]{safe_template_name}[/bold]: "
f"{_escape_markup(str(result['path']))}"
)
console.print(
f" [dim](from: {_escape_markup(str(result['source']))})[/dim]"
)
else:
console.print(f" [yellow]{template_name}[/yellow]: not found")
console.print(f" [yellow]{safe_template_name}[/yellow]: not found")
console.print(" [dim]No template with this name exists in the resolution stack[/dim]")
@@ -373,28 +440,38 @@ def preset_info(
from . import PresetCatalog, PresetManager, PresetError
project_root = _require_specify_project()
safe_preset_id = _escape_markup(str(preset_id))
# Check if installed locally first
manager = PresetManager(project_root)
local_pack = manager.get_pack(preset_id)
if local_pack:
console.print(f"\n[bold cyan]Preset: {local_pack.name}[/bold cyan]\n")
console.print(f" ID: {local_pack.id}")
console.print(f" Version: {local_pack.version}")
console.print(f" Description: {local_pack.description}")
console.print(
f"\n[bold cyan]Preset: {_escape_markup(str(local_pack.name))}[/bold cyan]\n"
)
console.print(f" ID: {_escape_markup(str(local_pack.id))}")
console.print(f" Version: {_escape_markup(str(local_pack.version))}")
console.print(
f" Description: {_escape_markup(str(local_pack.description))}"
)
if local_pack.author:
console.print(f" Author: {local_pack.author}")
if local_pack.tags:
console.print(f" Tags: {', '.join(str(t) for t in local_pack.tags)}")
console.print(f" Author: {_escape_markup(str(local_pack.author))}")
local_tags = local_pack.tags
if isinstance(local_tags, list) and local_tags:
tags_str = _escape_markup(", ".join(str(t) for t in local_tags))
console.print(f" Tags: {tags_str}")
console.print(f" Templates: {len(local_pack.templates)}")
for tmpl in local_pack.templates:
console.print(f" - {tmpl['name']} ({tmpl['type']}): {tmpl.get('description', '')}")
tmpl_name = _escape_markup(str(tmpl['name']))
tmpl_type = _escape_markup(str(tmpl['type']))
tmpl_desc = _escape_markup(str(tmpl.get('description', '')))
console.print(f" - {tmpl_name} ({tmpl_type}): {tmpl_desc}")
repo = local_pack.data.get("preset", {}).get("repository")
if repo:
console.print(f" Repository: {repo}")
console.print(f" Repository: {_escape_markup(str(repo))}")
license_val = local_pack.data.get("preset", {}).get("license")
if license_val:
console.print(f" License: {license_val}")
console.print(f" License: {_escape_markup(str(license_val))}")
console.print("\n [green]Status: installed[/green]")
# Get priority from registry
pack_metadata = manager.registry.get(preset_id)
@@ -414,20 +491,33 @@ def preset_info(
console.print(f"[red]Error:[/red] Preset '{preset_id}' not found (not installed and not in catalog)")
raise typer.Exit(1)
console.print(f"\n[bold cyan]Preset: {pack_info.get('name', preset_id)}[/bold cyan]\n")
console.print(f" ID: {pack_info['id']}")
console.print(f" Version: {pack_info.get('version', '?')}")
console.print(f" Description: {pack_info.get('description', '')}")
name = _escape_markup(str(pack_info.get("name", preset_id)))
console.print(f"\n[bold cyan]Preset: {name}[/bold cyan]\n")
console.print(f" ID: {_escape_markup(str(pack_info['id']))}")
console.print(
f" Version: {_escape_markup(str(pack_info.get('version', '?')))}"
)
console.print(
f" Description: {_escape_markup(str(pack_info.get('description', '')))}"
)
if pack_info.get("author"):
console.print(f" Author: {pack_info['author']}")
if pack_info.get("tags"):
console.print(f" Tags: {', '.join(str(t) for t in pack_info['tags'])}")
console.print(
f" Author: {_escape_markup(str(pack_info['author']))}"
)
catalog_tags = pack_info.get("tags", [])
if isinstance(catalog_tags, list) and catalog_tags:
catalog_tags_str = _escape_markup(", ".join(str(t) for t in catalog_tags))
console.print(f" Tags: {catalog_tags_str}")
if pack_info.get("repository"):
console.print(f" Repository: {pack_info['repository']}")
console.print(
f" Repository: {_escape_markup(str(pack_info['repository']))}"
)
if pack_info.get("license"):
console.print(f" License: {pack_info['license']}")
console.print(
f" License: {_escape_markup(str(pack_info['license']))}"
)
console.print("\n [yellow]Status: not installed[/yellow]")
console.print(f" Install with: [cyan]specify preset add {preset_id}[/cyan]")
console.print(f" Install with: [cyan]specify preset add {safe_preset_id}[/cyan]")
console.print()
@@ -662,10 +752,15 @@ def preset_catalog_add(
console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
raise typer.Exit(1)
# Only rendering is escaped — the raw values are what get persisted and
# compared below, so a name containing markup still round-trips exactly.
safe_name = _escape_markup(str(name))
safe_url = _escape_markup(str(url))
# Check for duplicate name
for existing in catalogs:
if isinstance(existing, dict) and existing.get("name") == name:
console.print(f"[yellow]Warning:[/yellow] A catalog named '{name}' already exists.")
console.print(f"[yellow]Warning:[/yellow] A catalog named '{safe_name}' already exists.")
console.print("Use 'specify preset catalog remove' first, or choose a different name.")
raise typer.Exit(1)
@@ -681,10 +776,11 @@ def preset_catalog_add(
config_path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
install_label = "install allowed" if install_allowed else "discovery only"
console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})")
console.print(f" URL: {url}")
console.print(f"\n[green]✓[/green] Added catalog '[bold]{safe_name}[/bold]' ({install_label})")
console.print(f" URL: {safe_url}")
console.print(f" Priority: {priority}")
console.print(f"\nConfig saved to {_display_project_path(project_root, config_path)}")
config_label = _escape_markup(str(_display_project_path(project_root, config_path)))
console.print(f"\nConfig saved to {config_label}")
@preset_catalog_app.command("remove")
@@ -712,17 +808,20 @@ def preset_catalog_remove(
if not isinstance(catalogs, list):
console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
raise typer.Exit(1)
# Rendering only — the raw name drives the comparison below.
safe_name = _escape_markup(str(name))
original_count = len(catalogs)
catalogs = [c for c in catalogs if isinstance(c, dict) and c.get("name") != name]
if len(catalogs) == original_count:
console.print(f"[red]Error:[/red] Catalog '{name}' not found.")
console.print(f"[red]Error:[/red] Catalog '{safe_name}' not found.")
raise typer.Exit(1)
config["catalogs"] = catalogs
config_path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
console.print(f"[green]✓[/green] Removed catalog '{name}'")
console.print(f"[green]✓[/green] Removed catalog '{safe_name}'")
if not catalogs:
console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]")

View File

@@ -272,27 +272,56 @@ _BASH_FORMAT_COMMAND_RE = re.compile(
_POWERSHELL_FORMAT_COMMAND_RE = re.compile(
r"Format-SpecKitCommand\s+-CommandName\s+(['\"])([A-Za-z0-9_.-]+)\1(?:\s+-RepoRoot\s+[^\r\n]+)?"
)
_PYTHON_FORMAT_COMMAND_RETURN_RE = re.compile(
r'return f"/speckit\{separator\}\{name\}"'
)
_BASH_FORMATTER_RETURN_RE = re.compile(
r'''printf '/speckit%s%s\\n' "\$separator" "\$command_name"'''
)
_POWERSHELL_FORMATTER_RETURN_RE = re.compile(
r'return "/speckit\$separator\$name"'
)
def _format_speckit_command(command_name: str, separator: str) -> str:
def _format_speckit_command(
command_name: str, separator: str, prefix: str = "/"
) -> str:
name = command_name.strip().lstrip("/")
if name.startswith("speckit."):
name = name[len("speckit.") :]
elif name.startswith("speckit-"):
name = name[len("speckit-") :]
name = name.replace(".", separator)
return f"/speckit{separator}{name}"
return f"{prefix}speckit{separator}{name}"
def _resolve_dynamic_command_refs(content: str, separator: str) -> str:
def _resolve_dynamic_command_refs(
content: str, separator: str, prefix: str = "/"
) -> str:
"""Render script runtime command helpers for managed shared infra copies."""
bash_prefix = r"\$" if prefix == "$" else prefix
content = _BASH_FORMAT_COMMAND_RE.sub(
lambda match: _format_speckit_command(match.group(2), separator),
lambda match: _format_speckit_command(
match.group(2), separator, bash_prefix
),
content,
)
return _POWERSHELL_FORMAT_COMMAND_RE.sub(
lambda match: f"'{_format_speckit_command(match.group(2), separator)}'",
content = _POWERSHELL_FORMAT_COMMAND_RE.sub(
lambda match: f"'{_format_speckit_command(match.group(2), separator, prefix)}'",
content,
)
content = _BASH_FORMATTER_RETURN_RE.sub(
f'''printf '{prefix}speckit%s%s\\\\n' "$separator" "$command_name"''',
content,
)
powershell_prefix = "`$" if prefix == "$" else prefix
content = _POWERSHELL_FORMATTER_RETURN_RE.sub(
f'return "{powershell_prefix}speckit$separator$name"',
content,
)
return _PYTHON_FORMAT_COMMAND_RETURN_RE.sub(
f'return f"{prefix}speckit{{separator}}{{name}}"',
content,
)
@@ -305,6 +334,7 @@ def refresh_shared_templates(
repo_root: Path,
console: Any,
invoke_separator: str,
invoke_prefix: str = "/",
force: bool = False,
) -> None:
"""Refresh default-sensitive shared templates without touching scripts."""
@@ -336,7 +366,9 @@ def refresh_shared_templates(
continue
content = src.read_text(encoding="utf-8")
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
content = IntegrationBase.resolve_command_refs(
content, invoke_separator, invoke_prefix
)
planned_updates.append((dst, rel, content))
for dst, rel, content in planned_updates:
@@ -363,6 +395,7 @@ def install_shared_infra(
console: Any,
force: bool = False,
invoke_separator: str = ".",
invoke_prefix: str = "/",
refresh_managed: bool = False,
refresh_hint: str | None = None,
) -> bool:
@@ -516,8 +549,12 @@ def install_shared_infra(
if not _ensure_or_bucket_dir(dst_path.parent):
continue
content = src_path.read_text(encoding="utf-8")
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
content = _resolve_dynamic_command_refs(content, invoke_separator)
content = IntegrationBase.resolve_command_refs(
content, invoke_separator, invoke_prefix
)
content = _resolve_dynamic_command_refs(
content, invoke_separator, invoke_prefix
)
planned_copies.append(
(
dst_path,
@@ -566,7 +603,9 @@ def install_shared_infra(
continue
content = src.read_text(encoding="utf-8")
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
content = IntegrationBase.resolve_command_refs(
content, invoke_separator, invoke_prefix
)
planned_templates.append((dst, rel, content))
for dst_path, rel, content, mode in planned_copies:

View File

@@ -21,10 +21,17 @@ from rich.markup import escape as _escape_markup
from .._console import console, err_console
from .._download_security import (
archive_format_from_content_type,
archive_format_from_name,
archive_suffix,
detect_archive_format,
is_https_or_localhost_http,
is_safe_download_redirect,
read_response_limited,
safe_extract_archive,
)
from .._project import _resolve_init_dir_override
from ..shared_infra import verify_archive_sha256
workflow_app = typer.Typer(
name="workflow",
@@ -455,6 +462,42 @@ def _read_response_within_limit(response, max_bytes: int | None = None) -> bytes
return b"".join(chunks)
def _workflow_yaml_is_declared(
source_name: str, content_type: str | None
) -> bool:
"""Return whether response metadata explicitly identifies workflow YAML."""
from urllib.parse import urlparse
path = urlparse(source_name).path.casefold()
media_type = (content_type or "").split(";", 1)[0].strip().casefold()
return path.endswith((".yml", ".yaml")) or media_type in {
"application/yaml",
"application/x-yaml",
"text/yaml",
"text/x-yaml",
}
def _sniff_workflow_archive_format(data: bytes):
"""Return a supported archive format when suffixless response bytes match."""
from io import BytesIO
try:
return detect_archive_format(
Path("workflow-download"),
archive_file=BytesIO(data),
)
except ValueError:
return None
def _enforce_workflow_yaml_size(data: bytes) -> None:
if len(data) > _MAX_WORKFLOW_YAML_BYTES:
raise ValueError(
f"response exceeds the {_MAX_WORKFLOW_YAML_BYTES}-byte workflow size limit"
)
def _validate_workflow_id_or_exit(workflow_id: str) -> None:
"""Validate that ``workflow_id`` is a safe installed-workflow directory name."""
if (
@@ -879,6 +922,231 @@ def _discard_committed_backup_file(backup_file: Path | None) -> None:
)
def _workflow_package_root(extracted_root: Path) -> Path:
"""Resolve a root-level or single-nested workflow package."""
if (extracted_root / "workflow.yml").is_file():
return extracted_root
entries = list(extracted_root.iterdir())
if (
len(entries) == 1
and entries[0].is_dir()
and not entries[0].is_symlink()
and (entries[0] / "workflow.yml").is_file()
):
return entries[0]
raise ValueError(
"Archive must contain workflow.yml at its root or in exactly one "
"top-level directory"
)
def _validate_local_workflow_package(package_dir: Path) -> None:
"""Reject links and special files before copying a local package."""
import stat
for root, dirnames, filenames in os.walk(package_dir, followlinks=False):
root_path = Path(root)
for name in [*dirnames, *filenames]:
path = root_path / name
mode = path.lstat().st_mode
if stat.S_ISLNK(mode):
raise ValueError(f"Workflow package contains symlink: {path}")
if not stat.S_ISDIR(mode) and not stat.S_ISREG(mode):
raise ValueError(f"Workflow package contains unsupported file: {path}")
def _workflow_package_has_companions(package_dir: Path) -> bool:
"""Return whether a directory contains anything beyond workflow.yml."""
return any(path.name != "workflow.yml" for path in package_dir.iterdir())
def _install_workflow_package(
project_root: Path,
workflows_dir: Path,
package_dir: Path,
source_label: str,
*,
expected_id: str | None = None,
expected_version: str | None = None,
expected_installed_version: str | None = None,
catalog_info: dict[str, Any] | None = None,
) -> None:
"""Validate and atomically install a complete workflow package directory."""
import shutil
import tempfile
from .engine import WorkflowDefinition, validate_workflow
workflow_file = package_dir / "workflow.yml"
try:
_validate_local_workflow_package(package_dir)
workflow_bytes = workflow_file.read_bytes()
definition = WorkflowDefinition.from_string(workflow_bytes.decode("utf-8"))
except (OSError, UnicodeDecodeError, ValueError, yaml.YAMLError) as exc:
console.print(
f"[red]Error:[/red] Invalid workflow package: "
f"{_escape_markup(str(exc))}"
)
raise typer.Exit(1)
errors = validate_workflow(definition)
if errors:
console.print("[red]Error:[/red] Workflow validation failed:")
for error in errors:
console.print(f"{_escape_markup(str(error))}")
raise typer.Exit(1)
if not isinstance(definition.id, str) or not definition.id.strip():
console.print("[red]Error:[/red] Workflow definition has an empty or missing 'id'")
raise typer.Exit(1)
if expected_id is not None and definition.id != expected_id:
console.print(
f"[red]Error:[/red] Workflow ID in YAML "
f"({_escape_markup(repr(definition.id))}) does not match the requested "
f"workflow ID ({_escape_markup(repr(expected_id))})."
)
raise typer.Exit(1)
if expected_version is not None and str(definition.version) != expected_version:
console.print(
f"[red]Error:[/red] Downloaded workflow version "
f"({_escape_markup(str(definition.version))}) does not match the catalog "
f"version ({_escape_markup(expected_version)})."
)
raise typer.Exit(1)
dest_dir = _safe_workflow_id_dir(workflows_dir, definition.id)
staged_dir = Path(
tempfile.mkdtemp(prefix=f".{definition.id}.installing-", dir=workflows_dir)
)
try:
package_root = package_dir.resolve()
def ignore_reserved_package_entries(
source: str, names: list[str]
) -> set[str]:
if Path(source).resolve() == package_root and "overlays" in names:
return {"overlays"}
return set()
shutil.copytree(
package_dir,
staged_dir,
dirs_exist_ok=True,
ignore=ignore_reserved_package_entries,
)
except OSError as exc:
shutil.rmtree(staged_dir, ignore_errors=True)
console.print(
f"[red]Error:[/red] Failed to stage workflow package: "
f"{_escape_markup(str(exc))}"
)
raise typer.Exit(1)
backup_dir: Path | None = None
try:
with _workflow_install_transaction(project_root):
registry = _open_workflow_registry(project_root)
existing = registry.get(definition.id)
if expected_installed_version is not None and (
not isinstance(existing, dict)
or existing.get("source") != "catalog"
or str(existing.get("version")) != expected_installed_version
):
console.print(
f"[yellow]Warning:[/yellow] Workflow "
f"'{_escape_markup(definition.id)}' changed during update; "
"rerun the command."
)
raise typer.Exit(1)
if dest_dir.exists():
backup_dir = Path(
tempfile.mkdtemp(
prefix=f".{definition.id}.backup-",
dir=workflows_dir,
)
)
backup_dir.rmdir()
os.replace(dest_dir, backup_dir)
try:
os.replace(staged_dir, dest_dir)
except BaseException:
if backup_dir is not None:
os.replace(backup_dir, dest_dir)
backup_dir = None
raise
entry = {
"name": definition.name,
"version": definition.version,
"description": definition.description,
"source": source_label,
}
if catalog_info is not None:
entry.update(
{
"source": "catalog",
"catalog_name": catalog_info.get("_catalog_name", ""),
"url": catalog_info.get("url", ""),
}
)
if isinstance(existing, dict) and not existing.get("enabled", True):
entry["enabled"] = False
try:
registry.add(definition.id, entry)
except (OSError, TypeError, ValueError):
failed_dir: Path | None = None
try:
failed_dir = Path(
tempfile.mkdtemp(
prefix=f".{definition.id}.failed-",
dir=workflows_dir,
)
)
failed_dir.rmdir()
os.replace(dest_dir, failed_dir)
if backup_dir is not None:
os.replace(backup_dir, dest_dir)
backup_dir = None
except OSError as rollback_exc:
console.print(
"[yellow]Warning:[/yellow] Failed to fully restore the prior "
f"workflow package: {_escape_markup(str(rollback_exc))}"
)
finally:
if failed_dir is not None and failed_dir.exists():
try:
shutil.rmtree(failed_dir)
except OSError as cleanup_exc:
console.print(
"[yellow]Warning:[/yellow] Could not remove failed "
f"workflow package: {_escape_markup(str(cleanup_exc))}"
)
raise
except typer.Exit:
raise
except (OSError, TypeError, ValueError) as exc:
console.print(
f"[red]Error:[/red] Failed to install workflow package: "
f"{_escape_markup(str(exc))}"
)
raise typer.Exit(1)
finally:
if staged_dir.exists():
shutil.rmtree(staged_dir, ignore_errors=True)
if backup_dir is not None:
try:
shutil.rmtree(backup_dir)
except OSError as exc:
console.print(
"[yellow]Warning:[/yellow] Workflow installed, but its backup "
f"directory could not be removed: {_escape_markup(str(exc))}"
)
console.print(
f"[green]✓[/green] Workflow '{_escape_markup(definition.name)}' "
f"({_escape_markup(definition.id)}) installed"
)
# Root helper re-fetched at call time so test monkeypatching of
# `specify_cli._require_specify_project` keeps working after the move.
def _require_specify_project(*args, **kwargs):
@@ -889,6 +1157,18 @@ def _require_specify_project(*args, **kwargs):
return project_root
def _failed_step_error(state: Any) -> str | None:
"""Terminal error for a failed/aborted run, if any.
Returns the run-level error persisted by the engine at the moment
the run terminated. Returns ``None`` for non-terminal statuses so
the caller can print unconditionally.
"""
if getattr(state.status, "value", state.status) not in ("failed", "aborted"):
return None
return getattr(state, "error", None)
def _workflow_run_payload(state: Any) -> dict[str, Any]:
"""Machine-readable summary of a run/resume outcome."""
payload = {
@@ -901,6 +1181,9 @@ def _workflow_run_payload(state: Any) -> dict[str, Any]:
gate = _gate_outcome(state)
if gate is not None:
payload["gate"] = gate
error = _failed_step_error(state)
if error is not None:
payload["error"] = error
return payload
@@ -1054,7 +1337,18 @@ def workflow_run(
load_custom_steps(project_root)
engine = WorkflowEngine(project_root)
if not json_output:
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
# Escape the literal bracket (\[) so Rich renders `[<step id>]` instead
# of parsing it as a style tag named after the step id -- which it
# silently swallows (losing the only identifying content on the line),
# applies as formatting when the id happens to be a real style such as
# `bold`, or raises MarkupError when the id forms a closing tag (`/`),
# failing the whole run. Escape the interpolated values too, since both
# come from workflow YAML. Mirrors the `\[<type>]` step-graph precedent
# in workflow_info below.
engine.on_step_start = lambda sid, label: console.print(
f" \u25b8 \\[{_escape_markup(str(sid))}] "
f"{_escape_markup(str(label))} \u2026"
)
err = _error_console(json_output)
@@ -1150,6 +1444,10 @@ def workflow_run(
console.print(f"\n[{color}]Status: {state.status.value}[/{color}]")
console.print(f"[dim]Run ID: {state.run_id}[/dim]")
err_msg = _failed_step_error(state)
if err_msg:
console.print(f"[red]Error:[/red] {_escape_markup(err_msg)}")
if state.status.value == "paused":
console.print(f"\nResume with: [cyan]specify workflow resume {state.run_id}[/cyan]")
@@ -1176,7 +1474,18 @@ def workflow_resume(
load_custom_steps(project_root)
engine = WorkflowEngine(project_root)
if not json_output:
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
# Escape the literal bracket (\[) so Rich renders `[<step id>]` instead
# of parsing it as a style tag named after the step id -- which it
# silently swallows (losing the only identifying content on the line),
# applies as formatting when the id happens to be a real style such as
# `bold`, or raises MarkupError when the id forms a closing tag (`/`),
# failing the whole run. Escape the interpolated values too, since both
# come from workflow YAML. Mirrors the `\[<type>]` step-graph precedent
# in workflow_info below.
engine.on_step_start = lambda sid, label: console.print(
f" \u25b8 \\[{_escape_markup(str(sid))}] "
f"{_escape_markup(str(label))} \u2026"
)
inputs = _parse_input_values(input_values, json_output=json_output)
err = _error_console(json_output)
@@ -1249,6 +1558,10 @@ def workflow_resume(
color = status_colors.get(state.status.value, "white")
console.print(f"\n[{color}]Status: {state.status.value}[/{color}]")
err_msg = _failed_step_error(state)
if err_msg:
console.print(f"[red]Error:[/red] {_escape_markup(err_msg)}")
raise typer.Exit(_run_outcome_exit_code(state.status.value))
@@ -1316,6 +1629,10 @@ def workflow_status(
if state.current_step_id:
console.print(f" Current: {state.current_step_id}")
err_msg = _failed_step_error(state)
if err_msg:
console.print(f" [red]Error: {_escape_markup(err_msg)}[/red]")
if state.step_results:
console.print(f"\n [bold]Steps ({len(state.step_results)}):[/bold]")
for step_id, step_data in state.step_results.items():
@@ -1553,16 +1870,48 @@ def workflow_add(
if dev_path.is_file() and dev_path.suffix.lower() in (".yml", ".yaml"):
_validate_and_install_local(dev_path, str(dev_path))
return
if dev_path.is_file() and archive_format_from_name(str(dev_path)) is not None:
import tempfile
with tempfile.TemporaryDirectory(
prefix="speckit-workflow-archive-"
) as tmpdir:
extracted_root = Path(tmpdir)
try:
safe_extract_archive(dev_path, extracted_root)
package_root = _workflow_package_root(extracted_root)
except ValueError as exc:
console.print(
f"[red]Error:[/red] Invalid workflow archive: "
f"{_escape_markup(str(exc))}"
)
raise typer.Exit(1)
_install_workflow_package(
project_root,
workflows_dir,
package_root,
str(dev_path),
)
return
if dev_path.is_dir():
dev_wf_file = dev_path / "workflow.yml"
if not dev_wf_file.is_file():
console.print(f"[red]Error:[/red] No workflow.yml found in {_escape_markup(source)}")
raise typer.Exit(1)
_validate_and_install_local(dev_wf_file, str(dev_path))
if _workflow_package_has_companions(dev_path):
_install_workflow_package(
project_root,
workflows_dir,
dev_path,
str(dev_path),
)
else:
_validate_and_install_local(dev_wf_file, str(dev_path))
return
console.print(
"[red]Error:[/red] --dev source must be a workflow YAML file or a "
f"directory containing workflow.yml: {_escape_markup(source)}"
"[red]Error:[/red] --dev source must be a workflow YAML file, "
"supported archive, or directory containing workflow.yml: "
f"{_escape_markup(source)}"
)
raise typer.Exit(1)
@@ -1625,6 +1974,7 @@ def workflow_add(
import tempfile
tmp_path: Path | None = None
downloaded_archive_format = None
try:
with _open_url(
download_url,
@@ -1638,13 +1988,48 @@ def workflow_add(
f"[red]Error:[/red] URL redirected to non-HTTPS: {_escape_markup(final_url)}"
)
raise typer.Exit(1)
with tempfile.NamedTemporaryFile(suffix=".yml", delete=False) as tmp:
content_type = (
resp.getheader("Content-Type")
if hasattr(resp, "getheader")
else None
)
downloaded_archive_format = (
archive_format_from_name(final_url)
or archive_format_from_name(download_url)
or archive_format_from_content_type(content_type)
)
declared_yaml = _workflow_yaml_is_declared(final_url, content_type)
suffix = (
archive_suffix(downloaded_archive_format)
if downloaded_archive_format is not None
else ".yml" if declared_yaml else ".download"
)
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
# Assign tmp_path immediately: NamedTemporaryFile(delete=False)
# creates the file on disk right away, before any bytes are
# written, so a failure in the size-limited read below must
# still be able to find and remove it.
tmp_path = Path(tmp.name)
tmp.write(_read_response_within_limit(resp))
if downloaded_archive_format is not None:
downloaded_content = read_response_limited(
resp,
error_type=ValueError,
label="workflow archive download",
)
elif declared_yaml:
downloaded_content = _read_response_within_limit(resp)
else:
downloaded_content = read_response_limited(
resp,
error_type=ValueError,
label="workflow download",
)
downloaded_archive_format = (
_sniff_workflow_archive_format(downloaded_content)
)
if downloaded_archive_format is None:
_enforce_workflow_yaml_size(downloaded_content)
tmp.write(downloaded_content)
except typer.Exit:
raise
except Exception as exc:
@@ -1664,13 +2049,38 @@ def workflow_add(
console.print(f"[red]Error:[/red] Failed to download workflow: {_escape_markup(str(exc))}")
raise typer.Exit(1)
try:
# When installed via --from, the positional argument names the
# workflow the user expects — enforce it like the catalog branch.
_validate_and_install_local(
tmp_path,
download_url,
expected_id=source if from_url else None,
)
if downloaded_archive_format is None:
_validate_and_install_local(
tmp_path,
download_url,
expected_id=source if from_url else None,
)
else:
with tempfile.TemporaryDirectory(
prefix="speckit-workflow-archive-"
) as extract_dir:
extracted_root = Path(extract_dir)
try:
safe_extract_archive(
tmp_path,
extracted_root,
source_name=final_url,
content_type=content_type,
)
package_root = _workflow_package_root(extracted_root)
except ValueError as exc:
console.print(
f"[red]Error:[/red] Invalid workflow archive: "
f"{_escape_markup(str(exc))}"
)
raise typer.Exit(1)
_install_workflow_package(
project_root,
workflows_dir,
package_root,
download_url,
expected_id=source if from_url else None,
)
finally:
# Best-effort: _validate_and_install_local may already have
# committed the file + registry entry (success) or already
@@ -1694,12 +2104,46 @@ def workflow_add(
if source_path.is_file() and source_path.suffix.lower() in (".yml", ".yaml"):
_validate_and_install_local(source_path, str(source_path))
return
elif (
source_path.is_file()
and archive_format_from_name(str(source_path)) is not None
):
import tempfile
with tempfile.TemporaryDirectory(
prefix="speckit-workflow-archive-"
) as tmpdir:
extracted_root = Path(tmpdir)
try:
safe_extract_archive(source_path, extracted_root)
package_root = _workflow_package_root(extracted_root)
except ValueError as exc:
console.print(
f"[red]Error:[/red] Invalid workflow archive: "
f"{_escape_markup(str(exc))}"
)
raise typer.Exit(1)
_install_workflow_package(
project_root,
workflows_dir,
package_root,
str(source_path),
)
return
elif source_path.is_dir():
wf_file = source_path / "workflow.yml"
if not wf_file.is_file():
console.print(f"[red]Error:[/red] No workflow.yml found in {_escape_markup(source)}")
raise typer.Exit(1)
_validate_and_install_local(wf_file, str(source_path))
if _workflow_package_has_companions(source_path):
_install_workflow_package(
project_root,
workflows_dir,
source_path,
str(source_path),
)
else:
_validate_and_install_local(wf_file, str(source_path))
return
# Try from catalog
@@ -1804,6 +2248,9 @@ def _install_workflow_from_catalog(
)
raise typer.Exit(1)
original_workflow_url = workflow_url
downloaded_archive_format = None
archive_content_type = None
try:
from specify_cli.authentication.http import open_url as _open_url
from specify_cli.authentication.http import github_provider_hosts as _github_provider_hosts
@@ -1835,10 +2282,38 @@ def _install_workflow_from_catalog(
f"[red]Error:[/red] Workflow '{safe_wf_id}' redirected to non-HTTPS URL: {_escape_markup(final_url)}"
)
raise typer.Exit(1)
archive_content_type = (
response.getheader("Content-Type")
if hasattr(response, "getheader")
else None
)
downloaded_archive_format = (
archive_format_from_name(final_url)
or archive_format_from_name(original_workflow_url)
or archive_format_from_content_type(archive_content_type)
)
# Written to the staging file, never workflow_file directly, so a
# reinstall's prior working copy is never touched until the
# atomic commit below runs.
downloaded_content = _read_response_within_limit(response)
if downloaded_archive_format is not None:
downloaded_content = read_response_limited(
response,
error_type=ValueError,
label=f"workflow '{workflow_id}' archive download",
)
elif _workflow_yaml_is_declared(final_url, archive_content_type):
downloaded_content = _read_response_within_limit(response)
else:
downloaded_content = read_response_limited(
response,
error_type=ValueError,
label=f"workflow '{workflow_id}' download",
)
downloaded_archive_format = _sniff_workflow_archive_format(
downloaded_content
)
if downloaded_archive_format is None:
_enforce_workflow_yaml_size(downloaded_content)
staged_file.write_bytes(downloaded_content)
except typer.Exit:
raise
@@ -1847,6 +2322,59 @@ def _install_workflow_from_catalog(
console.print(f"[red]Error:[/red] Failed to install workflow '{safe_wf_id}' from catalog: {_escape_markup(str(exc))}")
raise typer.Exit(1)
if downloaded_archive_format is not None:
try:
verify_archive_sha256(
downloaded_content,
info.get("sha256"),
workflow_id,
ValueError,
)
import tempfile
from io import BytesIO
with tempfile.TemporaryDirectory(
prefix="speckit-workflow-archive-"
) as extract_dir:
extracted_root = Path(extract_dir)
safe_extract_archive(
staged_file.path,
extracted_root,
archive_file=BytesIO(downloaded_content),
source_name=original_workflow_url,
content_type=archive_content_type,
)
package_root = _workflow_package_root(extracted_root)
_safe_discard_staged_workflow_file(
staged_file,
workflow_dir,
existed_before,
)
_install_workflow_package(
project_root,
workflows_dir,
package_root,
workflow_url,
expected_id=workflow_id,
expected_version=expected_version,
expected_installed_version=expected_installed_version,
catalog_info={**info, "url": workflow_url},
)
except typer.Exit:
raise
except (OSError, ValueError) as exc:
_safe_discard_staged_workflow_file(
staged_file,
workflow_dir,
existed_before,
)
console.print(
f"[red]Error:[/red] Invalid workflow archive: "
f"{_escape_markup(str(exc))}"
)
raise typer.Exit(1)
return
# Validate the downloaded workflow (still staged, not yet committed)
# before registering.
try:
@@ -2326,7 +2854,7 @@ def workflow_search(
if desc:
console.print(f" {_escape_markup(str(desc))}")
tags = wf.get("tags", [])
if tags:
if isinstance(tags, list) and tags:
safe_tags = _escape_markup(", ".join(str(t) for t in tags))
console.print(f" [dim]Tags: {safe_tags}[/dim]")
console.print()
@@ -2424,8 +2952,9 @@ def workflow_info(
console.print(f" Version: {_escape_markup(str(info.get('version', '?')))}")
if info.get("description"):
console.print(f" Description: {_escape_markup(str(info['description']))}")
if info.get("tags"):
safe_tags = _escape_markup(", ".join(str(t) for t in info["tags"]))
info_tags = info.get("tags", [])
if isinstance(info_tags, list) and info_tags:
safe_tags = _escape_markup(", ".join(str(t) for t in info_tags))
console.print(f" Tags: {safe_tags}")
console.print(" [yellow]Not installed[/yellow]")
else:
@@ -2526,9 +3055,10 @@ def workflow_step_list():
console.print(" [bold]Custom (installed):[/bold]")
for key in sorted(installed):
meta = installed[key] or {}
name = meta.get("name", key)
version = meta.get("version", "?")
console.print(f" • [bold]{name}[/bold] ({key}) v{version}")
name = _escape_markup(str(meta.get("name", key)))
safe_key = _escape_markup(str(key))
version = _escape_markup(str(meta.get("version", "?")))
console.print(f" • [bold]{name}[/bold] ({safe_key}) v{version}")
console.print()
if not built_in and not installed:
@@ -3072,13 +3602,15 @@ def workflow_step_search(
install_note = (
"" if step.get("_install_allowed", True) else " [dim](discovery only)[/dim]"
)
name = _escape_markup(str(step.get("name", step.get("id", "?"))))
step_id = _escape_markup(str(step.get("id", "?")))
version = _escape_markup(str(step.get("version", "?")))
console.print(
f" [bold]{step.get('name', step.get('id', '?'))}[/bold]"
f" ({step.get('id', '?')}) v{step.get('version', '?')}{install_note}"
f" [bold]{name}[/bold] ({step_id}) v{version}{install_note}"
)
desc = step.get("description", "")
if desc:
console.print(f" {desc}")
console.print(f" {_escape_markup(str(desc))}")
console.print()
@@ -3091,6 +3623,7 @@ def workflow_step_info(
from .catalog import StepCatalog, StepCatalogError, StepRegistry
project_root = _require_specify_project()
safe_step_id = _escape_markup(str(step_id))
registry = StepRegistry(project_root)
installed_meta = registry.get(step_id)
@@ -3100,20 +3633,27 @@ def workflow_step_info(
is_builtin = builtin_step is not None and not installed_meta
if is_builtin:
console.print(f"\n[bold cyan]{step_id}[/bold cyan] [dim](built-in)[/dim]")
console.print(f" Type key: {step_id}")
console.print(f"\n[bold cyan]{safe_step_id}[/bold cyan] [dim](built-in)[/dim]")
console.print(f" Type key: {safe_step_id}")
console.print(" [green]Built-in step type[/green]")
return
if installed_meta:
name = _escape_markup(str(installed_meta.get("name", step_id)))
version = _escape_markup(str(installed_meta.get("version", "?")))
console.print(
f"\n[bold cyan]{installed_meta.get('name', step_id)}[/bold cyan] ({step_id})"
f"\n[bold cyan]{name}[/bold cyan] ({safe_step_id})"
)
console.print(f" Version: {installed_meta.get('version', '?')}")
console.print(f" Version: {version}")
if installed_meta.get("author"):
console.print(f" Author: {installed_meta['author']}")
console.print(
f" Author: {_escape_markup(str(installed_meta['author']))}"
)
if installed_meta.get("description"):
console.print(f" Description: {installed_meta['description']}")
console.print(
f" Description: "
f"{_escape_markup(str(installed_meta['description']))}"
)
console.print(" [green]Installed[/green]")
return
@@ -3125,20 +3665,24 @@ def workflow_step_info(
info = None
if info:
name = _escape_markup(str(info.get("name", step_id)))
version = _escape_markup(str(info.get("version", "?")))
console.print(
f"\n[bold cyan]{info.get('name', step_id)}[/bold cyan] ({step_id})"
f"\n[bold cyan]{name}[/bold cyan] ({safe_step_id})"
)
console.print(f" Version: {info.get('version', '?')}")
console.print(f" Version: {version}")
if info.get("author"):
console.print(f" Author: {info['author']}")
console.print(f" Author: {_escape_markup(str(info['author']))}")
if info.get("description"):
console.print(f" Description: {info['description']}")
console.print(
f" Description: {_escape_markup(str(info['description']))}"
)
console.print(" [yellow]Not installed[/yellow]")
console.print(
f"\n Install with: [cyan]specify workflow step add {step_id}[/cyan]"
f"\n Install with: [cyan]specify workflow step add {safe_step_id}[/cyan]"
)
else:
console.print(f"[red]Error:[/red] Step type '{step_id}' not found")
console.print(f"[red]Error:[/red] Step type '{safe_step_id}' not found")
raise typer.Exit(1)

View File

@@ -56,6 +56,9 @@ class StepContext:
#: Current fan-out item (set only inside fan-out iterations).
item: Any = None
#: Whether the current step is executing inside a fan-out template.
inside_fan_out: bool = False
#: Fan-in aggregated results (set only for fan-in steps).
fan_in: dict[str, Any] = field(default_factory=dict)

View File

@@ -335,26 +335,45 @@ class WorkflowCatalog:
if not config_path.exists():
return None
try:
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
except (yaml.YAMLError, OSError, UnicodeError) as exc:
raise WorkflowValidationError(
f"Failed to read catalog config {config_path}: {exc}"
) from exc
# An empty document (or explicit ``null``) parses to None -> this config
# layer contributes nothing, so ``get_active_catalogs`` moves on to the
# next layer (this loader serves both the project and user configs;
# the built-in defaults apply only once every layer has returned None).
# Do NOT coerce with ``or {}`` here: that also turns a FALSY non-mapping
# (top-level ``[]``, ``false``, ``0``, ``''``) into ``{}`` and silently
# swallows it, while a TRUTHY non-mapping (``5``, a bare list) correctly
# raises below -- an inconsistency. Only None means "no document".
if data is None:
return None
if not isinstance(data, dict):
raise WorkflowValidationError(
f"Invalid catalog config: expected a mapping, "
f"got {type(data).__name__}"
)
catalogs_data = data.get("catalogs", [])
if not catalogs_data:
# Empty catalogs list (e.g. after removing last entry)
# is valid — fall back to built-in defaults.
# Same asymmetry as the top level above, one nesting level down: the
# shape check has to run BEFORE the emptiness check, or a FALSY non-list
# (``catalogs: {}``/``''``/``0``/``false``) is silently swallowed as
# "no catalogs" while a TRUTHY non-list (``catalogs: 5``) correctly
# raises. An absent key, an explicit ``catalogs:`` null, and an empty
# list all keep their existing "nothing configured here" behavior --
# only the misreported shapes change.
catalogs_data = data.get("catalogs")
if catalogs_data is None:
return None
if not isinstance(catalogs_data, list):
raise WorkflowValidationError(
f"Invalid catalog config: 'catalogs' must be a list, "
f"got {type(catalogs_data).__name__}"
)
if not catalogs_data:
# Empty catalogs list (e.g. after removing last entry)
# is valid — fall back to built-in defaults.
return None
entries: list[WorkflowCatalogEntry] = []
for idx, item in enumerate(catalogs_data):
@@ -476,6 +495,8 @@ class WorkflowCatalog:
try:
with open(meta_file, encoding="utf-8") as f:
meta = json.load(f)
if not isinstance(meta, dict):
return False
fetched_at = float(meta.get("fetched_at", 0))
return (time.time() - fetched_at) < self.CACHE_DURATION
except (json.JSONDecodeError, OSError, TypeError, ValueError):
@@ -490,8 +511,10 @@ class WorkflowCatalog:
if not force_refresh and self._is_url_cache_valid(entry.url):
try:
with open(cache_file, encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
cached = json.load(f)
if isinstance(cached, dict):
return cached
except (UnicodeDecodeError, json.JSONDecodeError, OSError):
# Ignore invalid/unreadable cache and fall back to fetching from source.
pass
@@ -555,7 +578,9 @@ class WorkflowCatalog:
if cache_file.exists():
try:
with open(cache_file, encoding="utf-8") as f:
return json.load(f)
cached = json.load(f)
if isinstance(cached, dict):
return cached
except (json.JSONDecodeError, ValueError, OSError):
# Stale-cache read failed; let the original fetch error propagate.
pass
@@ -1018,24 +1043,33 @@ class StepCatalog:
if not config_path.exists():
return None
try:
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
except (yaml.YAMLError, OSError, UnicodeError) as exc:
raise StepValidationError(
f"Failed to read catalog config {config_path}: {exc}"
) from exc
# Same two guards as WorkflowCatalog._load_catalog_config above, kept in
# lockstep: this is the step-catalog twin of that loader and read the
# same way. Dropping ``or {}`` stops a falsy non-mapping top level from
# being coerced past the isinstance check, and the ``catalogs`` shape
# check runs before the emptiness check for the same reason.
if data is None:
return None
if not isinstance(data, dict):
raise StepValidationError(
f"Invalid catalog config: expected a mapping, "
f"got {type(data).__name__}"
)
catalogs_data = data.get("catalogs", [])
if not catalogs_data:
catalogs_data = data.get("catalogs")
if catalogs_data is None:
return None
if not isinstance(catalogs_data, list):
raise StepValidationError(
f"Invalid catalog config: 'catalogs' must be a list, "
f"got {type(catalogs_data).__name__}"
)
if not catalogs_data:
return None
entries: list[StepCatalogEntry] = []
for idx, item in enumerate(catalogs_data):
@@ -1156,6 +1190,8 @@ class StepCatalog:
try:
with open(meta_file, encoding="utf-8") as f:
meta = json.load(f)
if not isinstance(meta, dict):
return False
fetched_at = float(meta.get("fetched_at", 0))
return (time.time() - fetched_at) < self.CACHE_DURATION
except (json.JSONDecodeError, OSError, TypeError, ValueError):
@@ -1174,7 +1210,7 @@ class StepCatalog:
cached = json.load(f)
if isinstance(cached, dict):
return cached
except (json.JSONDecodeError, OSError):
except (UnicodeDecodeError, json.JSONDecodeError, OSError):
# Ignore invalid/unreadable cache and fall back to fetching from source.
pass

View File

@@ -308,7 +308,16 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
errors.append("Workflow has no steps defined.")
seen_ids: set[str] = set()
_validate_steps(definition.steps, seen_ids, errors)
# ``input_defs`` maps declared workflow input names to their definitions —
# used by ``_validate_steps`` to cross-reference gate ``verdict_input``
# bindings (both that the name exists and that its ``enum`` permits the
# reset sentinel). ``None`` means the inputs block itself is malformed
# (already reported above); the cross-check is then disabled so one
# authoring mistake does not cascade into N spurious "undeclared" errors.
input_defs: dict[str, Any] | None = (
dict(definition.inputs) if isinstance(definition.inputs, dict) else None
)
_validate_steps(definition.steps, seen_ids, errors, input_defs)
return errors
@@ -317,8 +326,16 @@ def _validate_steps(
steps: list[dict[str, Any]],
seen_ids: set[str],
errors: list[str],
input_defs: dict[str, Any] | None = None,
inside_fan_out: bool = False,
) -> None:
"""Recursively validate a list of steps."""
"""Recursively validate a list of steps.
``input_defs`` maps declared workflow input names to their definitions (or
is ``None`` when the inputs block is malformed). ``inside_fan_out`` is
threaded through nested control-flow steps so gate verdict bindings can be
rejected anywhere inside a fan-out template.
"""
from . import STEP_REGISTRY
for step_config in steps:
@@ -411,30 +428,101 @@ def _validate_steps(
f"unknown or not-yet-declared step id {wid!r}."
)
# Gate verdict_input: fan-out items cannot bind shared workflow inputs
# as per-item verdicts. Outside fan-out, the binding must reference a
# declared workflow input because ``_resolve_inputs`` drops undeclared
# names at both initial run and resume. Only check a non-empty string;
# malformed shapes are already reported by ``GateStep.validate()``.
if step_type == "gate":
verdict_input = step_config.get("verdict_input")
if isinstance(verdict_input, str) and verdict_input:
if inside_fan_out:
errors.append(
f"Gate step {step_id!r}: 'verdict_input' is not "
"supported inside fan-out templates."
)
elif input_defs is not None and verdict_input not in input_defs:
errors.append(
f"Gate step {step_id!r}: 'verdict_input' references "
f"undeclared input {verdict_input!r}."
)
elif input_defs is not None:
# ``on_reject: retry`` resets the bound input to "" before
# pausing, and every later resume re-resolves the persisted
# inputs through ``_coerce_input``. If the input declares an
# ``enum`` that omits "", that reset value is instantly
# illegal: the run pauses fine, but the next resume that
# supplies any input raises "value '' not in allowed
# values", and no verdict can be routed through the gate
# again. Require the enum to admit the sentinel so the
# retry cycle the field advertises is actually reachable.
verdict_def = input_defs.get(verdict_input)
enum_values = (
verdict_def.get("enum")
if isinstance(verdict_def, dict)
else None
)
if (
step_config.get("on_reject") == "retry"
and isinstance(enum_values, list)
and "" not in enum_values
):
errors.append(
f"Gate step {step_id!r}: on_reject='retry' resets "
f"verdict input {verdict_input!r} to '' when the "
f"gate is rejected, but that input's 'enum' does "
f"not allow ''. Add '' to the enum or use "
f"on_reject='abort'/'skip'."
)
# Recursively validate nested steps
for nested_key in ("then", "else", "steps"):
nested = step_config.get(nested_key)
if isinstance(nested, list):
_validate_steps(nested, seen_ids, errors)
_validate_steps(
nested,
seen_ids,
errors,
input_defs,
inside_fan_out=inside_fan_out,
)
# Validate switch cases
cases = step_config.get("cases")
if isinstance(cases, dict):
for _case_key, case_steps in cases.items():
if isinstance(case_steps, list):
_validate_steps(case_steps, seen_ids, errors)
_validate_steps(
case_steps,
seen_ids,
errors,
input_defs,
inside_fan_out=inside_fan_out,
)
# Validate switch default
default = step_config.get("default")
if isinstance(default, list):
_validate_steps(default, seen_ids, errors)
_validate_steps(
default,
seen_ids,
errors,
input_defs,
inside_fan_out=inside_fan_out,
)
# Validate fan-out nested step (template — not added to seen_ids
# since the engine generates parentId:templateId:index at runtime)
fan_step = step_config.get("step")
if isinstance(fan_step, dict):
fan_errors: list[str] = []
_validate_steps([fan_step], set(), fan_errors)
_validate_steps(
[fan_step],
set(),
fan_errors,
input_defs,
inside_fan_out=True,
)
errors.extend(fan_errors)
@@ -560,6 +648,7 @@ class RunState:
self.created_at = datetime.now(timezone.utc).isoformat()
self.updated_at = self.created_at
self.log_entries: list[dict[str, Any]] = []
self.error: str | None = None
@property
def runs_dir(self) -> Path:
@@ -614,6 +703,7 @@ class RunState:
"workflow_dir": self.workflow_dir,
"created_at": self.created_at,
"updated_at": self.updated_at,
"error": self.error,
}
self._atomic_write_json(runs_dir / "state.json", state_data)
self._atomic_write_json(runs_dir / "inputs.json", {"inputs": self.inputs})
@@ -707,6 +797,7 @@ class RunState:
state.workflow_dir = state_data.get("workflow_dir")
state.created_at = state_data.get("created_at", "")
state.updated_at = state_data.get("updated_at", "")
state.error = state_data.get("error")
inputs_path = runs_dir / "inputs.json"
if inputs_path.exists():
@@ -901,6 +992,7 @@ class WorkflowEngine:
return state
except Exception as exc:
state.status = RunStatus.FAILED
state.error = str(exc)
state.append_log({"event": "workflow_failed", "error": str(exc)})
state.save()
raise
@@ -959,6 +1051,7 @@ class WorkflowEngine:
from . import STEP_REGISTRY
state.error = None
state.status = RunStatus.RUNNING
state.save()
@@ -979,6 +1072,7 @@ class WorkflowEngine:
return state
except Exception as exc:
state.status = RunStatus.FAILED
state.error = str(exc)
state.append_log({"event": "resume_failed", "error": str(exc)})
state.save()
raise
@@ -1038,6 +1132,7 @@ class WorkflowEngine:
step_impl = registry.get(step_type)
if not step_impl:
state.status = RunStatus.FAILED
state.error = f"Unknown step type: {step_type!r}"
state.append_log(
{
"event": "step_failed",
@@ -1065,6 +1160,7 @@ class WorkflowEngine:
or step_config.get("input", {}),
"output": result.output,
"status": result.status.value,
"error": result.error,
}
self._record_result(context, state, step_id, step_data)
@@ -1090,6 +1186,7 @@ class WorkflowEngine:
# is for transient/expected step failures only.
if result.output.get("aborted"):
state.status = RunStatus.ABORTED
state.error = result.error
state.append_log(
{
"event": "workflow_aborted",
@@ -1132,6 +1229,7 @@ class WorkflowEngine:
continue
state.status = RunStatus.FAILED
state.error = result.error
state.append_log(
{
"event": "step_failed",
@@ -1305,11 +1403,18 @@ class WorkflowEngine:
# Sequential path — identical to the historical behavior.
if workers <= 1:
results: list[Any] = []
for item_idx, item_val in enumerate(items):
context.item = item_val
results.append(run_item(item_idx, context))
if state.status in halting:
break
previous_item = context.item
previous_inside_fan_out = context.inside_fan_out
context.inside_fan_out = True
try:
for item_idx, item_val in enumerate(items):
context.item = item_val
results.append(run_item(item_idx, context))
if state.status in halting:
break
finally:
context.item = previous_item
context.inside_fan_out = previous_inside_fan_out
return results
# Concurrent path — bounded sliding window; results assembled in item order.
@@ -1320,7 +1425,14 @@ class WorkflowEngine:
# Each item runs against its own context copy so context.item is not
# clobbered across threads; the shared steps dict is written only on the
# disjoint parentId:templateId:index key (GIL-safe on distinct keys).
return run_item(idx, dataclasses.replace(context, item=items[idx]))
return run_item(
idx,
dataclasses.replace(
context,
item=items[idx],
inside_fan_out=True,
),
)
def item_halt_status(idx: int) -> RunStatus | None:
# If THIS item's own execution halted the run, return the resulting run
@@ -1401,6 +1513,16 @@ class WorkflowEngine:
# pool joined; restore the halting item's own outcome so the final run
# status matches the sequential semantics.
state.status = halted_status
# Restore the halting item's error so it matches the terminal
# status — a concurrent item may have overwritten state.error
# before the pool joined. Assign unconditionally when a record
# exists (even when the halting item's own error is falsy) so a
# third-party step returning FAILED with no message never inherits
# an unrelated concurrent item's error; this mirrors the sequential
# path, which sets state.error = result.error verbatim.
halt_rec = context.steps.get(item_id(halted_at))
if isinstance(halt_rec, dict):
state.error = halt_rec.get("error")
return slots[: halted_at + 1]
return slots[:collected]

View File

@@ -7,6 +7,7 @@ from typing import Any
import typer
import yaml
from rich.markup import escape as _escape_markup
from ..._console import console, err_console
from ...extensions import normalize_priority
@@ -412,14 +413,23 @@ def workflow_resolve(project_root: Path, workflow_id: str) -> dict[str, Any] | N
priority = (
"n/a" if layer.tier == "base" else str(normalize_priority(layer.priority))
)
# ``\[`` keeps the literal bracket: unescaped, Rich parses ``[base]`` /
# ``[project-overlay]`` as a style tag and swallows the tier label whole.
console.print(
f" \u2022 [{layer.tier}] {layer.source} "
f" \u2022 \\[{_escape_markup(layer.tier)}] "
f"{_escape_markup(layer.source)} "
f"(priority={priority})"
)
console.print("Step attribution:")
for composed in attribution:
console.print(f" \u2022 {composed.step_id}: {composed.source}")
# Step IDs come from base-workflow / overlay YAML, which only bans ``:``
# \u2014 brackets pass validation, so they reach Rich as markup. A balanced
# ``[stuff]`` is swallowed; an unbalanced ``[/red]`` raises MarkupError.
console.print(
f" \u2022 {_escape_markup(composed.step_id)}: "
f"{_escape_markup(composed.source)}"
)
return {
"workflow_id": workflow_id,

View File

@@ -70,6 +70,24 @@ class DoWhileStep(StepBase):
f"Do-while step {config.get('id', '?')!r} is missing "
f"'condition' field."
)
elif not isinstance(config["condition"], (str, bool)):
# The engine re-evaluates 'condition' via evaluate_condition() after
# each iteration. That call first delegates to
# evaluate_expression() -- which returns a non-string unchanged --
# and then coerces the result with bool(). So a list/dict/number
# condition silently resolves to its truthiness (e.g.
# condition: [1, 2] is always truthy, looping to max_iterations)
# with no error. Reject those at validation, mirroring the
# prompt/shell/command 'must be a string' checks.
#
# A literal ``bool`` stays valid: an unquoted ``condition: false``
# is idiomatic YAML and evaluate_condition() already resolves it
# exactly (bool passthrough, then a no-op bool()). "true"/"false"
# and an expression like "{{ ... }}" stay valid too.
errors.append(
f"Do-while step {config.get('id', '?')!r}: 'condition' must be a "
f"string or boolean, got {type(config['condition']).__name__}."
)
max_iter = config.get("max_iterations")
if max_iter is not None:
# bool is a subclass of int, so isinstance(True, int) is True and

View File

@@ -20,9 +20,31 @@ class FanInStep(StepBase):
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
wait_for = config.get("wait_for", [])
output_config = config.get("output") or {}
if not isinstance(output_config, dict):
output_config = config.get("output")
if output_config is None:
output_config = {}
elif not isinstance(output_config, dict):
# ``validate`` rejects a non-mapping ``output`` and its comment says
# why: "execute() silently coerces a non-mapping output to {}, so the
# author's declared aggregation keys would vanish with no error."
# The engine does not auto-validate before ``execute``, so on an
# unvalidated run that is exactly what happened -- and ``x or {}``
# masked the falsy shapes ([], false, 0, '') before the isinstance
# check even ran. Every declared key vanished while the step still
# reported COMPLETED, so downstream ``steps.<id>.output.<key>``
# resolved to None and interpolated as "": the same "silent empty
# result + COMPLETED" wiring bug the ``wait_for`` guard below
# rejects. Fail loudly with validate()'s own message instead. An
# explicit ``output:`` (YAML null) stays valid, matching validate.
return StepResult(
status=StepStatus.FAILED,
error=(
f"Fan-in step {config.get('id', '?')!r}: 'output' must be a "
f"mapping of key -> expression, got "
f"{type(output_config).__name__}."
),
output={"results": []},
)
# The engine does not auto-validate step config, so an unvalidated run
# with a non-list ``wait_for`` reaches here raw. Iterating it then

View File

@@ -26,7 +26,8 @@ class GateStep(StepBase):
later with ``specify workflow resume``.
The user's choice is stored in ``output.choice``. ``on_reject``
controls abort / skip / retry behaviour.
controls abort / skip / retry behaviour. ``verdict_input`` can name a
workflow input to use as the choice when resuming non-interactively.
"""
type_key = "gate"
@@ -42,6 +43,8 @@ class GateStep(StepBase):
options = config.get("options", ["approve", "reject"])
on_reject = config.get("on_reject", "abort")
has_verdict_input = "verdict_input" in config
verdict_input = config.get("verdict_input")
# ``validate`` rejects a non-list (or empty) ``options``, and requires
# every option to be a string, but the engine does not auto-validate
@@ -72,6 +75,51 @@ class GateStep(StepBase):
},
)
# ``validate`` rejects an ``on_reject`` outside abort/skip/retry, but the
# engine does not auto-validate before ``execute``. The reject branch
# below handles only "abort" and "retry" and then falls through to its
# ``on_reject == "skip"`` case, so on an unvalidated run any other value
# makes a REJECTED gate report COMPLETED and the run walks straight past
# the review the gate exists to enforce. Reachable by a capitalisation
# slip ("Abort"), a guessed verb ("fail", "stop"), a non-string, or the
# ``None`` that a bare ``on_reject:`` yields -- note ``config.get(k,
# default)`` does NOT substitute the default for an explicit null. Fail
# loudly instead, mirroring the ``options``/``verdict_input`` guards here.
if on_reject not in ("abort", "skip", "retry"):
return StepResult(
status=StepStatus.FAILED,
error=(
f"Gate step {config.get('id', '?')!r}: 'on_reject' must be "
f"'abort', 'skip', or 'retry', got {on_reject!r}."
),
output={
"message": message,
"options": options,
"on_reject": on_reject,
"choice": None,
},
)
if has_verdict_input and (
not isinstance(verdict_input, str) or not verdict_input
):
return StepResult(
status=StepStatus.FAILED,
error=(
f"Gate step {config.get('id', '?')!r}: 'verdict_input' must be "
"a non-empty string."
),
)
if has_verdict_input and context.inside_fan_out:
return StepResult(
status=StepStatus.FAILED,
error=(
f"Gate step {config.get('id', '?')!r}: 'verdict_input' is "
"not supported inside fan-out templates."
),
)
show_file = config.get("show_file")
if isinstance(show_file, str) and "{{" in show_file:
show_file = evaluate_expression(show_file, context)
@@ -90,16 +138,48 @@ class GateStep(StepBase):
"choice": None,
}
# Non-interactive: pause for later resume (the file is not read here)
if not sys.stdin.isatty():
return StepResult(status=StepStatus.PAUSED, output=output)
choice: str | None = None
bound_verdict_input: str | None = None
if verdict_input is not None:
value = context.inputs.get(verdict_input)
if value is not None and value != "":
if not isinstance(value, str):
return StepResult(
status=StepStatus.FAILED,
output=output,
error=(
f"Gate step {config.get('id', '?')!r}: verdict input "
f"{verdict_input!r} must be a string, got "
f"{type(value).__name__}."
),
)
choice = next(
(option for option in options if option.lower() == value.lower()),
None,
)
if choice is None:
return StepResult(
status=StepStatus.FAILED,
output=output,
error=(
f"Gate step {config.get('id', '?')!r}: verdict input "
f"{verdict_input!r} value {value!r} does not match any "
"configured option."
),
)
bound_verdict_input = verdict_input
# Interactive: prompt the user. ``show_file`` contents are folded
# into the displayed message so the operator can review the
# referenced material before choosing. Composing the prompt text
# here keeps ``_prompt`` to its ``(message, options)`` contract, so
# adding review material never widens the interactive seam.
choice = self._prompt(self._compose_prompt(message, show_file), options)
if choice is None:
# Non-interactive: pause for later resume (the file is not read here)
if not sys.stdin.isatty():
return StepResult(status=StepStatus.PAUSED, output=output)
# Interactive: prompt the user. ``show_file`` contents are folded
# into the displayed message so the operator can review the
# referenced material before choosing. Composing the prompt text
# here keeps ``_prompt`` to its ``(message, options)`` contract, so
# adding review material never widens the interactive seam.
choice = self._prompt(self._compose_prompt(message, show_file), options)
output["choice"] = choice
# Match rejection case-insensitively. ``_prompt`` echoes the option's
@@ -119,6 +199,8 @@ class GateStep(StepBase):
)
if on_reject == "retry":
# Pause so the next resume re-executes this gate
if bound_verdict_input is not None:
context.inputs[bound_verdict_input] = ""
return StepResult(status=StepStatus.PAUSED, output=output)
# on_reject == "skip" → completed, downstream steps decide
return StepResult(status=StepStatus.COMPLETED, output=output)
@@ -234,6 +316,13 @@ class GateStep(StepBase):
f"Gate step {config.get('id', '?')!r}: 'on_reject' must be "
f"'abort', 'skip', or 'retry'."
)
if "verdict_input" in config and (
not isinstance(config["verdict_input"], str) or not config["verdict_input"]
):
errors.append(
f"Gate step {config.get('id', '?')!r}: 'verdict_input' must be "
"a non-empty string."
)
# Only inspect option text when every option is a string; otherwise the
# `o.lower()` below would raise AttributeError on a non-string option
# (already reported above) and break validate_workflow's never-raise contract.

View File

@@ -61,6 +61,24 @@ class IfThenStep(StepBase):
errors.append(
f"If step {config.get('id', '?')!r} is missing 'condition' field."
)
elif not isinstance(config["condition"], (str, bool)):
# execute() feeds 'condition' to evaluate_condition(), which first
# delegates to evaluate_expression() -- that returns a non-string
# unchanged -- and then coerces the result with bool(). So a
# list/dict/number condition silently resolves to its truthiness
# (e.g. condition: [1, 2] is always True) with no error, branching
# wrongly on an authoring mistake. Reject those at validation,
# mirroring the prompt/shell/command 'must be a string' checks.
#
# A literal ``bool`` stays valid: an unquoted ``condition: false``
# is idiomatic YAML, evaluate_condition() already resolves it
# exactly (bool passthrough, then a no-op bool()), and this step
# itself defaults ``condition`` to ``False``. "true"/"false" and an
# expression like "{{ ... }}" are strings, so they stay valid too.
errors.append(
f"If step {config.get('id', '?')!r}: 'condition' must be a "
f"string or boolean, got {type(config['condition']).__name__}."
)
if "then" not in config:
errors.append(
f"If step {config.get('id', '?')!r} is missing 'then' field."

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import math
import shutil
from pathlib import Path
from typing import Any
@@ -88,9 +89,19 @@ class PromptStep(StepBase):
),
)
# An invalid timeout reaches subprocess.run() and raises a raw
# TypeError ("unsupported operand type(s) for +: 'float' and 'str'")
# or ValueError, which the engine re-raises — taking down the whole
# run with a message that names neither the step nor 'timeout'. Fail
# this step cleanly instead, mirroring the shell step.
timeout_error = self._timeout_error(config)
if timeout_error is not None:
return StepResult(status=StepStatus.FAILED, error=timeout_error)
# Attempt CLI dispatch
timeout = config.get("timeout", 300)
dispatch_result = self._try_dispatch(
prompt, integration, model, context
prompt, integration, model, context, timeout=timeout
)
output: dict[str, Any] = {
@@ -130,12 +141,48 @@ class PromptStep(StepBase):
),
)
@staticmethod
def _timeout_error(config: dict[str, Any]) -> str | None:
"""Return an error message if ``config['timeout']`` is invalid, else None.
Shared by execute() and validate() so both paths reject the same
values with the same message, mirroring the shell step. An absent
``timeout`` is valid (the default is used). bool is a subclass of int,
but ``timeout: true`` is a config error rather than a duration, so it
is rejected explicitly. Non-finite floats (YAML ``.inf``/``.nan``) pass
a plain ``> 0`` check but would raise in subprocess.run(), and a
non-positive timeout makes subprocess.run() report an immediate
TimeoutExpired, so both are rejected too.
"""
if "timeout" not in config:
return None
timeout = config["timeout"]
try:
valid_timeout = (
not isinstance(timeout, bool)
and isinstance(timeout, (int, float))
and timeout > 0
and math.isfinite(timeout)
)
except OverflowError:
# An int too large to convert to float (e.g. a 400-digit YAML
# scalar) clears every clause above and raises here — and would
# raise the same from subprocess.run(timeout=...).
valid_timeout = False
if not valid_timeout:
return (
f"Prompt step {config.get('id', '?')!r}: 'timeout' must be a "
f"positive number of seconds, got {timeout!r}."
)
return None
@staticmethod
def _try_dispatch(
prompt: str,
integration_key: str | None,
model: str | None,
context: StepContext,
timeout: int = 300,
) -> dict[str, Any] | None:
"""Dispatch *prompt* directly through the integration CLI."""
if not integration_key or not isinstance(integration_key, str) or not prompt:
@@ -167,6 +214,17 @@ class PromptStep(StepBase):
if not exec_args:
return None
# Windows: ``subprocess.run`` calls ``CreateProcess``, which does not
# consult ``PATHEXT``, so a bare command name like ``claude`` installed
# as ``claude.cmd`` (the usual npm shim layout) fails with
# ``WinError 2``. That OSError is swallowed below and reported as "CLI
# not found or not installed" -- even though the preflight above just
# found it. Reuse the already-resolved path so the shim is executed,
# mirroring ``IntegrationBase.dispatch_command``, which the ``command``
# step already goes through. On POSIX this is the same executable.
if fallback_cli_path:
exec_args = [fallback_cli_path, *exec_args[1:]]
import subprocess
project_root = (
@@ -178,6 +236,7 @@ class PromptStep(StepBase):
exec_args,
text=True,
cwd=str(project_root),
timeout=timeout,
)
return {
"exit_code": result.returncode,
@@ -190,6 +249,12 @@ class PromptStep(StepBase):
"stdout": "",
"stderr": "Interrupted by user",
}
except subprocess.TimeoutExpired:
return {
"exit_code": -1,
"stdout": "",
"stderr": f"Prompt timed out after {timeout} seconds.",
}
except OSError:
return None
@@ -230,4 +295,7 @@ class PromptStep(StepBase):
f"Prompt step {config.get('id', '?')!r}: 'model' must be a "
f"string, got {type(model).__name__}."
)
timeout_error = self._timeout_error(config)
if timeout_error is not None:
errors.append(timeout_error)
return errors

View File

@@ -121,12 +121,20 @@ class ShellStep(StepBase):
if "timeout" not in config:
return None
timeout = config["timeout"]
if (
isinstance(timeout, bool)
or not isinstance(timeout, (int, float))
or not math.isfinite(timeout)
or timeout <= 0
):
try:
invalid_timeout = (
isinstance(timeout, bool)
or not isinstance(timeout, (int, float))
or not math.isfinite(timeout)
or timeout <= 0
)
except OverflowError:
# An int too large to convert to float (e.g. a 400-digit YAML
# scalar) is not a bool and *is* an int, so it clears every clause
# before ``isfinite()`` and raises there — and would raise the same
# from subprocess.run(timeout=...). Mirrors the prompt step.
invalid_timeout = True
if invalid_timeout:
return (
f"Shell step {config.get('id', '?')!r}: 'timeout' must be a "
f"positive number of seconds, got {timeout!r}."

View File

@@ -79,6 +79,24 @@ class WhileStep(StepBase):
f"While step {config.get('id', '?')!r} is missing "
f"'condition' field."
)
elif not isinstance(config["condition"], (str, bool)):
# execute() feeds 'condition' to evaluate_condition(), which first
# delegates to evaluate_expression() -- that returns a non-string
# unchanged -- and then coerces the result with bool(). So a
# list/dict/number condition silently resolves to its truthiness
# (e.g. condition: [1, 2] is always truthy, spinning the loop to
# max_iterations) with no error. Reject those at validation,
# mirroring the prompt/shell/command 'must be a string' checks.
#
# A literal ``bool`` stays valid: an unquoted ``condition: false``
# is idiomatic YAML, evaluate_condition() already resolves it
# exactly (bool passthrough, then a no-op bool()), and this step
# itself defaults ``condition`` to ``False``. "true"/"false" and an
# expression like "{{ ... }}" are strings, so they stay valid too.
errors.append(
f"While step {config.get('id', '?')!r}: 'condition' must be a "
f"string or boolean, got {type(config['condition']).__name__}."
)
max_iter = config.get("max_iterations")
if max_iter is not None:
# bool is a subclass of int, so isinstance(True, int) is True and

View File

@@ -1,5 +1,5 @@
---
description: Create or update the project constitution from interactive or provided principle inputs, ensuring all dependent templates stay in sync.
description: Create or update the project constitution from interactive or provided principle inputs.
handoffs:
- label: Build Specification
agent: speckit.specify
@@ -16,8 +16,8 @@ You **MUST** consider the user input before proceeding (if not empty).
## Scope Guard
This command's own work is limited to updating the project constitution and propagating
constitution-driven changes to the dependent artifacts identified in this command.
This command's own work is limited to updating the project constitution itself. Dependent templates
and commands read the constitution at runtime and are not modified here.
- Classify every part of the user input as either constitution content or a separate,
non-governance intent.
@@ -25,7 +25,7 @@ constitution-driven changes to the dependent artifacts identified in this comman
deployment requests, you **MUST NOT** execute them. Extract them as deferred intents instead.
- You **MUST NOT** create, modify, or delete application source files, feature routes,
components, tests, deployment files, or other artifacts unrelated to the constitution
workflow and its required propagation.
workflow.
- If it is unclear whether an instruction is constitution content, ask for clarification before
making changes.
- After completing the constitution update, include a `Next Actions` section for each deferred
@@ -70,7 +70,7 @@ constitution-driven changes to the dependent artifacts identified in this comman
## Outline
You are updating the project constitution at `.specify/memory/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values, (b) fill the template precisely, and (c) propagate any amendments across dependent artifacts.
You are updating the project constitution at `.specify/memory/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values and (b) fill the template precisely.
**Note**: If `.specify/memory/constitution.md` does not exist yet, it should have been initialized from `.specify/templates/constitution-template.md` during project setup. If it's missing, copy the template first.
@@ -96,32 +96,24 @@ Follow this execution flow:
- Ensure each Principle section: succinct name line, paragraph (or bullet list) capturing nonnegotiable rules, explicit rationale if not obvious.
- Ensure Governance section lists amendment procedure, versioning policy, and compliance review expectations.
4. Consistency propagation checklist (convert prior checklist into active validations):
- Read `.specify/templates/plan-template.md` and ensure any "Constitution Check" or rules align with updated principles.
- Read `.specify/templates/spec-template.md` for scope/requirements alignment—update if constitution adds/removes mandatory sections or constraints.
- Read `.specify/templates/tasks-template.md` and ensure task categorization reflects new or removed principle-driven task types (e.g., observability, versioning, testing discipline).
- Read each installed Spec Kit command file for your agent (including this one) — named `speckit.*` or `speckit-*` (dot or hyphen depending on the agent), or laid out as `speckit-<name>/SKILL.md` for skills-based integrations, e.g. in `.github/agents/`, `.github/skills/`, `.claude/skills/`, or your agent's equivalent commands directory — to verify no outdated references (CLAUDE-only or other agent-specific names) remain when generic guidance is required.
- Read any runtime guidance docs (e.g., `README.md`, `docs/quickstart.md`, or agent-specific guidance files if present). Update references to principles changed.
5. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update):
4. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update):
- Version change: old → new
- List of modified principles (old title → new title if renamed)
- Added sections
- Removed sections
- Templates requiring updates (✅ updated / ⚠ pending) with file paths
- Follow-up TODOs if any placeholders intentionally deferred.
6. Validation before final output:
5. Validation before final output:
- No remaining unexplained bracket tokens.
- Version line matches report.
- Dates ISO format YYYY-MM-DD.
- Principles are declarative, testable, and free of vague language ("should" → replace with MUST/SHOULD rationale where appropriate).
7. Write the completed constitution back to `.specify/memory/constitution.md` (overwrite).
6. Write the completed constitution back to `.specify/memory/constitution.md` (overwrite).
8. Output a final summary to the user with:
7. Output a final summary to the user with:
- New version and bump rationale.
- Any files flagged for manual follow-up.
- Any TODO placeholders or deferred items requiring manual follow-up.
- Suggested commit message (e.g., `docs: amend constitution to vX.Y.Z (principle additions + governance update)`).
- A `Next Actions` section for any deferred non-governance intents.

View File

@@ -17,6 +17,7 @@ from typer.testing import CliRunner
from specify_cli import app
from specify_cli.bundler.services.packager import build_bundle
from tests.conftest import strip_ansi
from tests.bundler_helpers import (
catalog_entry_dict,
valid_manifest_dict,
@@ -25,6 +26,42 @@ from tests.bundler_helpers import (
runner = CliRunner()
MARKUP_BUNDLE_ID = "[red]markup-id[/red]"
MARKUP_SOURCE_ID = "[underline]markup-source[/underline]"
def _configure_markup_catalog(project: Path, **overrides: object) -> dict:
entry = catalog_entry_dict(
MARKUP_BUNDLE_ID,
name="[green]Markup Name[/green]",
version="[blue]1.0.0[/blue]",
role="[magenta]Markup Role[/magenta]",
description="[yellow]Markup Description[/yellow]",
author="[cyan]Markup Author[/cyan]",
license="[bold]Markup License[/bold]",
download_url="https://example.com/markup-bundle.zip",
requires={"speckit_version": "[italic]>=0.1.0[/italic]"},
**overrides,
)
catalog = project / "markup-catalog.json"
write_catalog_file(catalog, {MARKUP_BUNDLE_ID: entry})
config = {
"schema_version": "1.0",
"catalogs": [
{
"id": MARKUP_SOURCE_ID,
"url": str(catalog),
"priority": 1,
"install_policy": "install-allowed",
}
],
}
(project / ".specify" / "bundle-catalogs.yml").write_text(
yaml.safe_dump(config),
encoding="utf-8",
)
return entry
@pytest.fixture()
def project(tmp_path: Path, monkeypatch) -> Path:
@@ -124,6 +161,24 @@ def test_search_works_without_a_project(tmp_path: Path, monkeypatch):
assert result.output.strip().startswith("[")
def test_search_escapes_catalog_markup(project: Path):
entry = _configure_markup_catalog(project)
result = runner.invoke(app, ["bundle", "search", "--offline"])
assert result.exit_code == 0, result.output
output = " ".join(strip_ansi(result.output).split())
for value in (
entry["id"],
entry["name"],
entry["version"],
entry["role"],
entry["description"],
MARKUP_SOURCE_ID,
):
assert value in output
def test_info_unknown_bundle_without_project_reports_not_found(tmp_path: Path, monkeypatch):
monkeypatch.chdir(tmp_path) # no .specify/
result = runner.invoke(app, ["bundle", "info", "does-not-exist", "--offline"])
@@ -261,6 +316,83 @@ def test_info_expands_full_component_set(project: Path, monkeypatch):
assert "Trust" in text.output
def test_info_escapes_catalog_markup(project: Path, monkeypatch):
entry = _configure_markup_catalog(project)
bundle_dir = project / "markup-bundle"
bundle_dir.mkdir()
manifest_data = valid_manifest_dict()
manifest_data["bundle"]["id"] = MARKUP_BUNDLE_ID
manifest_data["integration"] = {
"id": "[conceal]markup-integration[/conceal]"
}
manifest_path = bundle_dir / "bundle.yml"
manifest_path.write_text(yaml.safe_dump(manifest_data), encoding="utf-8")
_mock_manifest_download(monkeypatch, manifest_path)
monkeypatch.setattr(
"specify_cli.commands.bundle._manifest_component_view",
lambda manifest: [
{
"kind": "extensions",
"id": "[reverse]markup-component[/reverse]",
"version": "[strike]2.0.0[/strike]",
}
],
)
monkeypatch.setattr(
"specify_cli.commands.bundle._bundle_overlaps",
lambda project_root, manifest, *, offline: [
"[blink]markup-overlap[/blink]"
],
)
result = runner.invoke(
app,
["bundle", "info", MARKUP_BUNDLE_ID, "--offline"],
)
assert result.exit_code == 0, result.output
output = " ".join(strip_ansi(result.output).split())
for value in (
entry["id"],
entry["name"],
entry["version"],
entry["role"],
entry["description"],
entry["author"],
entry["license"],
entry["requires"]["speckit_version"],
MARKUP_SOURCE_ID,
"[conceal]markup-integration[/conceal]",
"[reverse]markup-component[/reverse]",
"[strike]2.0.0[/strike]",
"[blink]markup-overlap[/blink]",
):
assert value in output
def test_info_escapes_catalog_provides_fallback_markup(project: Path, monkeypatch):
markup_count = "[bold]markup-count[/bold]"
_configure_markup_catalog(
project,
provides={"extensions": markup_count},
)
bundle_dir = project / "markup-bundle"
bundle_dir.mkdir()
manifest_data = valid_manifest_dict(provides={})
manifest_data["bundle"]["id"] = MARKUP_BUNDLE_ID
manifest_path = bundle_dir / "bundle.yml"
manifest_path.write_text(yaml.safe_dump(manifest_data), encoding="utf-8")
_mock_manifest_download(monkeypatch, manifest_path)
result = runner.invoke(
app,
["bundle", "info", MARKUP_BUNDLE_ID, "--offline"],
)
assert result.exit_code == 0, result.output
assert markup_count in strip_ansi(result.output)
def test_info_expands_discovery_only_bundle(project: Path, monkeypatch):
# Discovery-only bundles must still be fully inspectable via `info`;
# only `install` is refused for them.

View File

@@ -26,6 +26,45 @@ def test_missing_required_field_is_reported_by_name():
assert any("bundle.license" in e for e in errors)
@pytest.mark.parametrize(
"field", ["name", "role", "description", "author", "license"]
)
def test_explicit_null_bundle_field_is_reported_as_missing(field):
"""A field present but null is how YAML spells an empty value (`author:`).
`str(None)` is the literal text "None", which is non-empty, so it passed the
required-field checks: the bundle validated clean and shipped "None" as its
author/license/description.
"""
data = valid_manifest_dict()
data["bundle"][field] = None
manifest = BundleManifest.from_dict(data)
assert getattr(manifest.bundle, field) == ""
assert any(f"bundle.{field}" in e for e in manifest.structural_errors())
def test_explicit_null_speckit_version_is_reported_as_missing():
data = valid_manifest_dict()
data["requires"]["speckit_version"] = None
manifest = BundleManifest.from_dict(data)
assert manifest.requires.speckit_version == ""
assert any("speckit_version" in e for e in manifest.structural_errors())
def test_explicit_null_component_id_is_not_named_none():
"""A null component id must not become a component literally named "None"."""
data = valid_manifest_dict()
for kind, items in (data.get("provides") or {}).items():
if isinstance(items, list) and items and isinstance(items[0], dict):
items[0]["id"] = None
break
else: # pragma: no cover - fixture is expected to provide components
pytest.skip("fixture has no component list to null out")
manifest = BundleManifest.from_dict(data)
assert manifest.components, "fixture is expected to declare components"
assert all(ref.id != "None" for ref in manifest.components)
def test_unsupported_schema_version_is_rejected():
data = valid_manifest_dict(schema_version="9.9")
errors = BundleManifest.from_dict(data).structural_errors()

View File

@@ -0,0 +1,53 @@
"""Contract tests: every bundled preset must ship inside the wheel's core_pack.
``specify preset add <id>`` resolves a bundled preset via
``specify_cli._assets._locate_bundled_preset``, which checks the wheel's
``specify_cli/core_pack/presets/<id>/`` directory first. Any preset marked
``bundled: true`` in ``presets/catalog.json`` must therefore be force-included
at build time; otherwise the released wheel advertises a bundled preset it does
not actually ship, and ``specify preset add <id>`` falls through and reports the
preset as missing.
"""
from __future__ import annotations
import json
import tomllib
from pathlib import Path
REPO_ROOT = Path(__file__).parents[2]
def _force_include() -> dict[str, str]:
with (REPO_ROOT / "pyproject.toml").open("rb") as pyproject_file:
pyproject = tomllib.load(pyproject_file)
return pyproject["tool"]["hatch"]["build"]["targets"]["wheel"]["force-include"]
def _bundled_preset_ids() -> list[str]:
catalog = json.loads((REPO_ROOT / "presets" / "catalog.json").read_text())
return sorted(
preset_id
for preset_id, entry in catalog["presets"].items()
if entry.get("bundled")
)
def test_every_bundled_preset_is_force_included():
force_include = _force_include()
bundled = _bundled_preset_ids()
assert bundled, "expected at least one bundled preset in presets/catalog.json"
for preset_id in bundled:
assert force_include.get(f"presets/{preset_id}") == (
f"specify_cli/core_pack/presets/{preset_id}"
), f"bundled preset '{preset_id}' is missing from the wheel force-include list"
def test_constitution_sync_is_bundled_and_shipped():
# Explicit regression guard: constitution-sync was advertised as bundled
# before it was added to the wheel force-include list.
assert "constitution-sync" in _bundled_preset_ids()
assert _force_include()["presets/constitution-sync"] == (
"specify_cli/core_pack/presets/constitution-sync"
)

View File

@@ -204,19 +204,69 @@ class TestBuildCommandInvocation:
def test_skills_core_command(self):
from specify_cli.integrations import get_integration
i = get_integration("codex")
assert i.build_command_invocation("speckit.plan") == "/speckit-plan"
assert i.build_command_invocation("plan") == "/speckit-plan"
assert i.build_command_invocation("speckit.plan") == "$speckit-plan"
assert i.build_command_invocation("plan") == "$speckit-plan"
def test_skills_extension_command(self):
from specify_cli.integrations import get_integration
i = get_integration("codex")
assert i.build_command_invocation("speckit.git.commit") == "/speckit-git-commit"
assert i.build_command_invocation("git.commit") == "/speckit-git-commit"
assert i.build_command_invocation("speckit.git.commit") == "$speckit-git-commit"
assert i.build_command_invocation("git.commit") == "$speckit-git-commit"
def test_skills_extension_command_with_args(self):
from specify_cli.integrations import get_integration
i = get_integration("codex")
assert i.build_command_invocation("speckit.git.commit", "fix typo") == "/speckit-git-commit fix typo"
assert i.build_command_invocation("speckit.git.commit", "fix typo") == "$speckit-git-commit fix typo"
@pytest.mark.parametrize("integration_key", ["codex", "zcode"])
def test_dollar_skill_post_processing_is_idempotent(self, integration_key):
from specify_cli.integrations import get_integration
content = (
"---\nname: test\n---\n\n"
"Literal slash invocation: /speckit-plan\n"
"- For each executable hook, output the following based on its flag:\n"
)
integration = get_integration(integration_key)
once = integration.post_process_skill_content(content)
twice = integration.post_process_skill_content(once)
assert twice == once
assert once.count("replace dots (`.`) with hyphens") == 1
assert "$speckit-git-commit" in once
assert "/speckit-plan" in once
def test_kimi_skill_post_processing_is_idempotent(self):
"""Kimi's post_process_skill_content must be idempotent.
The hook-command note is injected with the /skill: prefix by the base
class (via get_invocation_prefix), so the idempotency check matches on
re-runs without requiring the broad /speckit- -> /skill:speckit- body
replacement to recognise a duplicate.
"""
from specify_cli.integrations import get_integration
content = (
"---\nname: test\n---\n\n"
"Literal slash invocation: /speckit-plan\n"
"- For each executable hook, output the following based on its flag:\n"
)
integration = get_integration("kimi")
once = integration.post_process_skill_content(content)
twice = integration.post_process_skill_content(once)
assert twice == once
assert once.count("replace dots (`.`) with hyphens") == 1
assert "/skill:speckit-git-commit" in once
def test_get_invocation_prefix_skill_colon(self):
"""get_invocation_prefix returns '/skill:' for Kimi in skills mode."""
from specify_cli._invocation_style import get_invocation_prefix
assert get_invocation_prefix("kimi", True) == "/skill:"
assert get_invocation_prefix("kimi", False) == "/"
assert get_invocation_prefix("codex", True) == "$"
assert get_invocation_prefix("claude", True) == "/"
def test_forge_core_command_hyphenated(self):
"""Forge installs hyphenated slash-commands (/speckit-<name>), so the
@@ -268,6 +318,26 @@ class TestResolveCommandRefs:
result = IntegrationBase.resolve_command_refs(text, "-")
assert result == "Run `/speckit-plan` to plan."
def test_dollar_prefix_core_command(self):
text = "Run `__SPECKIT_COMMAND_PLAN__` to plan."
result = IntegrationBase.resolve_command_refs(text, "-", "$")
assert result == "Run `$speckit-plan` to plan."
def test_skill_colon_prefix_core_command(self):
text = "Run `__SPECKIT_COMMAND_PLAN__` to plan."
result = IntegrationBase.resolve_command_refs(text, "-", "/skill:")
assert result == "Run `/skill:speckit-plan` to plan."
def test_process_template_kimi_uses_skill_colon_prefix(self):
"""process_template must use /skill: prefix for Kimi without relying on
post_process_skill_content's broad replacement."""
text = "---\ndescription: test\n---\nRun `__SPECKIT_COMMAND_PLAN__` to plan."
result = IntegrationBase.process_template(
text, "kimi", "sh", invoke_separator="-"
)
assert "/skill:speckit-plan" in result
assert "/speckit-plan" not in result
def test_multiple_placeholders(self):
text = "__SPECKIT_COMMAND_SPECIFY__ then __SPECKIT_COMMAND_PLAN__ then __SPECKIT_COMMAND_TASKS__"
result = IntegrationBase.resolve_command_refs(text, ".")

View File

@@ -3,6 +3,7 @@
import io
import json
import os
import runpy
import pytest
import yaml
@@ -1180,6 +1181,23 @@ class TestSharedInfraCommandRefs:
assert "__SPECKIT_COMMAND_" not in content
assert "/speckit-tasks" in content
def test_dollar_prefix_in_page_templates(self, tmp_path):
"""Dollar-style skills agents get $speckit-<name> in page templates."""
from specify_cli import _install_shared_infra
project = tmp_path / "dollar-test"
project.mkdir()
(project / ".specify").mkdir()
_install_shared_infra(
project, "sh", invoke_separator="-", invoke_prefix="$"
)
plan = project / ".specify" / "templates" / "plan-template.md"
content = plan.read_text(encoding="utf-8")
assert "$speckit-plan" in content
assert "/speckit-plan" not in content
@pytest.mark.parametrize("script_type", ["sh", "ps"])
def test_dot_separator_in_shared_scripts(self, tmp_path, script_type):
"""Markdown agents get /speckit.<name> in shared script hints."""
@@ -1220,6 +1238,48 @@ class TestSharedInfraCommandRefs:
assert "/speckit.plan" not in content
assert "/speckit.tasks" not in content
@pytest.mark.parametrize("script_type", ["sh", "ps", "py"])
def test_dollar_prefix_in_shared_scripts(self, tmp_path, script_type):
"""Dollar-style skills agents get native prefixes in shared script hints."""
from specify_cli import _install_shared_infra
project = tmp_path / f"dollar-script-{script_type}"
project.mkdir()
(project / ".specify").mkdir()
_install_shared_infra(
project, script_type, invoke_separator="-", invoke_prefix="$"
)
if script_type == "py":
state = {
"integration": "codex",
"integration_settings": {
"codex": {"invoke_separator": "-"},
},
}
(project / ".specify" / "integration.json").write_text(
json.dumps(state), encoding="utf-8"
)
common = project / ".specify" / "scripts" / "python" / "common.py"
namespace = runpy.run_path(str(common))
assert namespace["format_speckit_command"]("plan", project) == (
"$speckit-plan"
)
return
content = self._combined_script_content(project, script_type)
assert "$speckit-specify" in content
assert "$speckit-plan" in content
assert "$speckit-tasks" in content
assert "/speckit-specify" not in content
assert "/speckit-plan" not in content
assert "/speckit-tasks" not in content
if script_type == "sh":
assert r"\$speckit-specify" in content
assert r"\$speckit-plan" in content
assert r"\$speckit-tasks" in content
def test_full_init_claude_resolves_page_templates(self, tmp_path):
"""Full CLI init with Claude (skills agent) produces hyphen refs in page templates."""
from typer.testing import CliRunner
@@ -1343,6 +1403,18 @@ class TestIntegrationCatalogDiscoveryCLI:
"_install_allowed": True,
},
]
MARKUP_INTEGRATION = {
"id": "[red]markup-id[/red]",
"name": "[green]Markup Name[/green]",
"version": "[blue]1.0.0[/blue]",
"description": "[yellow]Markup Description[/yellow]",
"author": "[magenta]Markup Author[/magenta]",
"license": "[cyan]Markup License[/cyan]",
"repository": "[bold]Markup Repository[/bold]",
"tags": ["[italic]markup-tag[/italic]"],
"_catalog_name": "[underline]markup-catalog[/underline]",
"_install_allowed": False,
}
def _make_project(self, tmp_path):
project = tmp_path / "proj"
@@ -1806,6 +1878,25 @@ class TestIntegrationCatalogDiscoveryCLI:
# acme-coder is flagged _install_allowed=False, so we should warn
assert "Not directly installable" in result.output
def test_search_escapes_catalog_markup(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
self._patch_catalog(monkeypatch, integrations=[self.MARKUP_INTEGRATION])
result = self._invoke(["integration", "search"], project)
assert result.exit_code == 0, result.output
output = _normalize_cli_output(result.output)
for value in (
self.MARKUP_INTEGRATION["id"],
self.MARKUP_INTEGRATION["name"],
self.MARKUP_INTEGRATION["version"],
self.MARKUP_INTEGRATION["description"],
self.MARKUP_INTEGRATION["author"],
self.MARKUP_INTEGRATION["tags"][0],
self.MARKUP_INTEGRATION["_catalog_name"],
):
assert value in output
# -- info --------------------------------------------------------------
def test_info_found(self, tmp_path, monkeypatch):
@@ -1828,6 +1919,19 @@ class TestIntegrationCatalogDiscoveryCLI:
assert result.exit_code == 1
assert "not found" in result.output
def test_info_not_found_escapes_query_markup(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
self._patch_catalog(monkeypatch)
integration_id = "[red]does-not-exist[/red]"
result = self._invoke(
["integration", "info", integration_id],
project,
)
assert result.exit_code == 1
assert integration_id in _normalize_cli_output(result.output)
def test_info_builtin_not_in_catalog(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
# Empty catalog, but copilot is a registered built-in.
@@ -1836,6 +1940,30 @@ class TestIntegrationCatalogDiscoveryCLI:
assert result.exit_code == 0, result.output
assert "Built-in integration" in result.output
def test_info_escapes_catalog_markup(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
self._patch_catalog(monkeypatch, integrations=[self.MARKUP_INTEGRATION])
result = self._invoke(
["integration", "info", self.MARKUP_INTEGRATION["id"]],
project,
)
assert result.exit_code == 0, result.output
output = _normalize_cli_output(result.output)
for value in (
self.MARKUP_INTEGRATION["id"],
self.MARKUP_INTEGRATION["name"],
self.MARKUP_INTEGRATION["version"],
self.MARKUP_INTEGRATION["description"],
self.MARKUP_INTEGRATION["author"],
self.MARKUP_INTEGRATION["license"],
self.MARKUP_INTEGRATION["repository"],
self.MARKUP_INTEGRATION["tags"][0],
self.MARKUP_INTEGRATION["_catalog_name"],
):
assert value in output
# -- validation vs network guidance ------------------------------------
def test_search_local_config_error_shows_local_config_tip(
@@ -2244,3 +2372,279 @@ def test_refresh_shared_templates_preserves_recovered_user_file(tmp_path):
# Recovered user content must survive (fail-before: replaced by bundled body).
assert user_file.read_text(encoding="utf-8") == "# USER CUSTOM CONTENT\n"
class TestExtensionFlag:
"""Tests for the --extension flag on specify init."""
def _run_init(self, tmp_path, args, project_name="ext-test"):
from unittest.mock import patch
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / project_name
project.mkdir(exist_ok=True)
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
# Patch get_speckit_version to return a stable (non-dev) version so that
# the extension compatibility check (SpecifierSet(">=0.2.0")) passes.
with patch(
"specify_cli.commands.init.get_speckit_version",
return_value="0.8.2",
):
result = runner.invoke(app, [
"init", "--here",
"--integration", "copilot",
"--script", "sh",
"--ignore-agent-tools",
] + args, catch_exceptions=False)
finally:
os.chdir(old_cwd)
return project, result
def test_bundled_extension_installed(self, tmp_path):
"""--extension git installs the bundled git extension."""
project, result = self._run_init(tmp_path, ["--extension", "git"], project_name="ext-bundled")
assert result.exit_code == 0, f"init failed:\n{result.output}"
ext_dir = project / ".specify" / "extensions" / "git"
assert ext_dir.exists(), "git extension directory not found"
assert (ext_dir / "extension.yml").exists(), "extension.yml not found"
# Tracker should show extension step as done
normalized = _normalize_cli_output(result.output)
assert "Install extension: git" in normalized
def test_multiple_extensions_installed(self, tmp_path):
"""--extension can be specified multiple times."""
project, result = self._run_init(
tmp_path,
["--extension", "git", "--extension", "selftest"],
project_name="ext-multi",
)
assert result.exit_code == 0, f"init failed:\n{result.output}"
ext_dir_git = project / ".specify" / "extensions" / "git"
ext_dir_selftest = project / ".specify" / "extensions" / "selftest"
assert ext_dir_git.exists(), "git extension not installed"
assert ext_dir_selftest.exists(), "selftest extension not installed"
def test_local_path_extension_installed(self, tmp_path):
"""--extension /abs/path installs from a local absolute directory path."""
from specify_cli import _locate_bundled_extension
# Use the bundled git extension directory as our "local" extension source
bundled_git = _locate_bundled_extension("git")
assert bundled_git is not None, "bundled git extension not found; cannot run test"
# Pass the absolute path directly (starts with "/")
project, result = self._run_init(
tmp_path,
["--extension", str(bundled_git)],
project_name="ext-local",
)
assert result.exit_code == 0, f"init failed:\n{result.output}"
ext_dir = project / ".specify" / "extensions" / "git"
assert ext_dir.exists(), "extension from local path not installed"
def test_unknown_extension_shows_error_in_tracker(self, tmp_path):
"""An unknown extension name records a tracker error but does not abort init."""
project, result = self._run_init(
tmp_path,
["--extension", "nonexistent-xyz-ext"],
project_name="ext-unknown",
)
assert result.exit_code == 0, "init should not abort on unknown extension"
normalized = _normalize_cli_output(result.output)
assert "failed" in normalized.lower(), "expected 'failed' for unknown extension"
def test_extension_flag_works_with_preset(self, tmp_path):
"""--extension and --preset can be combined."""
project, result = self._run_init(
tmp_path,
["--extension", "git", "--preset", "lean"],
project_name="ext-preset",
)
assert result.exit_code == 0, f"init failed:\n{result.output}"
ext_dir = project / ".specify" / "extensions" / "git"
assert ext_dir.exists(), "git extension not installed alongside preset"
@staticmethod
def _zip_bytes_from_dir(source_dir):
"""Build in-memory ZIP bytes from an extension directory (yml at root)."""
import io
import zipfile
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for path in sorted(source_dir.rglob("*")):
if path.is_file():
zf.write(path, arcname=str(path.relative_to(source_dir)))
return buf.getvalue()
def test_url_extension_rejects_non_https(self, tmp_path):
"""A non-HTTPS URL is rejected before any download; init is not aborted."""
project, result = self._run_init(
tmp_path,
["--extension", "http://example.com/ext.zip", "--trust-extension-urls"],
project_name="ext-http",
)
assert result.exit_code == 0, "init should not abort on a rejected URL"
normalized = _normalize_cli_output(result.output)
assert "failed" in normalized.lower()
# No extension directory should have been created for the bad URL.
assert not (project / ".specify" / "extensions" / "ext").exists()
def test_url_extension_skipped_without_trust(self, tmp_path):
"""Non-interactive URL install without --trust-extension-urls is denied."""
from unittest.mock import patch
with patch(
"specify_cli.commands.init._stdin_is_interactive", return_value=False
), patch("specify_cli.authentication.http.open_url") as mock_open:
project, result = self._run_init(
tmp_path,
["--extension", "https://example.com/git.zip"],
project_name="ext-url-denied",
)
assert result.exit_code == 0, f"init failed:\n{result.output}"
# Default-deny: no download attempted, nothing installed.
mock_open.assert_not_called()
normalized = _normalize_cli_output(result.output)
assert "untrusted url" in normalized.lower()
assert not (project / ".specify" / "extensions" / "git").exists()
def test_url_extension_interactive_confirm_installs(self, tmp_path):
"""An interactive 'yes' to the trust prompt allows the URL install."""
import io
from unittest.mock import patch
from specify_cli import _locate_bundled_extension
bundled_git = _locate_bundled_extension("git")
assert bundled_git is not None, "bundled git extension not found"
zip_bytes = self._zip_bytes_from_dir(bundled_git)
class FakeResponse(io.BytesIO):
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def _cache_dir_stand_in(project_root):
d = project_root / ".specify" / "extensions" / ".cache" / "downloads"
d.mkdir(parents=True, exist_ok=True)
return d
def _open_download_zip(project_root, download_dir, zip_filename):
target = download_dir / zip_filename
o_temporary = getattr(os, "O_TEMPORARY", 0)
if o_temporary:
return os.open(
target, os.O_RDWR | os.O_CREAT | os.O_EXCL | o_temporary, 0o600
)
fd = os.open(target, os.O_RDWR | os.O_CREAT | os.O_EXCL, 0o600)
try:
os.unlink(target)
except OSError:
os.close(fd)
raise
return fd
with patch(
"specify_cli.commands.init._stdin_is_interactive", return_value=True
), patch("typer.confirm", return_value=True), patch(
"specify_cli.authentication.http.open_url",
return_value=FakeResponse(zip_bytes),
), patch(
"specify_cli.extensions._commands._validate_safe_cache_dir",
side_effect=_cache_dir_stand_in,
), patch(
"specify_cli.extensions._commands._safe_open_download_zip",
side_effect=_open_download_zip,
):
project, result = self._run_init(
tmp_path,
["--extension", "https://example.com/git.zip"],
project_name="ext-url-confirm",
)
assert result.exit_code == 0, f"init failed:\n{result.output}"
assert (project / ".specify" / "extensions" / "git").exists()
def test_url_extension_installs_zip(self, tmp_path):
"""A successful HTTPS ZIP download installs via the shared hardened path."""
import io
from unittest.mock import patch
from specify_cli import _locate_bundled_extension
bundled_git = _locate_bundled_extension("git")
assert bundled_git is not None, "bundled git extension not found"
zip_bytes = self._zip_bytes_from_dir(bundled_git)
class FakeResponse(io.BytesIO):
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def _cache_dir_stand_in(project_root):
d = project_root / ".specify" / "extensions" / ".cache" / "downloads"
d.mkdir(parents=True, exist_ok=True)
return d
def _open_download_zip(project_root, download_dir, zip_filename):
target = download_dir / zip_filename
o_temporary = getattr(os, "O_TEMPORARY", 0)
if o_temporary:
return os.open(
target, os.O_RDWR | os.O_CREAT | os.O_EXCL | o_temporary, 0o600
)
fd = os.open(target, os.O_RDWR | os.O_CREAT | os.O_EXCL, 0o600)
try:
os.unlink(target)
except OSError:
os.close(fd)
raise
return fd
with patch(
"specify_cli.authentication.http.open_url",
return_value=FakeResponse(zip_bytes),
), patch(
"specify_cli.extensions._commands._validate_safe_cache_dir",
side_effect=_cache_dir_stand_in,
), patch(
"specify_cli.extensions._commands._safe_open_download_zip",
side_effect=_open_download_zip,
):
project, result = self._run_init(
tmp_path,
["--extension", "https://example.com/git.zip", "--trust-extension-urls"],
project_name="ext-url",
)
assert result.exit_code == 0, f"init failed:\n{result.output}"
ext_dir = project / ".specify" / "extensions" / "git"
assert ext_dir.exists(), "extension from URL not installed"
assert (ext_dir / "extension.yml").exists()
# Transient download archive must not linger in the cache.
cache_dir = project / ".specify" / "extensions" / ".cache" / "downloads"
leftover = list(cache_dir.glob("*.zip")) if cache_dir.exists() else []
assert not leftover, f"download cache not cleaned: {leftover}"

File diff suppressed because it is too large Load Diff

View File

@@ -191,7 +191,7 @@ class SkillsIntegrationTests:
"---\n"
"name: test\n"
"---\n\n"
"- When constructing slash commands from hook command names, "
"- When constructing command invocations from hook command names, "
"replace dots (`.`) with hyphens (`-`). "
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
"- For each executable hook, output the following first block:\n"

View File

@@ -116,12 +116,15 @@ class TestCatalogURLValidation:
[
"https://[::1", # unclosed ipv6 bracket
"https://[not-an-ip]/c.json", # bracketed non-ip host
"https://example.com:notaport/c.json", # non-numeric port
"https://example.com:65536/c.json", # out-of-range port
],
)
def test_malformed_url_rejected_cleanly(self, url):
# A malformed authority makes urlparse/hostname raise ValueError. The
# validator must turn that into its normal catalog error, not leak a
# raw ValueError to the caller.
# A malformed authority makes urlparse/hostname raise ValueError, and a
# bad port makes ``parsed.port`` raise it. The validator must turn that
# into its normal catalog error, not leak a raw ValueError to the caller
# (or, for a bad port, accept the URL and fail later at fetch time).
with pytest.raises(IntegrationCatalogError, match="malformed"):
IntegrationCatalog._validate_catalog_url(url)
@@ -220,6 +223,33 @@ class TestActiveCatalogs:
# ---------------------------------------------------------------------------
class _OversizedResponse:
"""Response stub that supports bounded streaming reads for oversized-catalog tests."""
def __init__(self, data, url=""):
self._data = json.dumps(data).encode()
self._url = url if isinstance(url, str) else url.full_url
self._pos = 0
def read(self, n=-1):
if n < 0:
chunk = self._data[self._pos:]
self._pos = len(self._data)
return chunk
chunk = self._data[self._pos : self._pos + n]
self._pos += len(chunk)
return chunk
def geturl(self):
return self._url
def __enter__(self):
return self
def __exit__(self, *a):
pass
class TestCatalogFetch:
"""Tests that use a local HTTP server stub via monkeypatch."""
@@ -230,9 +260,16 @@ class TestCatalogFetch:
def __init__(self, data, url=""):
self._data = json.dumps(data).encode()
self._url = url if isinstance(url, str) else url.full_url
self._pos = 0
def read(self):
return self._data
def read(self, n=-1):
if n < 0:
chunk = self._data[self._pos:]
self._pos = len(self._data)
return chunk
chunk = self._data[self._pos:self._pos + n]
self._pos += len(chunk)
return chunk
def geturl(self):
return self._url
@@ -395,6 +432,90 @@ class TestCatalogFetch:
with pytest.raises(IntegrationCatalogError, match="Failed to fetch any integration catalog"):
cat.search()
def test_oversized_catalog_response_rejected(self, tmp_path, monkeypatch):
"""Response exceeding MAX_JSON_METADATA_BYTES is caught as IntegrationCatalogError.
The per-entry error is logged as a warning and skipped (not fatal).
When ALL catalogs are oversized, search() raises the aggregate error.
"""
from specify_cli._download_security import MAX_JSON_METADATA_BYTES
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
(tmp_path / ".specify").mkdir()
cat = IntegrationCatalog(tmp_path)
# Build a valid catalog dict whose JSON encoding exceeds the limit.
oversized = {
"schema_version": "1.0",
"integrations": {},
"padding": "x" * (MAX_JSON_METADATA_BYTES + 1),
}
import specify_cli.authentication.http as _auth_http
def _oversized_urlopen(req, timeout=10):
url = req if isinstance(req, str) else req.full_url
return _OversizedResponse(oversized, url)
monkeypatch.setattr(_auth_http.urllib.request, "urlopen", _oversized_urlopen)
# Both default + community catalogs are oversized → all fail → aggregate error.
# The per-entry IntegrationCatalogError (with "exceeds maximum size") is
# logged as a warning; the aggregate raise has a different message.
with pytest.raises(IntegrationCatalogError, match="Failed to fetch any integration catalog"):
cat.search()
def test_oversized_catalog_does_not_block_healthy_one(self, tmp_path, monkeypatch):
"""When one catalog is oversized, the healthy catalog still returns results."""
from specify_cli._download_security import MAX_JSON_METADATA_BYTES
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
specify = tmp_path / ".specify"
specify.mkdir()
healthy_catalog = {
"schema_version": "1.0",
"integrations": {
"good-agent": {
"id": "good-agent",
"name": "Good Agent",
"version": "1.0.0",
"description": "A healthy integration",
"author": "test-org",
},
},
}
oversized_catalog = {
"schema_version": "1.0",
"integrations": {},
"padding": "x" * (MAX_JSON_METADATA_BYTES + 1),
}
cfg = specify / "integration-catalogs.yml"
cfg.write_text(yaml.dump({"catalogs": [
{"url": "https://healthy.example.com/catalog.json", "name": "healthy", "priority": 1, "install_allowed": True},
{"url": "https://oversized.example.com/catalog.json", "name": "oversized", "priority": 2, "install_allowed": True},
]}))
cat = IntegrationCatalog(tmp_path)
import specify_cli.authentication.http as _auth_http
def _multi_catalog_urlopen(req, timeout=10):
url = req if isinstance(req, str) else req.full_url
if "oversized" in url:
return _OversizedResponse(oversized_catalog, url)
return _OversizedResponse(healthy_catalog, url)
monkeypatch.setattr(_auth_http.urllib.request, "urlopen", _multi_catalog_urlopen)
# The oversized catalog is skipped; the healthy catalog's integrations are returned.
results = cat.search()
ids = [r["id"] for r in results]
assert "good-agent" in ids
def test_clear_cache(self, tmp_path):
(tmp_path / ".specify").mkdir()
cat = IntegrationCatalog(tmp_path)
@@ -592,8 +713,15 @@ class TestIntegrationListCatalog:
def __init__(self, data, url=""):
self._data = json.dumps(data).encode()
self._url = url if isinstance(url, str) else url.full_url
def read(self):
return self._data
self._pos = 0
def read(self, n=-1):
if n < 0:
chunk = self._data[self._pos:]
self._pos = len(self._data)
return chunk
chunk = self._data[self._pos:self._pos + n]
self._pos += len(chunk)
return chunk
def geturl(self):
return self._url
def __enter__(self):

View File

@@ -12,7 +12,6 @@ class TestCodexIntegration(SkillsIntegrationTests):
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".agents/skills"
class TestCodexInitFlow:
"""--integration codex creates expected files."""
@@ -98,6 +97,8 @@ class TestCodexHookCommandNote:
assert "replace dots" in content, (
"speckit-specify should have dot-to-hyphen hook note"
)
assert "constructing command invocations" in content
assert "constructing slash commands" not in content
def test_hook_note_not_in_skills_without_hooks(self):
"""Skills without hook sections should not get the note."""

View File

@@ -109,6 +109,21 @@ class TestCopilotIntegration:
assert settings not in created
assert not any("settings.json" in k for k in m.files)
def test_setup_preserves_non_utf8_vscode_settings(self, tmp_path, caplog):
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
vscode_dir = tmp_path / ".vscode"
vscode_dir.mkdir(parents=True)
settings = vscode_dir / "settings.json"
original = b'{"editor.fontSize": 14}\xff'
settings.write_bytes(original)
m = IntegrationManifest("copilot", tmp_path)
copilot.setup(tmp_path, m)
assert settings.read_bytes() == original
assert "Could not parse" in caplog.text
def test_all_created_files_tracked_in_manifest(self, tmp_path):
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()

View File

@@ -43,6 +43,20 @@ class TestDroidIntegration(SkillsIntegrationTests):
i = get_integration(self.KEY)
assert i.multi_install_safe is True
def test_is_slash_skills_agent(self):
"""Droid is an always-skills agent whose commands install as
/speckit-<name>, so is_slash_skills_agent must report True — otherwise
hook invocations and the init next-steps panel render the dotted
/speckit.<name> form Droid never registers (mirrors grok/trae/zed/devin)."""
from specify_cli._invocation_style import is_slash_skills_agent
# True in BOTH the enabled and disabled cases: Droid is *always* slash,
# not conditional. The disabled case is what distinguishes an
# ALWAYS_SLASH agent from a CONDITIONAL_SLASH one (which would be False
# when ai_skills is disabled).
assert is_slash_skills_agent("droid", True) is True
assert is_slash_skills_agent("droid", False) is True
def test_install_url_points_to_factory(self):
i = get_integration(self.KEY)
url = i.config.get("install_url")

View File

@@ -55,6 +55,62 @@ class TestGenericIntegration:
with pytest.raises(ValueError, match="--commands-dir is required"):
i.setup(tmp_path, m, parsed_options={"commands_dir": ""})
@pytest.mark.parametrize("blank", [" ", "\t"])
def test_resolve_commands_dir_rejects_blank_parsed_value(self, blank):
"""A whitespace-only value must raise too: it resolves to a directory
literally named " ", scattering command files just like the empty case."""
from specify_cli.integrations.generic import GenericIntegration
with pytest.raises(ValueError, match="--commands-dir is required"):
GenericIntegration._resolve_commands_dir({"commands_dir": blank}, {})
@pytest.mark.parametrize(
"raw", ["--commands-dir ' '", "--commands-dir=' '", "--commands-dir '\t'"]
)
def test_resolve_commands_dir_rejects_blank_raw_value(self, raw):
"""Same rule on the raw_options branch, so the two cannot drift apart."""
from specify_cli.integrations.generic import GenericIntegration
with pytest.raises(ValueError, match="--commands-dir is required"):
GenericIntegration._resolve_commands_dir({}, {"raw_options": raw})
@pytest.mark.parametrize("padded", [" .myagent/cmds ", "\t.myagent/cmds"])
def test_resolve_commands_dir_returns_padded_value_verbatim(self, padded):
"""A padded but non-blank value is accepted and returned UNCHANGED: the
blankness test uses strip(), but rewriting the value would silently
retarget a directory the user asked for by name."""
from specify_cli.integrations.generic import GenericIntegration
assert GenericIntegration._resolve_commands_dir(
{"commands_dir": padded}, {}
) == padded
# Quoted in raw_options, since shlex.split() would otherwise consume the
# surrounding whitespace before this code ever sees it.
assert GenericIntegration._resolve_commands_dir(
{}, {"raw_options": f"--commands-dir='{padded}'"}
) == padded
@pytest.mark.parametrize("raw", ["--commands-dir=", "--commands-dir ''", '--commands-dir ""'])
def test_resolve_commands_dir_rejects_empty_raw_value(self, raw):
"""An empty --commands-dir in raw_options must raise the same "required"
error as the parsed-options path — not return "" (which resolves to the
project root and writes command files there). Mirrors the parsed branch."""
from specify_cli.integrations.generic import GenericIntegration
with pytest.raises(ValueError, match="--commands-dir is required"):
GenericIntegration._resolve_commands_dir({}, {"raw_options": raw})
def test_resolve_commands_dir_accepts_nonempty_raw_value(self):
"""A non-empty raw --commands-dir still resolves unchanged."""
from specify_cli.integrations.generic import GenericIntegration
assert GenericIntegration._resolve_commands_dir(
{}, {"raw_options": "--commands-dir .myagent/commands"}
) == ".myagent/commands"
assert GenericIntegration._resolve_commands_dir(
{}, {"raw_options": "--commands-dir=.myagent/commands"}
) == ".myagent/commands"
def test_setup_writes_to_correct_directory(self, tmp_path):
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)

View File

@@ -1276,6 +1276,24 @@ class TestIntegrationInstall:
assert "/speckit-specify" in script_content
assert "/speckit.specify" not in script_content
def test_install_dollar_skill_into_bare_project_gets_native_shared_refs(
self, tmp_path
):
"""A dollar-style integration supplies its prefix without a default."""
project = tmp_path / "bare-codex"
project.mkdir()
(project / ".specify").mkdir()
result = _run_in_project(
project, ["integration", "install", "codex", "--script", "sh"]
)
assert result.exit_code == 0, result.output
plan = project / ".specify" / "templates" / "plan-template.md"
plan_content = plan.read_text(encoding="utf-8")
assert "$speckit-plan" in plan_content
assert "/speckit-plan" not in plan_content
def test_install_defers_extension_commands_until_use(self, tmp_path):
"""Installing a second integration does not register enabled extensions.
@@ -2725,7 +2743,7 @@ class TestIntegrationSwitch:
assert opts["ai"] == "codex"
template = project / ".specify" / "templates" / "plan-template.md"
assert "/speckit-plan" in template.read_text(encoding="utf-8")
assert "$speckit-plan" in template.read_text(encoding="utf-8")
def test_failed_switch_rescaffolds_fallback_extensions(self, tmp_path):
"""Regression (review 3624184343).
@@ -3813,6 +3831,68 @@ class TestIntegrationUpgrade:
"upgrade of the active integration re-registers extension commands"
)
def test_upgrade_copilot_skills_restores_extension_skill_over_regenerated_dir(
self, tmp_path
):
"""End-to-end regression for #3849 (upgrade-overwrites-copilot-skills).
In Copilot skills mode, ``integration upgrade`` runs ``setup()`` — which
regenerates the core-template skill directories — *before* re-registering
installed extensions. The extension re-registration then hits the
``skill_dir_preexists`` guard in ``_register_extension_skills`` (the skill
sub-directory exists, courtesy of ``setup()``, but its ``SKILL.md`` has
not been rewritten with extension content), so pre-fix the extension
skill was silently left missing — its command content lost even though the
extension remained installed and registered.
The fix threads ``force=True`` from ``integration_upgrade()`` down to
``_register_extension_skills`` so the guard is bypassed and the extension
content is re-composed on top of the just-regenerated directory. This test
exercises the full ``specify integration upgrade`` command path and fails
without the fix (the skill is never recreated).
"""
project = _init_project(
tmp_path, "copilot", integration_options="--skills"
)
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
skill_dir = project / ".github" / "skills" / "speckit-git-feature"
skill_file = skill_dir / "SKILL.md"
assert skill_file.exists(), (
"precondition: git extension renders as a Copilot skill"
)
original = skill_file.read_text(encoding="utf-8")
assert "source: extension:git" in original, (
"precondition: skill carries the git extension ownership marker"
)
# Simulate the exact pre-condition the bug depends on: the skill file is
# gone but its directory survives (as it does once setup() regenerates the
# core-template layout during upgrade), triggering the skill_dir_preexists
# skip guard on re-registration.
skill_file.unlink()
assert skill_dir.exists() and not skill_file.exists()
result = _run_in_project(project, [
"integration", "upgrade", "copilot",
"--integration-options", "--skills",
"--script", "sh", "--force",
])
assert result.exit_code == 0, result.output
assert skill_file.exists(), (
"upgrade must restore the extension skill even when its directory "
"already exists (regression #3849)"
)
restored = skill_file.read_text(encoding="utf-8")
assert "source: extension:git" in restored, (
"restored skill must contain the git extension content, not a bare "
"core-template stub"
)
assert "# Git Feature Skill" in restored
def test_upgrade_active_integration_reregisters_presets(self, tmp_path):
"""Upgrading the active integration restores missing preset artifacts."""
import yaml

View File

@@ -9,7 +9,6 @@ class TestZcodeIntegration(SkillsIntegrationTests):
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".zcode/skills"
class TestZcodeInvocation:
"""ZCode renders $speckit-* chat invocations (like Codex)."""

View File

@@ -242,6 +242,34 @@ class TestManifestUninstall:
"remove_manifest=False must keep the manifest file on disk"
)
def test_undeletable_manifest_is_skipped_not_raised(self, tmp_path):
"""An undeletable manifest must not abort the whole uninstall.
The tracked files are removed *before* the manifest, so raising here
loses the ``(removed, skipped)`` result the caller needs: the CLI's
post-uninstall bookkeeping (reassigning the default integration,
rewriting/removing ``integration.json``, clearing init options) never
runs, leaving a removed integration still recorded as installed.
Leaving a directory at the manifest path is a portable way to make
``unlink()`` fail with no chmod and no monkeypatch: it raises
``IsADirectoryError`` on Linux and ``PermissionError`` on
Windows/macOS, both ``OSError`` subclasses.
"""
m = IntegrationManifest("test", tmp_path, version="1.0")
m.record_file("f.txt", "content")
m.save()
m.manifest_path.unlink()
m.manifest_path.mkdir()
removed, skipped = m.uninstall()
assert removed == [tmp_path / "f.txt"]
assert not (tmp_path / "f.txt").exists()
assert m.manifest_path in skipped, (
"an undeletable manifest must be reported in skipped"
)
def test_cleans_empty_parent_dirs(self, tmp_path):
m = IntegrationManifest("test", tmp_path)
m.record_file("a/b/c/f.txt", "content")
@@ -347,6 +375,14 @@ class TestManifestLoadValidation:
with pytest.raises(ValueError, match="invalid JSON"):
IntegrationManifest.load("bad", tmp_path)
def test_load_non_utf8_json_raises_value_error(self, tmp_path):
path = tmp_path / ".specify" / "integrations" / "bad.manifest.json"
path.parent.mkdir(parents=True)
path.write_bytes(b"\xff\xfe")
with pytest.raises(ValueError, match="valid UTF-8"):
IntegrationManifest.load("bad", tmp_path)
def test_load_filters_recovered_files_not_in_files(self, tmp_path):
# Finding B (Round-9): a recovered_files entry referencing a path
# not present in files indicates an internally-inconsistent manifest

View File

@@ -34,6 +34,19 @@ description: "ding\\aling"
Body of the command.
"""
# A description whose value contains an embedded ``---``. A substring split
# (``raw.split("---", 2)``) stops at this inner marker, truncating the parsed
# frontmatter — the closing document separator on its own line is the real
# boundary. See TestSkillFrontmatterEmbeddedDashes below.
DASHED_DESCRIPTION = "Separate sections with --- markers"
DASHED_TEMPLATE = """---
description: Separate sections with --- markers
name-marker: sentinel
---
Body of the command.
"""
def _parse_frontmatter(skill_file: Path) -> dict:
content = skill_file.read_text(encoding="utf-8")
@@ -90,6 +103,62 @@ class TestSkillFrontmatterQuoting:
assert fm["description"] == CONTROL
def _parse_frontmatter_line_anchored(skill_file: Path) -> dict:
"""Parse SKILL.md frontmatter using the closing ``---`` on its own line.
Unlike ``_parse_frontmatter`` (which uses ``split("---", 2)``), this is
robust to a ``---`` embedded in a value, so it can validate that the
generated frontmatter is itself well formed.
"""
content = skill_file.read_text(encoding="utf-8")
assert content.startswith("---\n")
lines = content.splitlines(keepends=True)
end = next(i for i in range(1, len(lines)) if lines[i].rstrip() == "---")
return yaml.safe_load("".join(lines[1:end]))
class TestSkillFrontmatterEmbeddedDashes:
"""A ``---`` inside a description value must not truncate parsing (#3634).
The skills setup path parsed template frontmatter with
``raw.split("---", 2)``, which stops at the first ``---`` *anywhere* —
including one inside a value such as ``description: ... --- ...``. That
dropped every frontmatter key after the marker (so the description fell
back to the generic default) and spilled the leftover frontmatter into
the skill body. The parser must match the closing ``---`` on its own line.
"""
def _generate(self, tmp_path, monkeypatch, template: str) -> Path:
integration = get_integration("agy")
monkeypatch.setattr(
integration,
"shared_commands_dir",
lambda: _fake_templates(tmp_path, template),
)
manifest = IntegrationManifest("agy", tmp_path)
created = integration.setup(tmp_path, manifest)
skill_files = [f for f in created if f.name == "SKILL.md"]
assert len(skill_files) == 1
return skill_files[0]
def test_dashed_description_is_preserved(self, tmp_path, monkeypatch):
skill_file = self._generate(tmp_path, monkeypatch, DASHED_TEMPLATE)
fm = _parse_frontmatter_line_anchored(skill_file)
# Buggy split("---", 2) truncates the value to "Separate sections with"
# (or drops it entirely, falling back to "Spec Kit: plan workflow").
assert fm["description"] == DASHED_DESCRIPTION
def test_leftover_frontmatter_not_spilled_into_body(self, tmp_path, monkeypatch):
skill_file = self._generate(tmp_path, monkeypatch, DASHED_TEMPLATE)
content = skill_file.read_text(encoding="utf-8")
lines = content.splitlines(keepends=True)
end = next(i for i in range(1, len(lines)) if lines[i].rstrip() == "---")
body = "".join(lines[end + 1 :])
# The template's trailing frontmatter key must not leak into the body.
assert "name-marker: sentinel" not in body
assert "Body of the command." in body
class TestHermesSkillFrontmatterQuoting:
def test_multiline_description_survives(self, tmp_path, monkeypatch):
home = tmp_path / "home"

View File

@@ -2,10 +2,12 @@
import re
from pathlib import Path
from typing import get_args, get_type_hints
import yaml
from specify_cli import AGENT_CONFIG
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
from specify_cli.extensions import CommandRegistrar
REPO_ROOT = Path(__file__).resolve().parent.parent
@@ -104,6 +106,14 @@ def _supported_agent_names_from_agent_request_template() -> list[str]:
class TestAgentConfigConsistency:
"""Ensure agent configuration stays synchronized across key surfaces."""
def test_register_commands_resolved_dir_annotation_accepts_none(self):
"""The internal resolved-directory override defaults to None."""
resolved_dir_type = get_type_hints(
AgentCommandRegistrar.register_commands
)["_resolved_dir"]
assert type(None) in get_args(resolved_dir_type)
def test_issue_template_agent_lists_match_runtime_integrations(self):
"""GitHub issue templates should list all concrete built-in agents."""
concrete_agent_keys = set(AGENT_CONFIG) - {"generic"}

View File

@@ -535,6 +535,57 @@ class TestAzureDevOpsAuth:
with patch("specify_cli.authentication.azure_devops.subprocess.run", return_value=result):
assert AzureDevOpsAuth().resolve_token(entry) is None
def test_resolve_token_azure_cli_resolves_executable(self):
"""The az executable is resolved via shutil.which before invocation, so
the .cmd/.bat shim on Windows (CreateProcess ignores PATHEXT) is used."""
from unittest.mock import patch, MagicMock
entry = AuthConfigEntry(
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli",
)
# Build the absolute path with the HOST's rules: the production code
# calls os.path.isabs(), so a hardcoded Windows path would read as
# RELATIVE on POSIX runners and silently exercise the fallback branch
# instead of the one under test.
resolved_path = os.path.join(os.path.abspath(os.sep), "opt", "az", "az.CMD")
assert os.path.isabs(resolved_path)
result = MagicMock()
result.returncode = 0
result.stdout = '{"accessToken": "tok"}'
with patch(
"specify_cli.authentication.azure_devops.shutil.which",
return_value=resolved_path,
), patch(
"specify_cli.authentication.azure_devops.subprocess.run",
return_value=result,
) as run:
assert AzureDevOpsAuth().resolve_token(entry) == "tok"
argv = run.call_args.args[0]
assert argv[0] == resolved_path
assert argv[1:4] == ["account", "get-access-token", "--resource"]
@pytest.mark.parametrize("which_result", [None, r".\az.CMD", "az.cmd", "./az"])
def test_resolve_token_azure_cli_falls_back_to_bare_az(self, which_result):
"""Fall back to the bare "az" when shutil.which finds nothing OR returns a
NON-ABSOLUTE path. On Windows shutil.which searches the current directory
first, so a stray .\\az.cmd must never be executed for a credential
operation; the bare name also preserves the not-installed OSError path."""
from unittest.mock import patch, MagicMock
entry = AuthConfigEntry(
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli",
)
result = MagicMock()
result.returncode = 0
result.stdout = '{"accessToken": "tok"}'
with patch(
"specify_cli.authentication.azure_devops.shutil.which",
return_value=which_result,
), patch(
"specify_cli.authentication.azure_devops.subprocess.run",
return_value=result,
) as run:
assert AzureDevOpsAuth().resolve_token(entry) == "tok"
assert run.call_args.args[0][0] == "az", which_result
def test_resolve_token_azure_cli_not_installed_returns_none(self):
"""azure-cli returns None when az is not installed."""
from unittest.mock import patch

View File

@@ -370,3 +370,69 @@ def test_python_branch_falls_back_to_feature_dir_basename(prereq_repo: Path) ->
assert py.returncode == 0, py.stderr
assert _json_stdout(py)["BRANCH"] == "001-my-feature"
class TestGetInvokeSeparatorTolerance:
"""`get_invoke_separator` must fall back to "." for an unusable
`integration.json`, matching its bash and PowerShell twins.
The bash twin tries jq -> python3 -> awk and keeps its `separator="."`
default on any parse failure; the PowerShell twin likewise returns ".".
The Python twin instead indexed the parsed value directly, so two shapes
escaped its `except (OSError, json.JSONDecodeError)`:
* a non-mapping top level (`[]`, `"forge"`, `42`, `null`) is valid JSON,
so JSONDecodeError never fires and `.get()` raised AttributeError;
* a non-UTF-8 file raises UnicodeDecodeError -- a ValueError, not an
OSError. Realistic on Windows, where PowerShell 5.1's `Out-File`/`>`
default to UTF-16.
The sibling `read_feature_json_feature_directory` in the same module
already guards both.
"""
@staticmethod
def _load_common():
import importlib.util
spec = importlib.util.spec_from_file_location("_speckit_common_py", COMMON_PY)
module = importlib.util.module_from_spec(spec)
# Register before exec: the module defines @dataclass types, and
# dataclasses resolves cls.__module__ through sys.modules.
sys.modules[spec.name] = module
try:
spec.loader.exec_module(module)
except Exception: # pragma: no cover - defensive cleanup
sys.modules.pop(spec.name, None)
raise
return module
def _repo(self, tmp_path: Path, body: str | bytes) -> Path:
(tmp_path / ".specify").mkdir(parents=True, exist_ok=True)
target = tmp_path / ".specify" / "integration.json"
if isinstance(body, bytes):
target.write_bytes(body)
else:
target.write_text(body, encoding="utf-8")
return tmp_path
@pytest.mark.parametrize(
"body", ["[]", '[{"a": 1}]', '"forge"', "42", "true", "null"]
)
def test_non_mapping_integration_json_falls_back(self, tmp_path: Path, body: str):
common = self._load_common()
assert common.get_invoke_separator(self._repo(tmp_path, body)) == "."
def test_non_utf8_integration_json_falls_back(self, tmp_path: Path):
common = self._load_common()
raw = '{"default_integration": "forge"}'.encode("utf-16")
assert common.get_invoke_separator(self._repo(tmp_path, raw)) == "."
def test_hyphen_separator_is_still_honoured(self, tmp_path: Path):
"""Regression guard: the real feature must keep working."""
common = self._load_common()
body = json.dumps({
"default_integration": "droid",
"integration_settings": {"droid": {"invoke_separator": "-"}},
})
assert common.get_invoke_separator(self._repo(tmp_path, body)) == "-"

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import io
import stat
import struct
import tarfile
import weakref
import zipfile
import zlib
@@ -13,11 +14,16 @@ import pytest
from specify_cli._download_security import (
MAX_ZIP_CENTRAL_DIRECTORY_BYTES,
archive_format_from_content_type,
archive_format_from_name,
build_safe_download_path,
detect_archive_format,
is_https_or_localhost_http,
is_loopback_url,
read_response_limited,
read_zip_member_limited,
safe_extract_archive,
safe_extract_tar,
safe_extract_zip,
)
@@ -314,6 +320,176 @@ def test_build_safe_download_path_rejects_nonportable_identifiers(
)
@pytest.mark.parametrize(
("name", "expected"),
[
("package.zip", "zip"),
("PACKAGE.TAR.GZ", "tar.gz"),
("https://example.com/package.tgz?download=1", "tar.gz"),
("package.tar", None),
],
)
def test_archive_format_from_name(name, expected):
assert archive_format_from_name(name) == expected
@pytest.mark.parametrize(
("content_type", "expected"),
[
("application/zip", "zip"),
("application/x-zip-compressed; charset=binary", "zip"),
("application/gzip", "tar.gz"),
("application/x-gzip", "tar.gz"),
("application/octet-stream", None),
],
)
def test_archive_format_from_content_type(content_type, expected):
assert archive_format_from_content_type(content_type) == expected
def _write_tar_gz(path, members):
with tarfile.open(path, "w:gz") as archive:
for name, content in members:
info = tarfile.TarInfo(name)
info.size = len(content)
archive.addfile(info, io.BytesIO(content))
@pytest.mark.parametrize("suffix", [".tar.gz", ".tgz"])
def test_detect_archive_format_accepts_tar_suffixes(tmp_path, suffix):
archive_path = tmp_path / f"package{suffix}"
_write_tar_gz(archive_path, [("file.txt", b"contents")])
assert detect_archive_format(archive_path) == "tar.gz"
def test_detect_archive_format_allows_content_type_fallback(tmp_path):
archive_path = tmp_path / "download"
_write_tar_gz(archive_path, [("file.txt", b"contents")])
assert (
detect_archive_format(
archive_path,
source_name="https://example.com/download",
content_type="application/gzip",
)
== "tar.gz"
)
def test_detect_archive_format_rejects_suffix_content_mismatch(tmp_path):
archive_path = tmp_path / "package.zip"
_write_tar_gz(archive_path, [("file.txt", b"contents")])
with pytest.raises(ValueError, match="format mismatch"):
detect_archive_format(archive_path)
def test_detect_archive_format_rejects_suffix_header_mismatch(tmp_path):
archive_path = tmp_path / "package.zip"
with zipfile.ZipFile(archive_path, "w") as archive:
archive.writestr("file.txt", "contents")
with pytest.raises(ValueError, match="Content-Type"):
detect_archive_format(
archive_path,
content_type="application/gzip",
)
def test_build_safe_download_path_uses_archive_suffix(tmp_path):
path = build_safe_download_path(tmp_path, "package", "1.0.0", suffix=".tar.gz")
assert path.name == "package-1.0.0.tar.gz"
@pytest.mark.parametrize(
"member_name",
["../evil.txt", "nested/../../evil.txt", "C:/Windows/evil.txt"],
)
def test_safe_extract_tar_rejects_traversal(tmp_path, member_name):
archive_path = tmp_path / "bad.tar.gz"
_write_tar_gz(archive_path, [(member_name, b"nope")])
with pytest.raises(ValueError, match="Unsafe path"):
safe_extract_tar(archive_path, tmp_path / "out")
@pytest.mark.parametrize(
("link_type", "message"),
[(tarfile.SYMTYPE, "symlink"), (tarfile.LNKTYPE, "hard link")],
)
def test_safe_extract_tar_rejects_links_without_partial_extraction(
tmp_path, link_type, message
):
archive_path = tmp_path / "bad.tar.gz"
with tarfile.open(archive_path, "w:gz") as archive:
safe = tarfile.TarInfo("safe.txt")
safe.size = 4
archive.addfile(safe, io.BytesIO(b"safe"))
link = tarfile.TarInfo("escape")
link.type = link_type
link.linkname = "../../outside"
archive.addfile(link)
out_dir = tmp_path / "out"
with pytest.raises(ValueError, match=message):
safe_extract_tar(archive_path, out_dir)
assert not out_dir.exists() or not any(out_dir.rglob("*"))
def test_safe_extract_tar_rejects_special_file(tmp_path):
archive_path = tmp_path / "bad.tar.gz"
with tarfile.open(archive_path, "w:gz") as archive:
fifo = tarfile.TarInfo("pipe")
fifo.type = tarfile.FIFOTYPE
archive.addfile(fifo)
with pytest.raises(ValueError, match="Unsafe member type"):
safe_extract_tar(archive_path, tmp_path / "out")
def test_safe_extract_tar_rejects_conflicting_paths(tmp_path):
archive_path = tmp_path / "bad.tar.gz"
_write_tar_gz(
archive_path,
[("Folder/file.txt", b"one"), ("folder/FILE.txt", b"two")],
)
with pytest.raises(ValueError, match="Conflicting path"):
safe_extract_tar(archive_path, tmp_path / "out")
def test_safe_extract_tar_enforces_entry_and_size_limits(tmp_path):
archive_path = tmp_path / "bad.tar.gz"
_write_tar_gz(
archive_path,
[("one.txt", b"1234"), ("two.txt", b"5678")],
)
with pytest.raises(ValueError, match="too many entries"):
safe_extract_tar(archive_path, tmp_path / "entries", max_entries=1)
with pytest.raises(ValueError, match="member.*maximum size"):
safe_extract_tar(archive_path, tmp_path / "member", max_member_bytes=3)
with pytest.raises(ValueError, match="uncompressed size"):
safe_extract_tar(archive_path, tmp_path / "total", max_total_bytes=7)
@pytest.mark.parametrize("suffix", [".zip", ".tar.gz", ".tgz"])
def test_safe_extract_archive_has_format_parity(tmp_path, suffix):
archive_path = tmp_path / f"package{suffix}"
if suffix == ".zip":
with zipfile.ZipFile(archive_path, "w") as archive:
archive.writestr("nested/file.txt", b"contents")
else:
_write_tar_gz(archive_path, [("nested/file.txt", b"contents")])
out_dir = tmp_path / f"out-{suffix.replace('.', '-')}"
safe_extract_archive(archive_path, out_dir)
assert (out_dir / "nested" / "file.txt").read_bytes() == b"contents"
@pytest.mark.parametrize(
"member_name",
[

View File

@@ -0,0 +1,401 @@
"""Security tests for the extension URL download cache."""
from __future__ import annotations
import io
import os
import shutil
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
import typer
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.extensions import ExtensionCatalog, ExtensionManager
from specify_cli.extensions import _commands
_MINIMAL_ZIP_BYTES = b"PK\x05\x06" + b"\x00" * 18
runner = CliRunner()
def _require_secure_dir_fd() -> None:
if (
not getattr(os, "O_NOFOLLOW", 0)
or os.open not in os.supports_dir_fd
or os.mkdir not in os.supports_dir_fd
):
pytest.skip("requires dir_fd and O_NOFOLLOW support")
def _symlink_directory(link: Path, target: Path) -> None:
try:
link.symlink_to(target, target_is_directory=True)
except (OSError, NotImplementedError) as exc:
pytest.skip(f"directory symlinks are unavailable: {exc}")
@pytest.fixture
def project_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
project = tmp_path / "project"
project.mkdir()
(project / ".specify").mkdir()
monkeypatch.chdir(project)
return project
@pytest.mark.parametrize(
"ancestor_parts",
[
("extensions",),
("extensions", ".cache"),
("extensions", ".cache", "downloads"),
],
)
def test_symlinked_cache_ancestor_is_refused(
project_dir: Path, tmp_path: Path, ancestor_parts: tuple[str, ...]
) -> None:
_require_secure_dir_fd()
outside = tmp_path / "outside"
outside.mkdir()
parent = project_dir / ".specify"
for part in ancestor_parts[:-1]:
parent = parent / part
parent.mkdir()
_symlink_directory(parent / ancestor_parts[-1], outside)
with pytest.raises(typer.Exit):
_commands._validate_safe_cache_dir(project_dir)
assert list(outside.iterdir()) == []
@pytest.mark.parametrize(
"ancestor_parts",
[
("extensions",),
("extensions", ".cache"),
("extensions", ".cache", "downloads"),
],
)
def test_symlinked_cache_ancestor_is_refused_without_dir_fd(
project_dir: Path,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
ancestor_parts: tuple[str, ...],
) -> None:
"""The portable (Windows) validation path must also refuse a symlinked
cache ancestor and never create anything under the symlink target."""
monkeypatch.setattr(os, "supports_dir_fd", set())
outside = tmp_path / "outside"
outside.mkdir()
parent = project_dir / ".specify"
for part in ancestor_parts[:-1]:
parent = parent / part
parent.mkdir()
_symlink_directory(parent / ancestor_parts[-1], outside)
with pytest.raises(typer.Exit):
_commands._validate_safe_cache_dir(project_dir)
assert list(outside.iterdir()) == []
def test_cache_ancestor_resolving_outside_project_is_refused(
project_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_require_secure_dir_fd()
cache_root = project_dir / ".specify" / "extensions" / ".cache"
cache_root.mkdir(parents=True)
outside = tmp_path / "outside"
outside.mkdir()
real_resolve = Path.resolve
def fake_resolve(self: Path, *args, **kwargs) -> Path:
if self == cache_root:
return real_resolve(outside, *args, **kwargs)
return real_resolve(self, *args, **kwargs)
monkeypatch.setattr(Path, "resolve", fake_resolve)
with pytest.raises(typer.Exit):
_commands._validate_safe_cache_dir(project_dir)
assert list(outside.iterdir()) == []
def test_safe_open_refuses_exclusive_leaf_collision(project_dir: Path) -> None:
_require_secure_dir_fd()
download_dir = _commands._validate_safe_cache_dir(project_dir)
zip_filename = "extension-url-download-collision.zip"
collision = download_dir / zip_filename
collision.write_bytes(b"sentinel")
with pytest.raises(OSError):
_commands._safe_open_download_zip(
project_dir, download_dir, zip_filename
)
assert collision.read_bytes() == b"sentinel"
def test_safe_open_refuses_swapped_cache_ancestor(
project_dir: Path, tmp_path: Path
) -> None:
_require_secure_dir_fd()
download_dir = _commands._validate_safe_cache_dir(project_dir)
cache_root = project_dir / ".specify" / "extensions" / ".cache"
outside = tmp_path / "outside"
outside.mkdir()
shutil.rmtree(cache_root)
_symlink_directory(cache_root, outside)
with pytest.raises(OSError):
_commands._safe_open_download_zip(
project_dir,
download_dir,
"extension-url-download-swapped.zip",
)
assert list(outside.iterdir()) == []
def test_safe_open_refuses_symlinked_project_root(
project_dir: Path, tmp_path: Path
) -> None:
_require_secure_dir_fd()
project_link = tmp_path / "project-link"
_symlink_directory(project_link, project_dir)
download_dir = project_link / ".specify" / "extensions" / ".cache" / "downloads"
with pytest.raises(OSError):
_commands._safe_open_download_zip(
project_link,
download_dir,
"extension-url-download-project-link.zip",
)
def test_safe_open_succeeds_without_dir_fd_support(
project_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""On a platform without dir_fd (e.g. Windows) the portable path must
still hand back a usable, exclusively-created descriptor rather than
failing closed."""
monkeypatch.setattr(os, "supports_dir_fd", set())
download_dir = _commands._validate_safe_cache_dir(project_dir)
assert download_dir == (
project_dir / ".specify" / "extensions" / ".cache" / "downloads"
)
fd = _commands._safe_open_download_zip(
project_dir, download_dir, "extension-url-download-portable.zip"
)
try:
os.write(fd, b"payload")
os.lseek(fd, 0, os.SEEK_SET)
assert os.read(fd, 7) == b"payload"
finally:
os.close(fd)
def test_safe_open_without_dir_fd_refuses_symlinked_leaf(
project_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The portable path must refuse a leaf pre-staged as a symlink so an
attacker cannot redirect the exclusive create outside the project."""
monkeypatch.setattr(os, "supports_dir_fd", set())
download_dir = _commands._validate_safe_cache_dir(project_dir)
outside = tmp_path / "outside.zip"
zip_filename = "extension-url-download-symlink-leaf.zip"
try:
(download_dir / zip_filename).symlink_to(outside)
except (OSError, NotImplementedError) as exc:
pytest.skip(f"symlinks are unavailable: {exc}")
with pytest.raises(OSError):
_commands._safe_open_download_zip(project_dir, download_dir, zip_filename)
assert not outside.exists()
def test_url_install_succeeds_without_dir_fd_support(
project_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A full ``--from`` install must work on platforms without dir_fd rather
than failing closed, exercising the portable hardened download path."""
captured: dict[str, object] = {}
class FakeResponse(io.BytesIO):
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def fake_install(
self,
zip_path: Path,
speckit_version: str,
priority: int = 10,
force: bool = False,
*,
archive_file=None,
):
captured["bytes"] = archive_file.read()
archive_file.seek(0)
return SimpleNamespace(
id="test-ext",
name="Test Extension",
version="1.0.0",
description="",
warnings=[],
commands=[],
)
monkeypatch.setattr(os, "supports_dir_fd", set())
monkeypatch.setattr(typer, "confirm", lambda *args, **kwargs: True)
monkeypatch.setattr(
ExtensionCatalog,
"_open_url",
lambda *args, **kwargs: FakeResponse(_MINIMAL_ZIP_BYTES),
)
monkeypatch.setattr(ExtensionManager, "install_from_zip", fake_install)
monkeypatch.setattr(_commands, "_refresh_events_and_warn", lambda root: None)
monkeypatch.setattr(_commands, "load_init_options", lambda root: {})
result = runner.invoke(
app,
[
"extension",
"add",
"test-ext",
"--from",
"https://example.com/test-ext.zip",
],
)
assert result.exit_code == 0, result.output
assert captured["bytes"] == _MINIMAL_ZIP_BYTES
def test_url_install_writes_and_cleans_up_secure_download(
project_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_require_secure_dir_fd()
captured: dict[str, object] = {}
class FakeResponse(io.BytesIO):
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def fake_install(
self,
zip_path: Path,
speckit_version: str,
priority: int = 10,
force: bool = False,
*,
archive_file=None,
):
captured["path"] = zip_path
captured["mode"] = os.fstat(archive_file.fileno()).st_mode & 0o777
captured["exists_during_install"] = zip_path.exists()
captured["bytes"] = archive_file.read()
archive_file.seek(0)
return SimpleNamespace(
id="test-ext",
name="Test Extension",
version="1.0.0",
description="",
warnings=[],
commands=[],
)
monkeypatch.setattr(typer, "confirm", lambda *args, **kwargs: True)
monkeypatch.setattr(
ExtensionCatalog,
"_open_url",
lambda *args, **kwargs: FakeResponse(_MINIMAL_ZIP_BYTES),
)
monkeypatch.setattr(ExtensionManager, "install_from_zip", fake_install)
monkeypatch.setattr(_commands, "_refresh_events_and_warn", lambda root: None)
monkeypatch.setattr(_commands, "load_init_options", lambda root: {})
result = runner.invoke(
app,
[
"extension",
"add",
"test-ext",
"--from",
"https://example.com/test-ext.zip",
],
)
assert result.exit_code == 0, result.output
assert captured["bytes"] == _MINIMAL_ZIP_BYTES
assert captured["mode"] == 0o600
# The archive is an anonymous inode: it is never visible on disk, even
# while installation consumes the open descriptor.
assert captured["exists_during_install"] is False
zip_path = captured["path"]
assert isinstance(zip_path, Path)
assert zip_path.parent == (
project_dir / ".specify" / "extensions" / ".cache" / "downloads"
)
assert zip_path.name.startswith("extension-url-download-")
assert not zip_path.exists()
def test_url_install_open_error_surfaces_as_controlled_exit(
project_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""An ``OSError`` from the hardened create (e.g. an exclusive-leaf
collision or a swapped ancestor) must fail closed as ``typer.Exit(1)``
rather than escaping as an unhandled traceback, and installation must
not run."""
download_dir = project_dir / ".specify" / "extensions" / ".cache" / "downloads"
monkeypatch.setattr(typer, "confirm", lambda *args, **kwargs: True)
monkeypatch.setattr(
ExtensionCatalog,
"_open_url",
lambda *args, **kwargs: io.BytesIO(_MINIMAL_ZIP_BYTES),
)
monkeypatch.setattr(
_commands, "_validate_safe_cache_dir", lambda root: download_dir
)
download_dir.mkdir(parents=True, exist_ok=True)
def _raise_collision(project_root, dir_, zip_filename):
raise FileExistsError("leaf already exists")
monkeypatch.setattr(_commands, "_safe_open_download_zip", _raise_collision)
install_spy = MagicMock()
monkeypatch.setattr(ExtensionManager, "install_from_zip", install_spy)
result = runner.invoke(
app,
[
"extension",
"add",
"test-ext",
"--from",
"https://example.com/test-ext.zip",
],
)
assert result.exit_code == 1
assert "Could not safely create download file" in result.output
install_spy.assert_not_called()

View File

@@ -2943,6 +2943,135 @@ class TestExtensionSkillRegistration:
assert "speckit-early-ext-world" in metadata["registered_skills"]
# ===== Regression test: upgrade-overwrites-copilot-skills (#3849) =====
class TestRegisterExtensionSkillsForceFlag:
"""Regression tests for the ``force`` flag on ``_register_extension_skills``.
Issue #3849: ``integration upgrade --force`` called ``setup()`` which
regenerated all core-template SKILL.md files, then called
``register_enabled_extensions_for_agent()``. The skip-guard in
``_register_extension_skills`` treated the freshly-written core files as
existing user content and skipped every extension skill, leaving only core
template content on disk.
The fix introduces ``force=True`` in the upgrade path so the guard does not
fire for core-template files that setup() just wrote.
"""
def test_force_false_skips_existing_skill(self, project_dir, temp_dir):
"""Without force=True the skip guard must still protect existing files."""
_create_init_options(project_dir, ai="claude", ai_skills=True)
skills_dir = _create_skills_dir(project_dir, ai="claude")
ext_dir = _create_extension_dir(temp_dir)
# Manually pre-create a SKILL.md as if setup() had already written it
skill_subdir = skills_dir / "speckit-test-ext-hello"
skill_subdir.mkdir(parents=True, exist_ok=True)
skill_file = skill_subdir / "SKILL.md"
skill_file.write_text("core-template content only", encoding="utf-8")
manager = ExtensionManager(project_dir)
manifest = ExtensionManifest(ext_dir / "extension.yml")
# Default (force=False): existing file must not be overwritten
written = manager._register_extension_skills(manifest, ext_dir, force=False)
assert "speckit-test-ext-hello" not in written
assert skill_file.read_text(encoding="utf-8") == "core-template content only"
def test_force_true_overwrites_existing_skill(self, project_dir, temp_dir):
"""With force=True the function must overwrite the existing SKILL.md.
This is the core regression test for #3849: calling
``_register_extension_skills(force=True)`` after ``setup()`` has
written a fresh core-template SKILL.md must replace it with the
composed extension content.
"""
_create_init_options(project_dir, ai="claude", ai_skills=True)
skills_dir = _create_skills_dir(project_dir, ai="claude")
ext_dir = _create_extension_dir(temp_dir)
# Simulate what setup() writes: a bare core-template SKILL.md
skill_subdir = skills_dir / "speckit-test-ext-hello"
skill_subdir.mkdir(parents=True, exist_ok=True)
skill_file = skill_subdir / "SKILL.md"
skill_file.write_text("core-template content only", encoding="utf-8")
manager = ExtensionManager(project_dir)
manifest = ExtensionManifest(ext_dir / "extension.yml")
# Upgrade path (force=True): extension content should replace the core file
written = manager._register_extension_skills(manifest, ext_dir, force=True)
assert "speckit-test-ext-hello" in written, (
"force=True should overwrite the core-template file and return the skill name"
)
content = skill_file.read_text(encoding="utf-8")
assert "Run this to say hello." in content, (
"Extension command body must appear in the overwritten SKILL.md"
)
assert "core-template content only" not in content, (
"Core-template placeholder must have been replaced by extension content"
)
def test_register_enabled_extensions_for_agent_force_flag_threads_through(
self, project_dir, temp_dir
):
"""force=True on register_enabled_extensions_for_agent must reach _register_extension_skills.
End-to-end check: after an upgrade writes a fresh core-template SKILL.md,
``register_enabled_extensions_for_agent(force=True)`` must produce a
SKILL.md that contains the extension content.
"""
_create_init_options(project_dir, ai="claude", ai_skills=True)
skills_dir = _create_skills_dir(project_dir, ai="claude")
ext_dir = _create_extension_dir(temp_dir)
manager = ExtensionManager(project_dir)
# Install extension so it is in the registry
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
# Simulate a freshly-regenerated core-template SKILL.md (as setup() would write)
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
skill_file.write_text("core-template content only", encoding="utf-8")
# Re-register with force=True (upgrade path)
manager.register_enabled_extensions_for_agent("claude", force=True)
content = skill_file.read_text(encoding="utf-8")
assert "Run this to say hello." in content, (
"After register_enabled_extensions_for_agent(force=True), the SKILL.md "
"must contain the extension body, not just the core-template stub."
)
def test_force_true_with_preexisting_dir_but_no_skill_file(
self, project_dir, temp_dir
):
"""force=True must write into a pre-existing directory with no SKILL.md.
The second skip guard (``elif skill_dir_preexists``) should also be
bypassed by force=True so an upgrade can create a missing SKILL.md
even when the skill sub-directory already exists.
"""
_create_init_options(project_dir, ai="claude", ai_skills=True)
skills_dir = _create_skills_dir(project_dir, ai="claude")
ext_dir = _create_extension_dir(temp_dir)
# Create the skill directory without the SKILL.md file
skill_subdir = skills_dir / "speckit-test-ext-hello"
skill_subdir.mkdir(parents=True, exist_ok=True)
skill_file = skill_subdir / "SKILL.md"
assert not skill_file.exists()
manager = ExtensionManager(project_dir)
manifest = ExtensionManifest(ext_dir / "extension.yml")
written = manager._register_extension_skills(manifest, ext_dir, force=True)
assert "speckit-test-ext-hello" in written
assert skill_file.exists()
assert "Run this to say hello." in skill_file.read_text(encoding="utf-8")
# ===== Extension Skill Unregistration Tests =====
class TestExtensionSkillUnregistration:

View File

@@ -49,6 +49,38 @@ from specify_cli._utils import version_satisfies
_MINIMAL_ZIP_BYTES = b"PK\x05\x06" + b"\x00" * 18
def _open_test_download_zip(project_root, download_dir, zip_filename):
"""Cross-platform stand-in for the POSIX-only secure cache primitive.
Mirrors production behavior by making the leaf disappear from disk while
the descriptor stays open. On POSIX the file is unlinked immediately; on
Windows an in-use file cannot be unlinked, so it is opened with
``O_TEMPORARY`` and removed automatically when the descriptor closes.
"""
target = download_dir / zip_filename
o_temporary = getattr(os, "O_TEMPORARY", 0)
if o_temporary:
return os.open(
target,
os.O_RDWR | os.O_CREAT | os.O_EXCL | o_temporary,
0o600,
)
fd = os.open(target, os.O_RDWR | os.O_CREAT | os.O_EXCL, 0o600)
try:
os.unlink(target)
except OSError:
os.close(fd)
raise
return fd
def _validate_safe_cache_dir_test_stand_in(project_root):
"""Cross-platform stand-in for the secure cache validator."""
download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads"
download_dir.mkdir(parents=True, exist_ok=True)
return download_dir
def can_create_symlink(tmp_path: Path) -> bool:
"""Return True when the current platform/user can create file symlinks."""
target = tmp_path / "symlink-target.txt"
@@ -576,7 +608,7 @@ class TestExtensionManifest:
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="must provide at least one command or hook"):
with pytest.raises(ValidationError, match="must provide at least one command, hook, or event"):
ExtensionManifest(manifest_path)
def test_hooks_only_extension(self, temp_dir, valid_manifest_data):
@@ -614,6 +646,67 @@ class TestExtensionManifest:
with pytest.raises(ValidationError, match="Invalid provides.commands"):
ExtensionManifest(manifest_path)
@pytest.mark.parametrize("section", ["extension", "requires", "provides"])
@pytest.mark.parametrize("bad", [None, [], "text"])
def test_required_section_not_mapping_rejected(
self, temp_dir, valid_manifest_data, section, bad
):
"""A required section that is written but empty or wrongly shaped must
raise ValidationError, not a raw TypeError/AttributeError.
REQUIRED_FIELDS only checks key presence, so `provides:` with no value
passed it and then hit `None.get(...)`. That AttributeError escaped
list_installed()'s ValidationError-only "Corrupted extension" fallback,
so one bad extension made `specify extension list` exit 1 instead of
listing the others.
"""
import yaml
valid_manifest_data[section] = bad
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match=f"Invalid {section}"):
ExtensionManifest(manifest_path)
def test_empty_provides_mapping_is_still_accepted_with_hooks(
self, temp_dir, valid_manifest_data
):
"""Regression guard: `provides: {}` is a well-SHAPED mapping, so the new
shape check must not reject it — an extension may provide only hooks."""
import yaml
valid_manifest_data["provides"] = {}
assert valid_manifest_data.get("hooks"), "fixture is expected to define hooks"
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
ExtensionManifest(manifest_path) # must not raise
def test_empty_provides_and_no_hooks_keeps_its_own_message(
self, temp_dir, valid_manifest_data
):
"""...and with no hooks (or events) either, it reports the "nothing
provided" message rather than the new shape error."""
import yaml
valid_manifest_data["provides"] = {}
valid_manifest_data.pop("hooks", None)
valid_manifest_data.pop("events", None)
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w') as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(
ValidationError, match="at least one command, hook, or event"
):
ExtensionManifest(manifest_path)
def test_hooks_not_dict_rejected(self, temp_dir, valid_manifest_data):
"""Test manifest with hooks as a list is rejected."""
import yaml
@@ -2168,6 +2261,79 @@ class TestExtensionManager:
assert not manager.registry.is_installed("test-ext")
@pytest.mark.skipif(os.name == "nt", reason="requires replacing an open file")
def test_install_from_zip_uses_open_archive_after_path_replacement(
self, extension_dir, project_dir, temp_dir
):
"""An authoritative archive stream must survive pathname replacement."""
import zipfile
zip_path = temp_dir / "original-extension.zip"
with zipfile.ZipFile(zip_path, "w") as archive:
for file_path in extension_dir.rglob("*"):
if file_path.is_file():
archive.write(file_path, file_path.relative_to(extension_dir))
manager = ExtensionManager(project_dir)
with zip_path.open("rb") as archive_file:
zip_path.unlink()
with zipfile.ZipFile(zip_path, "w"):
pass
manifest = manager.install_from_zip(
zip_path,
"0.1.0",
archive_file=archive_file,
)
assert manifest.id == "test-ext"
assert manager.registry.is_installed("test-ext")
@pytest.mark.parametrize("suffix", [".tar.gz", ".tgz"])
@pytest.mark.parametrize("nested", [False, True])
def test_install_from_tar_archive(
self, extension_dir, project_dir, temp_dir, suffix, nested
):
"""Tar archives install with the same flat/nested behavior as ZIP."""
import tarfile
archive_path = temp_dir / f"test-ext{suffix}"
with tarfile.open(archive_path, "w:gz") as archive:
for file_path in extension_dir.rglob("*"):
if file_path.is_file():
relative = file_path.relative_to(extension_dir)
arcname = Path("test-ext-v1") / relative if nested else relative
archive.add(file_path, arcname=arcname)
manager = ExtensionManager(project_dir)
manifest = manager.install_from_archive(archive_path, "0.1.0")
assert manifest.id == "test-ext"
assert manager.registry.is_installed("test-ext")
def test_install_from_tar_rejects_symlink_entry(
self, extension_dir, project_dir, temp_dir
):
import tarfile
archive_path = temp_dir / "symlink-extension.tar.gz"
with tarfile.open(archive_path, "w:gz") as archive:
for file_path in extension_dir.rglob("*"):
if file_path.is_file():
archive.add(
file_path,
arcname=file_path.relative_to(extension_dir),
)
link = tarfile.TarInfo("templates/escape")
link.type = tarfile.SYMTYPE
link.linkname = "../../outside"
archive.addfile(link)
manager = ExtensionManager(project_dir)
with pytest.raises(ValidationError, match="Unsafe symlink"):
manager.install_from_archive(archive_path, "0.1.0")
assert not manager.registry.is_installed("test-ext")
assert not manager.registry.is_installed("test-ext")
def test_install_duplicate_error_mentions_force(self, extension_dir, project_dir):
"""Test that duplicate install error message suggests --force."""
manager = ExtensionManager(project_dir)
@@ -2662,6 +2828,36 @@ Real body starts here.
assert parsed["description"] == "first line\nsecond line\n"
@pytest.mark.parametrize(
("description", "expected"),
[
(None, ""), # "description:" with no value
(42, "42"), # unquoted number
(True, "True"), # unquoted boolean
(["a", "b"], "['a', 'b']"), # was silently concatenated to "ab"
],
)
def test_render_toml_command_coerces_non_string_description(
self, description, expected
):
"""Frontmatter comes from yaml.safe_load, so description can be any type.
_render_basic_toml_string iterates the value and calls ord() per
character, so a non-string raised a raw TypeError and a list of
single-character items was silently concatenated into a wrong value.
render_yaml_command (same class) already coerces; this brings the TOML
branch to parity.
"""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
registrar = AgentCommandRegistrar()
output = registrar.render_toml_command(
{"description": description}, "body", "extension:test-ext"
)
parsed = tomllib.loads(output)
assert parsed["description"] == expected
def test_render_toml_command_escapes_control_characters(self):
"""Control characters and a lone CR must be escaped so the TOML parses.
@@ -2884,6 +3080,29 @@ Real body starts here.
assert "source: test-ext:commands/hello.md" in content
assert "<!-- Extension:" not in content
def test_codex_skill_registration_uses_dollar_command_refs(
self, extension_dir, project_dir
):
"""Codex extension skills use the native dollar invocation prefix."""
skills_dir = project_dir / ".agents" / "skills"
skills_dir.mkdir(parents=True)
command = extension_dir / "commands" / "hello.md"
command.write_text(
"---\ndescription: Test hello command\n---\n\nRun __SPECKIT_COMMAND_PLAN__.",
encoding="utf-8",
)
manifest = ExtensionManifest(extension_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_agent(
"codex", manifest, extension_dir, project_dir
)
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
content = skill_file.read_text(encoding="utf-8")
assert "$speckit-plan" in content
assert "/speckit-plan" not in content
def test_codex_skill_registration_resolves_script_placeholders(self, project_dir, temp_dir):
"""Codex SKILL.md overrides should resolve script placeholders."""
import yaml
@@ -4389,6 +4608,44 @@ class TestExtensionCatalog:
results = catalog.search(query="jira")
assert {r["id"] for r in results} == {"jira"}
def test_search_and_info_tolerate_non_list_tags(self, temp_dir):
"""A scalar ``tags:`` value must not crash the search/info display.
``ExtensionCatalog.search`` guards its tag *filter* with
``isinstance(raw_tags, list)``, but the ``extension search`` and
``extension info`` display paths only tested truthiness before
iterating. ``tags: 5`` is truthy and not iterable, so both raised
``TypeError: 'int' object is not iterable``.
"""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
merged = [{
"id": "jira",
"name": "Jira",
"version": "1.0.0",
"description": "Jira",
"tags": 5,
}]
with patch.object(ExtensionCatalog, "_get_merged_extensions", return_value=merged), \
patch("specify_cli.extensions._commands._require_specify_project",
return_value=project_dir):
searched = CliRunner().invoke(app, ["extension", "search", "Jira"])
info = CliRunner().invoke(app, ["extension", "info", "jira"])
assert searched.exit_code == 0, searched.output
assert "Jira" in searched.output
assert "Tags:" not in searched.output
assert info.exit_code == 0, info.output
assert "Tags:" not in info.output
def test_search_tolerates_non_string_author_and_name(self, temp_dir):
"""Non-string catalog author/name must not crash author/query search.
@@ -4746,9 +5003,9 @@ class TestExtensionCatalog:
catalog = self._make_catalog(temp_dir)
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(
mock_response.read.side_effect = io.BytesIO(json.dumps(
{"schema_version": "1.0", "extensions": {}}
).encode()
).encode()).read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "http://evil.test/catalog.json"
@@ -4794,9 +5051,9 @@ class TestExtensionCatalog:
catalog = self._make_catalog(temp_dir)
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(
mock_response.read.side_effect = io.BytesIO(json.dumps(
{"schema_version": "1.0", "extensions": {}}
).encode()
).encode()).read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "http://evil.test/catalog.json"
@@ -5554,6 +5811,35 @@ class TestExtensionCatalog:
assert captured[0].get_header("Authorization") == "Bearer ghp_testtoken"
assert captured[0].get_header("Accept") == "application/octet-stream"
@pytest.mark.parametrize("suffix", [".tar.gz", ".tgz"])
def test_download_extension_preserves_tar_archive_format(
self, temp_dir, suffix
):
import tarfile
from unittest.mock import patch
archive_buffer = io.BytesIO()
with tarfile.open(fileobj=archive_buffer, mode="w:gz") as archive:
content = b"extension:\n id: test-ext\n"
member = tarfile.TarInfo("extension.yml")
member.size = len(content)
archive.addfile(member, io.BytesIO(content))
archive_bytes = archive_buffer.getvalue()
catalog = self._make_catalog(temp_dir)
ext_info = {
"id": "test-ext",
"name": "Test Extension",
"version": "1.0.0",
"download_url": f"https://example.com/test-ext{suffix}",
}
with patch.object(catalog, "get_extension_info", return_value=ext_info), \
patch.object(catalog, "_open_url", return_value=self._mock_response(archive_bytes)):
archive_path = catalog.download_extension("test-ext", target_dir=temp_dir)
assert archive_path.name == "test-ext-1.0.0.tar.gz"
assert archive_path.read_bytes() == archive_bytes
# ===== CatalogEntry Tests =====
@@ -7239,7 +7525,15 @@ class TestExtensionAddCLI:
manifest_id = "[red]bad[/red]"
def fake_install_from_zip(self_obj, zip_path, speckit_version, priority=10, force=False):
def fake_install_from_zip(
self_obj,
zip_path,
speckit_version,
priority=10,
force=False,
*,
archive_file=None,
):
return SimpleNamespace(
id=manifest_id,
name="Bad Extension",
@@ -7253,7 +7547,9 @@ class TestExtensionAddCLI:
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("typer.confirm", return_value=True), \
patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \
patch("specify_cli.authentication.http.open_url", return_value=FakeResponse(_MINIMAL_ZIP_BYTES)), \
patch("specify_cli.extensions._commands._safe_open_download_zip", side_effect=_open_test_download_zip), \
patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip), \
patch.object(ExtensionRegistry, "get", return_value={}):
result = runner.invoke(
@@ -7301,6 +7597,7 @@ class TestExtensionAddCLI:
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("typer.confirm", return_value=True), \
patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \
patch(
"specify_cli.authentication.http.open_url",
side_effect=urllib.error.URLError("bad [red]download[/red]"),
@@ -7342,6 +7639,7 @@ class TestExtensionAddCLI:
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("typer.confirm", return_value=True), \
patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \
patch(
"specify_cli.authentication.http.open_url",
return_value=FakeResponse(b"<!DOCTYPE html><html>Sign in</html>"),
@@ -7392,6 +7690,7 @@ class TestExtensionAddCLI:
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("typer.confirm", return_value=True), \
patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \
patch(
"specify_cli.authentication.http.open_url",
return_value=FakeResponse(_MINIMAL_ZIP_BYTES),
@@ -7447,7 +7746,15 @@ class TestExtensionAddCLI:
seen["headers"] = extra_headers
return FakeResponse(_MINIMAL_ZIP_BYTES)
def fake_install(self_obj, zip_path, speckit_version, priority=10, force=False):
def fake_install(
self_obj,
zip_path,
speckit_version,
priority=10,
force=False,
*,
archive_file=None,
):
return SimpleNamespace(
id="x", name="X", version="1.0.0", description="", warnings=[], commands=[], hooks=[]
)
@@ -7455,8 +7762,10 @@ class TestExtensionAddCLI:
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("typer.confirm", return_value=True), \
patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \
patch("specify_cli.authentication.http.github_provider_hosts", return_value=("ghes.example",)), \
patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url), \
patch("specify_cli.extensions._commands._safe_open_download_zip", side_effect=_open_test_download_zip), \
patch.object(ExtensionManager, "install_from_zip", fake_install):
result = runner.invoke(
app,
@@ -7529,10 +7838,19 @@ class TestExtensionAddCLI:
downloads_dir = project_dir / ".specify" / "extensions" / ".cache" / "downloads"
installed = {}
def fake_install_from_zip(self_obj, zip_path, speckit_version, priority=10, force=False):
def fake_install_from_zip(
self_obj,
zip_path,
speckit_version,
priority=10,
force=False,
*,
archive_file=None,
):
captured_path = Path(zip_path)
installed["zip_path"] = captured_path
installed["zip_bytes"] = captured_path.read_bytes()
installed["zip_bytes"] = archive_file.read()
archive_file.seek(0)
return SimpleNamespace(
id="escape",
name="Escape Test",
@@ -7546,7 +7864,9 @@ class TestExtensionAddCLI:
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("typer.confirm", return_value=True), \
patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \
patch("specify_cli.authentication.http.open_url", return_value=FakeResponse(_MINIMAL_ZIP_BYTES)), \
patch("specify_cli.extensions._commands._safe_open_download_zip", side_effect=_open_test_download_zip), \
patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip):
result = runner.invoke(
app,
@@ -7607,7 +7927,7 @@ class TestDownloadExtensionBundled:
}
mock_response = MagicMock()
mock_response.read.side_effect = io.BytesIO(b"fake zip data").read
mock_response.read.side_effect = io.BytesIO(_MINIMAL_ZIP_BYTES).read
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"

View File

@@ -379,6 +379,31 @@ class TestResolveGitHubReleaseAssetApiUrl:
assert result == "https://api.github.com/repos/org/repo/releases/assets/99"
assert captured == ["https://api.github.com/repos/org/repo/releases/tags/v1.0"]
def test_tag_with_literal_slash_in_path(self):
"""A tag containing a literal '/' (e.g. feature/v1.0.0) splits across
multiple URL path segments. The implementation must join all segments
between 'download/' and the asset name to reconstruct the full tag."""
captured_urls = []
asset_url = "https://api.github.com/repos/org/repo/releases/assets/77"
@contextmanager
def capturing_open(url, timeout=None, extra_headers=None):
captured_urls.append(url)
resp = MagicMock()
resp.read.side_effect = io.BytesIO(json.dumps({
"assets": [{"name": "asset.zip", "url": asset_url}]
}).encode()).read
yield resp
result = resolve_github_release_asset_api_url(
"https://github.com/org/repo/releases/download/feature/v1.0.0/asset.zip",
capturing_open,
)
assert result == asset_url
# Tag must be the full "feature/v1.0.0", not just "v1.0.0"
assert len(captured_urls) == 1
assert "releases/tags/feature%2Fv1.0.0" in captured_urls[0]
class TestGitHubRedirectAuth:
"""Tests for GitHub-owned redirect auth handling."""

Some files were not shown because too many files have changed in this diff Show More