Compare commits

..

21 Commits

Author SHA1 Message Date
github-actions[bot]
7fc9974014 chore: bump version to 0.14.1 2026-07-23 20:17:39 +00:00
github-actions[bot]
58f5730dd5 Update Agent Parity Governance preset to v0.4.0 (#3697)
Update agent-parity-governance preset submitted by @hindermath:
- presets/catalog.community.json (version, download_url, description, documentation, updated_at)
- docs/community/presets.md community presets table

Closes #3684

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-23 15:09:31 -05:00
Ali jawwad
52b20f1a82 fix(bundler): InstallResult.changed counts uninstalled as a change (#3692)
* fix(bundler): InstallResult.changed counts uninstalled as a change

The `changed` property only considered `installed` and `refreshed`, omitting
`uninstalled`. A `bundle update` whose new manifest drops components (removing
them via the refresh path) with no new install/refresh produces
installed=[], refreshed=[], uninstalled=[dropped set] — yet `changed` returned
False, misreporting a mutating update as a no-op.

Include `uninstalled` in the disjunction (it is the third mutating outcome
list on the same dataclass, also the sole output of the remove_bundle path).

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

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

* ci: pin ruff to 0.15.0 to avoid 0.16.0 default-ruleset breakage

ruff 0.16.0 expanded its default rule set from ~59 to ~413 rules,
causing the unpinned `uvx ruff check` step to report ~1475 pre-existing
violations unrelated to this change. Pin to 0.15.0 to restore green lint.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
2026-07-23 14:52:06 -05:00
github-actions[bot]
579579ba80 [preset] Update Cross-Platform Governance preset to v0.2.1 (#3695)
* Update Cross-Platform Governance preset to v0.2.1

Update cross-platform-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 #3683

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

* ci: pin ruff to 0.15.0 to avoid 0.16.0 default-ruleset breakage

ruff 0.16.0 expanded its default rule set from ~59 to ~413 rules,
causing the unpinned `uvx ruff check` step to report 1476 pre-existing
violations. Pin to 0.15.0 to restore green lint until the codebase is
audited against the new defaults.

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>
2026-07-23 14:38:40 -05:00
github-actions[bot]
34a086940f Update A11Y Governance preset to v0.4.1 (#3693)
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 #3682

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-23 13:53:52 -05:00
Ali jawwad
0b6bf865c1 fix(workflows): escape step-graph brackets in workflow info so the type shows (#3690)
`workflow info` rendered each step as `→ <id> [<type>]`, but console.print
has Rich markup enabled, so `[<type>]` was parsed as a style tag named after
the step type (command/gate/prompt/…) and silently swallowed — every step
printed as `→ <id> ` with the type gone.

Escape the literal bracket with `\[` (and escape id/type via _escape_markup,
as the sibling workflow_list does), so Rich renders `[<type>]` literally.
Mirrors the in-file `\[disabled]` precedent.

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 13:47:20 -05:00
Ali jawwad
043c4ec572 fix(workflows): filter parser rejects trailing tokens (fullmatch, not match) (#3689)
_apply_filter parsed a name(arg) filter with an UNANCHORED regex
(re.match(r"(\w+)\((.+)\)")), so any tokens after the closing paren were
silently discarded. Because _evaluate_simple_expression splits the top-level
pipe before comparison/boolean operators, `count | default(0) > 5` was split
into value `count` and filter segment `default(0) > 5`; the segment matched
as `default(0)` and `> 5` vanished — the filter's value was returned as the
whole expression, giving a silently wrong result.

Use re.fullmatch so a mis-wired segment falls through to the existing
"unsupported form" ValueError, mirroring the from_json branch's strict
trailing-token handling. The greedy `.+` still matches legitimate forms
(literal `)` / `|` inside quoted args), so registered/chained/quoted-pipe
filters are unaffected.

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 13:37:07 -05:00
github-actions[bot]
5ad312863f Update iSAQB Architecture Governance preset to v0.2.1 (#3687)
Update isaqb-architecture-governance preset submitted by @hindermath:
- presets/catalog.community.json (version, download_url, description, documentation, updated_at)
- docs/community/presets.md community presets table

Closes #3681

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-23 12:58:58 -05:00
Quratulain-bilal
58b3cadb39 fix(extensions): parse SKILL.md on the --- delimiter line during removal (#3634)
* fix(extensions): parse SKILL.md on the --- delimiter line during removal

ExtensionManager._unregister_extension_skills verified an installed skill
before deleting it by reading metadata.source back from its SKILL.md with a
raw split("---", 2). That substring split stops at the first "---" anywhere
after the opening delimiter, including one embedded in a command description
(e.g. "Separate sections with --- markers"). The frontmatter was then
truncated mid-value, metadata.source parsed empty, the skill looked
unrelated, and its directory was left orphaned on uninstall.

Parse on the "---" delimiter *line* instead, reusing CommandRegistrar.
parse_frontmatter (the line-anchored parser from #3590) in both the fast
(registry-driven) and fallback (directory-scan) removal paths.

Add a regression test that installs an extension whose command description
contains "---", removes it, and asserts the skill directory is gone. Fails
before the fix (dir orphaned), passes after.

* test: cover the fallback scan branch for the --- SKILL.md parse

Copilot noted the new regression test only exercised the fast removal
path (skills_project keeps ai_skills enabled, so remove() resolves the
skills dir directly). Add test_skills_removed_with_dashes_via_fallback_scan,
which deletes init-options.json after install so _get_skills_dir() returns
None and removal takes the fallback directory-scan branch. That branch
re-reads metadata.source with an independently duplicated parser; reverting
it to the old substring split now fails this test (dir orphaned) while the
fast-path test still passes.
2026-07-23 12:49:06 -05:00
Noor ul ain
cce47f6900 fix(cli): guard lazy .hostname ValueError in extension/preset add --from (#3651)
* fix(cli): guard lazy .hostname ValueError in extension/preset add --from

`extension add --from <url>` and `preset add --from <url>` validated the URL
by reading `parsed.hostname` OUTSIDE their `try/except ValueError` guards. A
bracketed-but-invalid IPv6 authority (e.g. "https://[not-an-ip]/x.zip") parses
cleanly under urlparse() on Python < 3.14 and only raises ValueError lazily on
the first .hostname access. On the interpreters spec-kit supports (>=3.11) that
raw ValueError leaked past the CLI, printing an uncaught traceback instead of
the clean "Invalid URL" error. (The raise moved eager into urlparse() only in
3.14.) Same bug class as the catalog/download fixes #3433/#3435/#3437/#3577.

- extensions/_commands.py: read parsed.hostname inside the existing try and
  reuse it for the localhost check.
- presets/_commands.py: guard the up-front `urlparse(from_url).hostname` read
  (preserves the "Invalid URL" message), and harden the nested
  `_is_allowed_download_url` to take a URL string and parse+read .hostname
  inside its own try/except -> returns False on malformed input. This also
  covers the redirect-validator and final-URL (post-redirect) checks, where the
  URL is server-controlled.

Regression tests for each command: a bracketed-non-IP URL, plus a monkeypatched
lazy-.hostname raiser that reproduces the pre-3.14 shape independently of the
running interpreter (fails with a raw ValueError before the fix, verified via
test-the-test).

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

* Potential fix for pull request finding

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

* fix(cli): address Copilot review on --from URL guard comments/tests

Copilot's review on #3651 flagged two accuracy problems:

1. The guard comments asserted a specific (and incorrect) CPython version
   history -- that "https://[not-an-ip]/..." parses cleanly under urlparse()
   on Python < 3.14 and only raises ValueError lazily on the first .hostname
   access. In fact the eager bracketed-host check (gh-103848, CVE-2024-11168)
   was backported to the 3.11 branch and shipped in 3.11.4, so on every
   interpreter spec-kit supports (>=3.11) that URL is rejected eagerly at
   urlparse(). Reworded the three source comments to state the guard as a
   defensive policy (parsing OR the .hostname read can raise ValueError, guard
   both) without asserting version history.

2. The two monkeypatched lazy-.hostname tests were described as reproducing
   "the exact production path" / "the Python < 3.14 shape". They are synthetic
   defensive cases. Relabeled them as synthetic defensive coverage that does
   not reproduce any specific CPython behavior, and dropped the version-history
   claims from the bracketed-non-IP test docstrings.

The second-round suggestion (_is_allowed_download_url(final_url) instead of
_is_allowed_download_url(_urlparse(final_url))) was already applied in the
original commit.

Behavior unchanged; comments/docstrings only. URL-guard tests pass.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-23 12:30:09 -05:00
github-actions[bot]
e14561f773 Update Architecture Governance preset to v0.5.1 (#3686)
Update architecture-governance preset submitted by @hindermath:
- presets/catalog.community.json (version, download_url, documentation, description, updated_at)
- docs/community/presets.md community presets table

Closes #3680

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-23 12:14:28 -05:00
Ali jawwad
cf0abe28f7 fix(bundler): reject a top-level non-mapping bundle-catalogs.yml in _merge_config (#3659)
* fix(bundler): reject a top-level non-mapping bundle-catalogs.yml in _merge_config

_merge_config silently ignored a top-level non-mapping document (a YAML list
or scalar) — `data.get("catalogs") if isinstance(data, dict) else None` made
it fall through to the built-in default stack — while the sibling reader of
the SAME file (commands_impl/catalog_config._read) raises "expected a mapping
at the top level". #3623 already made the inner non-list `catalogs` value
agree between the two readers; this closes the remaining top-level-shape gap
so both readers reject the same malformed documents.

An empty file (load_yaml coerces to {}), absent `catalogs`, and `catalogs: []`
all remain no-ops.

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

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

* fix(bundler): reject FALSY non-mapping catalog configs (parse raw, not load_yaml)

Address review (Copilot on #3659): the top-level guard used the shared
load_yaml, whose `yaml.safe_load(...) or {}` coerces a FALSY top-level
document ([], false, 0, '') to {} BEFORE the isinstance check — so those
malformed configs silently fell back to the built-in defaults instead of
raising. Only truthy non-mappings ([a,b], 42) were caught.

Parse the raw document in both readers of bundle-catalogs.yml
(models/catalog._merge_config AND commands_impl/catalog_config._read):
an empty document (None) stays a no-op, but every non-mapping top level —
falsy or truthy — now raises "expected a mapping at the top level". This
keeps the two readers genuinely consistent. Tests cover the falsy cases for
both.

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

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

* fix(bundler): correct load_yaml so only empty documents become {} (not falsy non-mappings)

Address review (Copilot re-review of #3659): the previous fix duplicated
YAML parsing + exception wrapping inline in two readers, bypassing the
centralized yamlio helper. Instead, correct the root cause in load_yaml.

load_yaml did `yaml.safe_load(...) or {}`, which coerced ANY falsy result
(None empty-doc, but also [], false, 0, '') to {} — contradicting its own
docstring ("{} for an empty document") and hiding malformed non-mapping
configs from callers' shape guards. Change to `{} if data is None else data`
so only an empty document becomes {}; a non-mapping top level is returned
as-parsed.

Revert the inline raw-parse in models/catalog._merge_config and
commands_impl/catalog_config._read back to the centralized load_yaml; their
existing `isinstance(data, dict)` guards now correctly reject falsy
non-mappings too. All three load_yaml callers (these two + manifest.from_dict)
already guard the top-level shape, so none regresses. Falsy-case tests for
both readers retained.

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

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

* fix(bundler): distinguish an empty YAML document from an explicit null in load_yaml

Address review (Copilot on #3659): yaml.safe_load returns None for BOTH an
empty document AND an explicit null scalar (`null`/`~`), so mapping None to {}
still let a top-level null bundle-catalogs.yml fall back to defaults instead of
being rejected by the mapping guard.

Use yaml.compose (which yields a node only for a non-empty document) to tell
the two apart: a truly empty document becomes {}, while an explicit null is
returned as None so the callers' isinstance(dict) guard rejects it like any
other non-mapping. Drop the now-incorrect `if data is None: return []`
short-circuit in catalog_config._read so an explicit null reaches that guard.
Tests cover null/~ for both readers plus empty/comment-only no-op.

🤖 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-23 11:15:14 -05:00
github-actions[bot]
9b3546f437 Update Security Governance preset to v0.6.1 (#3685)
Update security-governance preset submitted by @hindermath to:
- presets/catalog.community.json (version, download_url, description, documentation, updated_at)
- docs/community/presets.md community presets table

Closes #3679

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-23 11:08:26 -05:00
Ali jawwad
e9dfe900f6 fix(integrations): declare OmpIntegration multi_install_safe (#3650)
* fix(integrations): declare OmpIntegration multi_install_safe

OmpIntegration is a plain MarkdownIntegration whose files live only under
its isolated, static root .omp/commands/, disjoint from every other
integration. But it never declared multi_install_safe, so it inherited the
IntegrationBase default False — leaving `specify integration status` in a
permanent unsafe-multi-install ERROR state whenever omp is co-installed
alongside another agent, with no acknowledgment path.

Add `multi_install_safe = True`, mirroring the isolated MarkdownIntegration
cohort (qwen, shai, qodercli, junie, kilocode) and the kiro-cli #3471 fix.
The parametrized registry isolation contracts auto-include omp once the flag
is set and pass (.omp/commands is isolated and its manifest disjoint).

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

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

* docs(integrations): list omp in the multi-install-safe reference table

Declaring OmpIntegration multi_install_safe means the reference table in
docs/reference/integrations.md (which states it lists all currently
declared multi-install-safe integrations) should include it. Add the
alphabetized omp row with its .omp/commands isolation path.

🤖 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-23 11:07:40 -05:00
Dhruv Rastogi
d0a83890d5 feat(git-extension): add configurable Conventional Commit support (#3390) (#3413)
* feat(git-extension): add configurable Conventional Commit support

Adds a commit_style option (fixed | conventional) to the git
extension's auto-commit config. When set to conventional, the
speckit.git.commit hook instructs the agent to generate a Conventional
Commit message from the diff and pass it to auto-commit.sh /
auto-commit.ps1 as an explicit argument. If no message is supplied in
conventional mode, the scripts fail loudly (stderr + exit 1) instead of
silently falling back to the fixed message, but still short-circuit
cleanly when there are no changes to commit.

- extensions/git/config-template.yml, git-config.yml: new
  commit_style: fixed (default) / conventional option.
- extensions/git/scripts/bash/auto-commit.sh: optional
  [generated_message] arg, commit_style parsing, conventional-mode
  enforcement.
- extensions/git/scripts/powershell/auto-commit.ps1: mirrored
  PowerShell implementation.
- extensions/git/commands/speckit.git.commit.md: documents commit
  message styles and updated execution/config guidance.
- extensions/git/README.md: documents the new option.
- tests/extensions/git/test_git_extension.py: regression tests for
  fixed default, conventional success, conventional missing-message
  failure, and no-changes short-circuit (bash + PowerShell).

Fixes #3390

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

* fix(tests): check combined stdout+stderr for conventional commit_style failure test

Write-Warning output stream placement is not deterministic across pwsh
versions/platforms (observed failing on macOS CI). Match the existing
pattern used elsewhere in this file (e.g.
test_not_a_repo_still_detected_with_autocrlf) by asserting against the
combined stdout+stderr instead of stderr alone.

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

* fix(git-extension): strip YAML inline comments from commit_style value

Copilot review feedback on PR #3413 identified that commit_style parsing
didn't strip trailing YAML inline comments (e.g. "commit_style: conventional
# team standard"), causing the value to retain a trailing comment fragment
and silently skip conventional-mode enforcement.

- bash: fix the inline-comment strip regex to use a proper {1,} interval
  so multiple spaces before '#' are consumed together with the comment,
  preventing a stray trailing quote character from surviving quote-strip
  when the value is quoted (e.g. commit_style: "conventional"  # x).
- powershell: already handled this correctly via \s+#.*$ + Trim(); no
  behavior change needed there.
- tests: add regression coverage for commit_style values with trailing
  inline comments (bash + pwsh), and a pwsh regression test for the
  no-changes short-circuit ordering, per additional Copilot suggestion.

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

* fix(git-extension): validate commit_style and reword abort message per review

Address Copilot review feedback on PR #3413:
- Validate commit_style against the documented fixed/conventional values;
  an unrecognized value now warns and falls back to fixed instead of
  silently mis-parsing.
- Reword the conventional-mode-without-generated-message message from
  'skipped auto-commit' to 'aborting auto-commit' since the script exits
  1 (a failure, not a skip), and include the actionable remediation.
- Add regression tests (bash + pwsh) covering the unknown commit_style
  fallback.

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

* fix(git-extension): limit commit_style parsing to first match in config

Address Copilot review feedback on PR #3413: grep '^commit_style:' without
-m1 could concatenate values if a config file accidentally contains
multiple commit_style lines (e.g. from a bad merge/manual edit), causing
an unexpected fallback to 'fixed'. Limit to the first match and add a
regression test covering duplicate commit_style lines.

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

* fix(git-extension): avoid shell interpolation of generated commit messages

- Remove 	r -d '[:space:]' from commit_style parsing in auto-commit.sh:
  it stripped ALL whitespace (not just leading/trailing), so
  commit_style: con ventional was silently normalized to conventional
  instead of being rejected as unknown (PowerShell version already
  rejected it correctly).
- Add a file-based message-passing channel to both auto-commit scripts:
  --message-file <path> (bash) / -MessageFile <path> (PowerShell).
  Agent-generated commit messages may contain quotes, $(...), or
  backticks; passing them as a shell argument risked command injection
  if ever inlined into a shell command string. The new flag reads the
  message from a file instead, so untrusted content never touches a
  shell command line. The raw positional-argument form is kept for
  backward compatibility.
- Update speckit.git.commit.md to instruct the agent to write the
  generated message to a temp file (via its file-editing tool) and pass
  the file path, explicitly warning against inlining the message into a
  shell command string.
- Add test coverage: explicit commit_style: fixed (previously only the
  absent-key default was tested), --message-file/-MessageFile success
  path (including injection-shaped content), and missing-file error path,
  for both bash and PowerShell suites.

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

* fix(git-extension): exclude --message-file transport file from staging

The temp file passed via --message-file / -MessageFile was read but left
in the worktree. If written inside the project (as an agent's file-editing
tool would naturally do), git add . staged it into the commit, and its
mere presence as an untracked file could also defeat the no-changes
short-circuit, causing a spurious commit containing only that file.

Remove the file immediately after its content is captured, before the
change-detection check and before staging. Add bash + pwsh regression
tests covering both scenarios.

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

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-07-23 10:45:18 -05:00
Ali jawwad
88b3230e2e fix(extensions): hyphenate command names in the Forge post-install listing (#3669)
After `specify extension add`, the "Provided commands" summary hyphenated
command names only for Cline. For a Forge project the names were printed in
dotted form (e.g. `speckit.test-ext.hello`), but Forge registers them
hyphenated (`speckit-test-ext-hello`), so the printed names didn't match
what the user actually invokes in Forge.

Extend the existing Cline handling to Forge via `format_forge_command_name`,
completing the Forge command-name parity already fixed for hook invocations
(#3641) and the init next-steps panel (#3642).

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 09:36:04 -05:00
Ali jawwad
f5be0fffc8 fix(bundler): reject falsy non-mapping requires/provides in CatalogEntry.from_dict (#3667)
CatalogEntry.from_dict used `data.get("requires") or {}` and
`data.get("provides") or {}`, so a FALSY non-mapping ([], '', 0, false) was
coerced to {} before the isinstance guard — a corrupt catalog entry passed
silently. Only a truthy non-mapping was rejected.

Handle None explicitly and reject every other non-mapping, mirroring the
merged manifest requires/provides/integration guards (#3629, #3661).

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 09:33:19 -05:00
Ali jawwad
34bbaafbf3 fix(bundler): reject falsy non-list bundles/contributed_components in records (#3666)
load_records and InstalledBundleRecord.from_dict defaulted their list fields
with `data.get(...) or []` BEFORE the isinstance(list) guard, so a FALSY
non-list value (0, '', False, {}) was coerced to [] and the guard became dead
code — a corrupt .specify/bundle-records.json was silently read as "no
bundles"/"no components" instead of raising. Only an absent/None value should
mean empty.

Handle None explicitly and reject every other non-list, mirroring the merged
requires/provides/integration guards (#3629, #3661) and the catalog_config
sibling reader.

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 09:22:28 -05:00
github-actions[bot]
8def197612 Update Intake Authoring Governance preset to v0.1.1 (#3678)
Update intake-authoring-governance preset submitted by @hindermath:
- presets/catalog.community.json (version, download_url, documentation, description, tags, updated_at)
- docs/community/presets.md community presets table

Closes #3676

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-23 09:21:36 -05:00
YASHURA
a62fb1f034 docs(extensions): clarify agent-context README and add config examples (#3389)
* docs(extensions): clarify agent-context README and add config examples

Rewrite the agent-context extension README to read as plain prose
instead of a bullet dump, and add the missing install/disable
commands (specify extension add/disable/enable agent-context).
Add inline example comments to agent-context-config.yml for
context_file/context_files.

* docs(agent-context): clarify config documentation

- Reformat comments to flow as single-line paragraphs instead of multi-line breaks
- Add "WHAT" sections describing each configuration option's purpose
- Add "REQUIREMENT" sections specifying if options are optional or required
- Add explicit EXAMPLE sections for context_markers configuration
- Improve clarity of context_file and context_files option descriptions

* docs(agent-context): fix GitHub casing, clarify config

- Fix "Github" -> "GitHub" casing in README issues link
- Clarify agent-context-config.yml comments on context_file/context_files behavior and precedence

* YAML indentation fix for context markers

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

* docs(agent-context): simplify README config section

- Clarify config file path reference (.specify/extensions path vs repo path)
- Remove duplicated YAML example/field docs from README, point to config file directly
- Minor spacing fix in agent-context-config.yml comment

* docs(agent-context): clarify config file path in README

- Reference the installed .specify config path alongside the repo-relative link

* docs(agent-context): clarify install and marker requirement

- README: clarify install command must be run from an initialized Spec Kit project root
- config: correct context_markers requirement from REQUIRED to OPTIONAL

* Wording Fix

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

* docs(agent-context): clarify context_file path rules

- Document that context_file/context_files are relative to the project root (directory containing .specify/)
- State the rejected path forms (absolute paths, backslash separators, .. segments) directly in each field's WHAT comment

* Updated supported invocation syntaxes

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

* Extension Disable Clarification

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

* docs(agent-context): document .mdc frontmatter exception

- Clarify that .mdc files get alwaysApply: true set in frontmatter, outside the managed marker block
- Fix "Everything else is untouched" wording so it doesn't contradict the exception right above it

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-23 09:06:51 -05:00
Manfred Riem
a5b6ce4173 chore: release 0.14.0, begin 0.14.1.dev0 development (#3677)
* chore: bump version to 0.14.0

* chore: begin 0.14.1.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-23 08:57:55 -05:00
34 changed files with 1396 additions and 150 deletions

View File

@@ -24,7 +24,7 @@ jobs:
python-version: "3.14"
- name: Run ruff check
run: uvx ruff check src tests
run: uvx ruff@0.15.0 check src tests
pytest:
runs-on: ${{ matrix.os }}

View File

@@ -2,6 +2,31 @@
<!-- insert new changelog below this comment -->
## [0.14.1] - 2026-07-23
### Changed
- Update Agent Parity Governance preset to v0.4.0 (#3697)
- fix(bundler): InstallResult.changed counts uninstalled as a change (#3692)
- [preset] Update Cross-Platform Governance preset to v0.2.1 (#3695)
- Update A11Y Governance preset to v0.4.1 (#3693)
- fix(workflows): escape step-graph brackets in `workflow info` so the type shows (#3690)
- fix(workflows): filter parser rejects trailing tokens (fullmatch, not match) (#3689)
- Update iSAQB Architecture Governance preset to v0.2.1 (#3687)
- fix(extensions): parse SKILL.md on the --- delimiter line during removal (#3634)
- fix(cli): guard lazy .hostname ValueError in extension/preset add --from (#3651)
- Update Architecture Governance preset to v0.5.1 (#3686)
- fix(bundler): reject a top-level non-mapping bundle-catalogs.yml in _merge_config (#3659)
- Update Security Governance preset to v0.6.1 (#3685)
- fix(integrations): declare OmpIntegration multi_install_safe (#3650)
- feat(git-extension): add configurable Conventional Commit support (#3390) (#3413)
- fix(extensions): hyphenate command names in the Forge post-install listing (#3669)
- fix(bundler): reject falsy non-mapping requires/provides in CatalogEntry.from_dict (#3667)
- fix(bundler): reject falsy non-list bundles/contributed_components in records (#3666)
- Update Intake Authoring Governance preset to v0.1.1 (#3678)
- docs(extensions): clarify agent-context README and add config examples (#3389)
- chore: release 0.14.0, begin 0.14.1.dev0 development (#3677)
## [0.14.0] - 2026-07-23
### Changed

View File

@@ -7,28 +7,28 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Preset | Purpose | Provides | Requires | URL |
|--------|---------|----------|----------|-----|
| A11Y Governance | Adds accessibility (WCAG 2.2 AA), bilingual DE/EN delivery, CEFR-B2 readability, inclusive-content governance, didactic inline-code-comment review, and audit-ready Spec Kit run 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 parity, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across a project's declared AI-agent instruction surfaces so agent guidance does not drift. | 6 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
| 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) |
| 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, 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) |
| 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) |
| 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) |
| Cross-Platform Governance | Adds Bash + PowerShell parity, Unix man-pages, bilingual comment-based help, Verb-Noun Cmdlet discipline, and audit-ready Spec Kit run evidence for scripting projects managed with Spec Kit | 8 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
| Cross-Platform Governance | Adds Bash/PowerShell and read-only check parity, root-path and native-override review, Unix man pages, bilingual help, Verb-Noun discipline, and audit-ready evidence. | 8 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose 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 | Creates traceable Spec Kit intake files and receipts from ordered text sources while preserving clarification, update, and delivery-authority boundaries. | 7 templates, 2 commands, 2 scripts | — | [spec-kit-preset-intake-authoring-governance](https://github.com/hindermath/spec-kit-preset-intake-authoring-governance) |
| Intake Authoring Governance | Creates traceable Spec Kit intakes from ordered text sources and now truthfully adopts legacy intakes without inventing predecessor receipts. | 7 templates, 2 commands, 2 scripts | — | [spec-kit-preset-intake-authoring-governance](https://github.com/hindermath/spec-kit-preset-intake-authoring-governance) |
| Intake Review Governance | Adds hash-bound review, repair, and status gates for single, series, and campaign intake files before interactive, autonomous, or parallel Spec Kit execution. | 8 templates, 3 commands, 2 scripts | — | [spec-kit-preset-intake-review-governance](https://github.com/hindermath/spec-kit-preset-intake-review-governance) |
| iSAQB Architecture Governance | Adds general iSAQB/CPSA-F and arc42 software-architecture governance, including audit-ready Spec Kit run evidence for architecture goals, views, quality 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) |
| 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) |
| 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 preference, language-specific secure coding profiles, audit-ready Spec-Kit run evidence, ASVS verification, SBOM/AI-SBOM supply-chain transparency, CRA awareness, and regulatory applicability screening for NIS2, CRA, EU AI Act, and DORA | 14 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
| 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) |
| SicarioSpec Core | Baseline secure-by-default Spec Kit governance profile. | 5 templates | — | [sicario-spec](https://github.com/dfirs1car1o/sicario-spec) |
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy | 5 templates, 8 commands | — | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
| Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) |

View File

@@ -275,6 +275,7 @@ The currently declared multi-install safe integrations are:
| `junie` | `.junie/commands` |
| `kilocode` | `.kilocode/workflows` |
| `kiro-cli` | `.kiro/prompts` |
| `omp` | `.omp/commands` |
| `qodercli` | `.qoder/commands` |
| `qwen` | `.qwen/commands` |
| `shai` | `.shai/commands` |

View File

@@ -2,55 +2,55 @@
This bundled extension manages the **coding agent context/instruction file** (e.g. `CLAUDE.md`, `.github/copilot-instructions.md`, `AGENTS.md`, `GEMINI.md`, …) for the active integration.
It owns the lifecycle of the managed section delimited by the configurable start/end markers (defaults: `<!-- SPECKIT START -->` / `<!-- SPECKIT END -->`).
It owns the lifecycle of the managed section delimited by the configurable start/end markers (defaults: `<!-- SPECKIT START -->` / `<!-- SPECKIT END -->`). For `.mdc` files, it also ensures the YAML frontmatter (the metadata block at the top of the file) contains `alwaysApply: true`. Otherwise, everything outside the managed section is untouched.
> NOTE: Spec Kit itself never touches your agent context file. This extension is the only thing that does, and it's opt-in: install it if you want the block kept in sync, skip it if you'd rather manage that file yourself.
## Why an extension?
Not every Spec Kit user wants Spec Kit to write into the coding agent's context file. Keeping this behavior in a dedicated, **opt-in** extension lets users:
- **Choose whether to install it at all** `specify init` does not install it. Add it explicitly when you want Spec Kit to manage the agent context file; if it is absent or disabled, Spec Kit never creates or modifies that file.
- **Customize the markers** by editing `.specify/extensions/agent-context/agent-context-config.yml` the bundled scripts honor the `context_markers` value.
- **Choose whether to install it at all** - `specify init` does **not** install it. Add it explicitly when you want Spec Kit to manage the agent context file; when it is absent, the file is never modified, and when it is disabled, its automatic hooks do not run.
- **Customize the markers** by editing `.specify/extensions/agent-context/agent-context-config.yml` ([agent-context-config.yml](./agent-context-config.yml) in this repo) - the bundled scripts honor the `context_markers` value.
- **Synchronize multiple agent anchors** by setting `context_files` when a project intentionally uses more than one coding agent context file, such as `AGENTS.md` and `CLAUDE.md`.
- **Refresh on demand** by running the `speckit.agent-context.update` command in your agent, or automatically through the hooks declared in `extension.yml` (`after_specify`, `after_plan`). Invoke it using your agent's slash-command separator — `/speckit.agent-context.update` for dot-separator agents or `/speckit-agent-context-update` for hyphen-separator agents (e.g. Forge, Cline).
- **Refresh on demand** by running the `speckit.agent-context.update` command in your agent, or automatically through the hooks declared in [extension.yml](./extension.yml) (`after_specify`, `after_plan`).
## Installation
To install the extension, from the root of an initialized Spec Kit project, run:
```bash
specify extension add agent-context
```
## Disabling
```bash
specify extension disable agent-context
# Re-enable it
specify extension enable agent-context
```
While this extension is disabled (or not installed), nothing in Spec Kit creates, updates, or removes the managed block - the `__CONTEXT_FILE__` placeholder in any template is left as-is, and the extension's own config is never read.
## Commands
The command ID below is canonical. When invoking it as a slash command, use your agent's separator: `/speckit.agent-context.update` for dot-separator agents or `/speckit-agent-context-update` for hyphen-separator agents (e.g. Forge, Cline).
| Command | Description |
|---------|-------------|
| Command | Description |
| ------------------------------ | --------------------------------------------------------------------------------- |
| `speckit.agent-context.update` | Refresh the managed section in the agent context file with the current plan path. |
> NOTE: The command ID above is canonical. Invoke it using the syntax for your integration: `/speckit.agent-context.update` for dot-command integrations; `/speckit-agent-context-update` for hyphen/skills integrations (including Forge and Cline); `$speckit-agent-context-update` for Codex or ZCode in skills mode; or `/skill:speckit-agent-context-update` for Kimi.
## Configuration
All configuration flows through the extension's own config file at
`.specify/extensions/agent-context/agent-context-config.yml`:
```yaml
# Path to the coding agent context file managed by this extension
context_file: CLAUDE.md
# Optional list of coding agent context files to manage together.
# When non-empty, this takes precedence over context_file.
context_files:
- AGENTS.md
- CLAUDE.md
# Delimiters for the managed Spec Kit section
context_markers:
start: "<!-- SPECKIT START -->"
end: "<!-- SPECKIT END -->"
```
- `context_file` — the project-relative path to the coding agent context file. When empty, the bundled update scripts self-seed it by looking up the active integration's key in this extension's own `agent-context-defaults.json` map. The Specify CLI is never consulted.
- `context_files` — optional project-relative paths to multiple coding agent context files. When non-empty, the list takes precedence over `context_file`. Absolute paths, backslash separators, and `..` path segments are rejected.
- `context_markers.start` / `.end` — the delimiters around the managed section. Edit these to use custom markers.
All configuration flows through the extension's own config file at `.specify/extensions/agent-context/agent-context-config.yml` ([agent-context-config.yml](./agent-context-config.yml) in the repo).
## Requirements
The bundled update scripts require **Python 3** with **PyYAML** for YAML/upsert processing (PowerShell can also use `ConvertFrom-Yaml` when available).
PyYAML ships with the `specify` CLI and is normally available via the same `python3` interpreter. If a hook reports *"PyYAML is required … not available in the current Python environment"*, it means the system `python3` differs from the one used to install Spec Kit. To resolve, run:
PyYAML ships with the `specify` CLI and is normally available via the same `python3` interpreter. If a hook reports _"PyYAML is required … not available in the current Python environment"_, it means the system `python3` differs from the one used to install Spec Kit. To resolve, run:
```bash
pip install pyyaml
@@ -58,10 +58,6 @@ pip install pyyaml
/path/to/speckit-python -m pip install pyyaml
```
## Disable
## Issues
```bash
specify extension disable agent-context
```
When disabled (or never installed), Spec Kit performs no agent context file creation, updates, or removal — the extension's bundled scripts are the only code that ever touches the managed section. The Specify CLI carries no agent-context state at all: it never reads this config, never resolves a context file, and the `__CONTEXT_FILE__` placeholder (if present in any template) is left untouched. All context-file knowledge — including the per-agent default mapping in `agent-context-defaults.json` — lives entirely within this extension, so disabling it is a complete opt-out.
For any other issues, please create an issue in the [official GitHub repo](https://github.com/github/spec-kit/issues).

View File

@@ -1,20 +1,24 @@
# Coding Agent Context Extension Configuration
# These values are populated automatically by `specify init` and
# `specify integration use` / `specify integration install`.
# Path (relative to the project root) to the default coding agent context file
# managed by this extension (e.g. CLAUDE.md, AGENTS.md,
# .github/copilot-instructions.md). Set automatically from the active
# integration and regenerated during `specify init` or integration switches.
# WHAT: The single agent context file relative to the project root (the directory containing .specify/). Absolute paths, backslash separators, and `..` path segments are rejected.
# REQUIREMENT: OPTIONAL. Use this if you want to manually specify a single context file. If you leave this entry blank, it will use the default context file for the coding agent you picked when you set up Spec Kit. See `agent-context-defaults.json` for the defaults.
# EXAMPLE: context_file: CLAUDE.md
context_file: ""
# Optional list of project-relative coding agent context files managed by this
# extension. When non-empty, this list takes precedence over `context_file`.
# Use this for projects that intentionally keep multiple agent anchors in sync.
# WHAT: List of agent context files relative to the project root (the directory containing .specify/). If you have both `context_file` and `context_files` filled, then this (`context_files`) takes precedence. Absolute paths, backslash separators, and `..` path segments are rejected.
# REQUIREMENT: OPTIONAL. Use this if your project requires you to keep multiple agent context files in sync.
# EXAMPLE:
# context_files:
# - AGENTS.md
# - CLAUDE.md
context_files: []
# Delimiters for the managed Spec Kit section.
# Edit these to use custom markers.
# WHAT: Markers (delimiters) for the managed Spec Kit section. This extension injects information only between these markers.
# REQUIREMENT: OPTIONAL. Only change if you wish to have a custom marker name.
# EXAMPLE:
# context_markers:
# start: "<!-- AGENT SPEC KIT CONTEXT START -->"
# end: "<!-- AGENT SPEC KIT CONTEXT END -->"
context_markers:
start: "<!-- SPECKIT START -->"
end: "<!-- SPECKIT END -->"

View File

@@ -10,7 +10,7 @@ This extension provides Git operations as an optional, self-contained module. It
- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering and optional templates for branch namespaces
- **Branch validation** to ensure branches follow naming conventions
- **Git remote detection** for GitHub integration (e.g., issue creation)
- **Auto-commit** after core commands (configurable per-command with custom messages)
- **Auto-commit** after core commands (configurable per-command with custom messages, or Conventional Commit messages generated by the agent)
## Commands
@@ -66,6 +66,11 @@ branch_prefix: ""
# Custom commit message for git init
init_commit_message: "[Spec Kit] Initial commit"
# Commit message style for auto-commit hooks: "fixed" (default) uses the
# messages below; "conventional" asks the agent to generate a Conventional
# Commit message (e.g. "feat: add OAuth spec") from the diff instead.
commit_style: fixed
# Auto-commit per command (all disabled by default)
# Example: enable auto-commit after specify
auto_commit:

View File

@@ -14,23 +14,37 @@ This command is invoked as a hook after (or before) core commands. It:
2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section
3. Looks up the specific event key to see if auto-commit is enabled
4. Falls back to `auto_commit.default` if no event-specific key exists
5. Uses the per-command `message` if configured, otherwise a default message
5. Determines the commit message based on `commit_style` (see below)
6. If enabled and there are uncommitted changes, runs `git add .` + `git commit`
## Commit Message Styles
Controlled by the `commit_style` key in `.specify/extensions/git/git-config.yml`:
- **`fixed`** (default): use the per-command `message` if configured, otherwise a generic `[Spec Kit] Auto-commit <phase> <command>` message.
- **`conventional`**: inspect the actual changes (`git diff` / `git status`) since the last commit and generate a single-line [Conventional Commit](https://www.conventionalcommits.org/) message (`type(scope): subject`, e.g. `feat: add OAuth specification` or `docs: update implementation plan`) that accurately summarizes the change. Write this message to a temporary file and pass the file's path to the script (see Execution below). The configured `message` values are ignored in this mode.
## Execution
Determine the event name from the hook that triggered this command, then run the script:
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name>`
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name>`
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name> [--message-file <path>]`
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name> [-MessageFile <path>]`
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`).
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`). Only pass a generated message when `commit_style: conventional` is configured — first check `.specify/extensions/git/git-config.yml` for the value of `commit_style`:
- If `conventional`: inspect the diff and generate a Conventional Commit message. **Do not interpolate the generated message directly into a shell command string** — its content is derived from repository changes and may contain characters (quotes, `$(...)`, backticks) that a shell would execute or that would break command quoting. Instead, write the message to a temporary file using your file-editing tool (not a shell `echo`/`printf`), then pass that file's path via `--message-file <path>` (Bash) or `-MessageFile <path>` (PowerShell).
- If `fixed` or absent: run the script with just `<event_name>`; it uses the configured/static message.
## Configuration
In `.specify/extensions/git/git-config.yml`:
```yaml
# "fixed" (default) uses the messages below; "conventional" asks the agent
# to generate a Conventional Commit message from the diff instead.
commit_style: fixed
auto_commit:
default: false # Global toggle — set true to enable for all commands
after_specify:
@@ -46,3 +60,4 @@ auto_commit:
- If Git is not available or the current directory is not a repository: skips with a warning
- If no config file exists: skips (disabled by default)
- If no changes to commit: skips with a message
- If `commit_style: conventional` is set and no generated message was supplied: fails with a clear error instead of silently falling back to the fixed message format

View File

@@ -17,6 +17,13 @@ branch_prefix: ""
# Commit message used by `git commit` during repository initialization
init_commit_message: "[Spec Kit] Initial commit"
# Commit message style used by auto-commit hooks (speckit.git.commit):
# "fixed" - default; use the configured/static messages below.
# "conventional" - ask the agent to inspect the diff and generate a
# Conventional Commit message (e.g. "feat: add OAuth spec")
# instead of using the messages configured below.
commit_style: fixed
# Auto-commit before/after core commands.
# Set "default" to enable for all commands, then override per-command.
# Each key can be true/false. Message is customizable per-command.

View File

@@ -17,6 +17,13 @@ branch_prefix: ""
# Commit message used by `git commit` during repository initialization
init_commit_message: "[Spec Kit] Initial commit"
# Commit message style used by auto-commit hooks (speckit.git.commit):
# "fixed" - default; use the configured/static messages below.
# "conventional" - ask the agent to inspect the diff and generate a
# Conventional Commit message (e.g. "feat: add OAuth spec")
# instead of using the messages configured below.
commit_style: fixed
# Auto-commit before/after core commands.
# Set "default" to enable for all commands, then override per-command.
# Each key can be true/false. Message is customizable per-command.

View File

@@ -3,16 +3,57 @@
# Automatically commit changes after a Spec Kit command completes.
# Checks per-command config keys in git-config.yml before committing.
#
# Usage: auto-commit.sh <event_name>
# Usage: auto-commit.sh <event_name> [generated_message]
# auto-commit.sh <event_name> --message-file <path>
# e.g.: auto-commit.sh after_specify
# e.g.: auto-commit.sh after_specify --message-file /tmp/commit-msg.txt (commit_style: conventional)
#
# --message-file is the preferred way to supply an agent-generated commit
# message: it reads the message from a file instead of a shell argument,
# so message content (which may contain quotes, `$(...)`, backticks, etc.)
# is never interpolated into a shell command line.
set -e
EVENT_NAME="${1:-}"
if [ -z "$EVENT_NAME" ]; then
echo "Usage: $0 <event_name>" >&2
echo "Usage: $0 <event_name> [generated_message | --message-file <path>]" >&2
exit 1
fi
shift || true
# Optional second argument: an agent-generated commit message (used when
# commit_style: conventional is configured). Prefer --message-file over
# passing the message directly as a shell argument.
GENERATED_MESSAGE=""
while [ $# -gt 0 ]; do
case "$1" in
--message-file)
_message_file="${2:-}"
if [ -z "$_message_file" ]; then
echo "[specify] Error: --message-file requires a path argument" >&2
exit 1
fi
if [ ! -f "$_message_file" ]; then
echo "[specify] Error: message file '$_message_file' not found" >&2
exit 1
fi
GENERATED_MESSAGE="$(cat "$_message_file")"
# The message file is a transport-only artifact: its content is
# now captured above, so remove it immediately. Otherwise, if it
# was written inside the worktree, it would be picked up as an
# untracked change by both the "any changes?" check below and by
# `git add .`, polluting the commit or defeating the no-changes
# short-circuit even when nothing else changed.
rm -f "$_message_file"
shift 2
;;
*)
GENERATED_MESSAGE="$1"
shift
;;
esac
done
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -46,8 +87,22 @@ fi
_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml"
_enabled=false
_commit_msg=""
_commit_style="fixed"
if [ -f "$_config_file" ]; then
# Top-level scalar key: commit_style (fixed | conventional)
_style_val=$(grep -m1 '^commit_style:' "$_config_file" 2>/dev/null | sed 's/^commit_style:[[:space:]]*//' | sed 's/[[:space:]]\{1,\}#.*$//' | sed 's/[[:space:]]*$//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//' | tr '[:upper:]' '[:lower:]')
if [ -n "$_style_val" ]; then
case "$_style_val" in
fixed|conventional)
_commit_style="$_style_val"
;;
*)
echo "[specify] Warning: unknown commit_style '$_style_val' in git-config.yml (expected 'fixed' or 'conventional'); defaulting to 'fixed'" >&2
;;
esac
fi
# Parse the auto_commit section for this event.
# Look for auto_commit.<event_name>.enabled and .message
# Also check auto_commit.default as fallback.
@@ -128,6 +183,17 @@ if git diff --quiet HEAD 2>/dev/null && git diff --cached --quiet 2>/dev/null &&
exit 0
fi
# In conventional mode, the commit message must be supplied by the agent
# (via the generated_message argument); never fall back to the fixed message.
if [ "$_commit_style" = "conventional" ]; then
if [ -n "$GENERATED_MESSAGE" ]; then
_commit_msg="$GENERATED_MESSAGE"
else
echo "[specify] Error: commit_style is 'conventional' but no generated commit message was supplied; aborting auto-commit (pass --message-file <path>, or a raw message as arg 2, or set commit_style: fixed)" >&2
exit 1
fi
fi
# Derive a human-readable command name from the event
# e.g., after_specify -> specify, before_plan -> plan
_command_name=$(echo "$EVENT_NAME" | sed 's/^after_//' | sed 's/^before_//')

View File

@@ -3,14 +3,47 @@
# Automatically commit changes after a Spec Kit command completes.
# Checks per-command config keys in git-config.yml before committing.
#
# Usage: auto-commit.ps1 <event_name>
# Usage: auto-commit.ps1 <event_name> [generated_message]
# auto-commit.ps1 <event_name> -MessageFile <path>
# e.g.: auto-commit.ps1 after_specify
# e.g.: auto-commit.ps1 after_specify -MessageFile C:\temp\commit-msg.txt (commit_style: conventional)
#
# -MessageFile is the preferred way to supply an agent-generated commit
# message: it reads the message from a file instead of a shell argument,
# so message content (which may contain quotes, $(...), backticks, etc.)
# is never interpolated into a shell command line.
param(
[Parameter(Position = 0, Mandatory = $true)]
[string]$EventName
[string]$EventName,
# Optional agent-generated commit message (used when commit_style: conventional is configured).
# Prefer -MessageFile over passing the message directly as a shell argument.
[Parameter(Position = 1, Mandatory = $false)]
[string]$GeneratedMessage = "",
[Parameter(Mandatory = $false)]
[string]$MessageFile = ""
)
$ErrorActionPreference = 'Stop'
if ($MessageFile) {
if (-not (Test-Path $MessageFile -PathType Leaf)) {
Write-Warning "[specify] Error: message file '$MessageFile' not found"
exit 1
}
$GeneratedMessage = (Get-Content -Path $MessageFile -Raw)
if ($null -ne $GeneratedMessage) {
$GeneratedMessage = $GeneratedMessage.TrimEnd("`r", "`n")
}
# The message file is a transport-only artifact: its content is now
# captured above, so remove it immediately. Otherwise, if it was written
# inside the worktree, it would be picked up as an untracked change by
# both the "any changes?" check below and by `git add .`, polluting the
# commit or defeating the no-changes short-circuit even when nothing
# else changed.
Remove-Item -Path $MessageFile -Force -ErrorAction SilentlyContinue
}
function Find-ProjectRoot {
param([string]$StartDir)
$current = Resolve-Path $StartDir
@@ -55,8 +88,25 @@ if (-not $isRepo) {
$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
$enabled = $false
$commitMsg = ""
$commitStyle = "fixed"
if (Test-Path $configFile) {
# Top-level scalar key: commit_style (fixed | conventional)
foreach ($line in Get-Content $configFile) {
if ($line -match '^commit_style:\s*(.+)$') {
$styleVal = (($matches[1] -replace '\s+#.*$', '').Trim()) -replace '^["'']' -replace '["'']$'
if ($styleVal) {
$styleVal = $styleVal.ToLower()
if ($styleVal -eq 'fixed' -or $styleVal -eq 'conventional') {
$commitStyle = $styleVal
} else {
Write-Warning "[specify] Warning: unknown commit_style '$styleVal' in git-config.yml (expected 'fixed' or 'conventional'); defaulting to 'fixed'"
}
}
break
}
}
# Parse YAML to find auto_commit section
$inAutoCommit = $false
$inEvent = $false
@@ -140,6 +190,17 @@ if ($d1 -eq 0 -and $d2 -eq 0 -and -not $untracked) {
exit 0
}
# In conventional mode, the commit message must be supplied by the agent
# (via the GeneratedMessage argument); never fall back to the fixed message.
if ($commitStyle -eq 'conventional') {
if ($GeneratedMessage) {
$commitMsg = $GeneratedMessage
} else {
Write-Warning "[specify] Error: commit_style is 'conventional' but no generated commit message was supplied; aborting auto-commit (pass -MessageFile <path>, or a raw message as arg 2, or set commit_style: fixed)"
exit 1
}
}
# Derive a human-readable command name from the event
$commandName = $EventName -replace '^after_', '' -replace '^before_', ''
$phase = if ($EventName -match '^before_') { 'before' } else { 'after' }

View File

@@ -1,18 +1,19 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-22T00:00:00Z",
"updated_at": "2026-07-23T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
"presets": {
"a11y-governance": {
"name": "A11Y Governance",
"id": "a11y-governance",
"version": "0.4.0",
"description": "Adds accessibility (WCAG 2.2 AA), bilingual DE/EN delivery, CEFR-B2 readability, inclusive-content governance, didactic inline-code-comment review, and audit-ready Spec Kit run evidence.",
"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.",
"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.0.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.4.1.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/main/README.md",
"documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/v0.4.1/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.0"
@@ -33,18 +34,18 @@
"didactic-comments"
],
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-06-14T00:00:00Z"
"updated_at": "2026-07-23T00:00:00Z"
},
"agent-parity-governance": {
"name": "Agent Parity Governance",
"id": "agent-parity-governance",
"version": "0.3.0",
"description": "Adds shared-guidance parity, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across a project's declared AI-agent instruction surfaces so agent guidance does not drift.",
"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.",
"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.3.0.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.4.0.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/blob/main/README.md",
"documentation": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/blob/v0.4.0/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.0"
@@ -63,7 +64,7 @@
"multi-agent"
],
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-06-14T00:00:00Z"
"updated_at": "2026-07-23T00:00:00Z"
},
"aide-in-place": {
"name": "AIDE In-Place Migration",
@@ -96,13 +97,13 @@
"architecture-governance": {
"name": "Architecture Governance",
"id": "architecture-governance",
"version": "0.5.0",
"description": "Adds secure software architecture, 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.",
"version": "0.5.1",
"description": "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.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-architecture-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-architecture-governance/archive/refs/tags/v0.5.0.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-architecture-governance/archive/refs/tags/v0.5.1.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-architecture-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-architecture-governance/blob/main/README.md",
"documentation": "https://github.com/hindermath/spec-kit-preset-architecture-governance/blob/v0.5.1/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.0"
@@ -129,7 +130,7 @@
"assurance"
],
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-06-14T00:00:00Z"
"updated_at": "2026-07-23T00:00:00Z"
},
"autonomous-run-governance": {
"name": "Autonomous Run Governance",
@@ -243,13 +244,13 @@
"cross-platform-governance": {
"name": "Cross-Platform Governance",
"id": "cross-platform-governance",
"version": "0.2.0",
"description": "Adds Bash + PowerShell parity, Unix man-pages, bilingual comment-based help, Verb-Noun Cmdlet discipline, and audit-ready Spec Kit run evidence for scripting projects managed with Spec Kit.",
"version": "0.2.1",
"description": "Adds Bash/PowerShell and read-only check parity, root-path and native-override review, Unix man pages, bilingual help, Verb-Noun discipline, and audit-ready evidence.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/archive/refs/tags/v0.2.0.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/archive/refs/tags/v0.2.1.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/blob/main/README.md",
"documentation": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/blob/v0.2.1/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.0"
@@ -271,7 +272,7 @@
"linux"
],
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-06-14T00:00:00Z"
"updated_at": "2026-07-23T00:00:00Z"
},
"explicit-task-dependencies": {
"name": "Explicit Task Dependencies",
@@ -367,13 +368,13 @@
"intake-authoring-governance": {
"name": "Intake Authoring Governance",
"id": "intake-authoring-governance",
"version": "0.1.0",
"description": "Creates traceable Spec Kit intake files and receipts from ordered text sources while preserving clarification, update, and delivery-authority boundaries.",
"version": "0.1.1",
"description": "Creates traceable Spec Kit intakes from ordered text sources and now truthfully adopts legacy intakes without inventing predecessor receipts.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/archive/refs/tags/v0.1.0.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/archive/refs/tags/v0.1.1.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/blob/v0.1.0/README.md",
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/blob/v0.1.1/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.3"
@@ -388,10 +389,10 @@
"authoring",
"governance",
"traceability",
"clarification"
"legacy-adoption"
],
"created_at": "2026-07-22T00:00:00Z",
"updated_at": "2026-07-22T00:00:00Z"
"updated_at": "2026-07-23T00:00:00Z"
},
"intake-review-governance": {
"name": "Intake Review Governance",
@@ -425,13 +426,13 @@
"isaqb-architecture-governance": {
"name": "iSAQB Architecture Governance",
"id": "isaqb-architecture-governance",
"version": "0.2.0",
"description": "Adds general iSAQB/CPSA-F and arc42 software-architecture governance, including audit-ready Spec Kit run evidence for architecture goals, views, quality scenarios, ADRs, risks, and technical debt.",
"version": "0.2.1",
"description": "Adds iSAQB/CPSA-F and arc42 architecture governance with audit-ready evidence for goals, views, resumability, partial-failure scenarios, ADRs, risks, and technical debt.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/archive/refs/tags/v0.2.0.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/archive/refs/tags/v0.2.1.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/blob/main/README.md",
"documentation": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/blob/v0.2.1/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.0"
@@ -452,7 +453,7 @@
"technical-debt"
],
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-06-14T00:00:00Z"
"updated_at": "2026-07-23T00:00:00Z"
},
"jira": {
"name": "Jira Issue Tracking",
@@ -635,13 +636,13 @@
"security-governance": {
"name": "Security Governance",
"id": "security-governance",
"version": "0.6.0",
"description": "Adds memory-safe-language preference, language-specific secure coding profiles, audit-ready Spec-Kit run evidence, ASVS verification, SBOM/AI-SBOM supply-chain transparency, CRA awareness, and regulatory applicability screening for NIS2, CRA, EU AI Act, and DORA to Spec Kit.",
"version": "0.6.1",
"description": "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.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-security-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.6.0.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.6.1.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-security-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-security-governance/blob/main/README.md",
"documentation": "https://github.com/hindermath/spec-kit-preset-security-governance/blob/v0.6.1/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.0"
@@ -680,7 +681,7 @@
"regulatory"
],
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-06-14T00:00:00Z"
"updated_at": "2026-07-23T00:00:00Z"
},
"sicario-core": {
"name": "SicarioSpec Core",

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.14.0"
version = "0.14.1"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
readme = "README.md"
requires-python = ">=3.11"

View File

@@ -40,9 +40,12 @@ def _read(project_root: Path) -> list[dict]:
path = ensure_within(project_root, _config_path(project_root))
if not path.exists():
return []
# ``load_yaml`` returns ``{}`` only for an empty document and the raw parse
# otherwise, so a non-mapping top level — a falsy ``[]``/``false``/``0``/``''``
# or an explicit null (``load_yaml`` -> ``None``) — is caught by the isinstance
# guard below and raised like a truthy one, staying consistent with the other
# reader of this file (models/catalog._merge_config).
data = load_yaml(path)
if data is None:
return []
if not isinstance(data, dict):
raise BundlerError(
f"Malformed catalog config at {path}: expected a mapping at the top "

View File

@@ -39,17 +39,35 @@ def ensure_within(root: Path, candidate: Path) -> Path:
def load_yaml(path: Path) -> Any:
"""Parse a YAML file, returning ``{}`` for an empty document."""
"""Parse a YAML file, returning ``{}`` only for an *empty* document.
A non-empty document is returned exactly as parsed — including a
non-mapping such as ``[]``, ``false``, ``0``, ``''``, or an explicit null
(``null``/``~``) — so callers can validate the top-level shape (e.g. reject
a non-mapping config) instead of having it silently coerced to an empty
mapping.
``yaml.safe_load`` returns ``None`` for *both* an empty document and an
explicit null scalar, so ``yaml.compose`` (which yields no node only for a
truly empty document) is used to tell them apart: an empty document becomes
``{}`` while an explicit ``null``/``~`` is returned as ``None`` for the
caller to reject.
"""
path = Path(path)
if not path.exists():
raise BundlerError(f"File not found: {path}")
try:
with path.open("r", encoding="utf-8") as handle:
return yaml.safe_load(handle) or {}
except yaml.YAMLError as exc:
raise BundlerError(f"Invalid YAML in {path}: {exc}") from exc
text = path.read_text(encoding="utf-8")
except OSError as exc:
raise BundlerError(f"Could not read {path}: {exc}") from exc
try:
has_node = yaml.compose(text) is not None
data = yaml.safe_load(text)
except yaml.YAMLError as exc:
raise BundlerError(f"Invalid YAML in {path}: {exc}") from exc
if data is None and not has_node:
return {}
return data
def dump_yaml(path: Path, data: Any, *, within: Path | None = None) -> Path:

View File

@@ -152,14 +152,21 @@ class CatalogEntry:
if not isinstance(data, dict):
raise BundlerError("Each catalog entry must be a mapping.")
entry_id = str(data.get("id", "")).strip()
requires = data.get("requires") or {}
if not isinstance(requires, dict):
# `or {}` would coerce a FALSY non-mapping (0, '', False, []) to {} before
# the isinstance guard, silently accepting a corrupt catalog entry; only
# an absent/None value means "not present".
requires = data.get("requires")
if requires is None:
requires = {}
elif not isinstance(requires, dict):
raise BundlerError(
f"Catalog entry '{entry_id or '<unknown>'}': 'requires' must be a "
"mapping when present."
)
provides_raw = data.get("provides") or {}
if not isinstance(provides_raw, dict):
provides_raw = data.get("provides")
if provides_raw is None:
provides_raw = {}
elif not isinstance(provides_raw, dict):
raise BundlerError(
f"Catalog entry '{entry_id or '<unknown>'}': 'provides' must be a "
"mapping when present."
@@ -249,8 +256,18 @@ def load_source_stack(project_root: Path, user_config_dir: Path | None = None) -
def _merge_config(by_id: dict[str, CatalogSource], config_path: Path, scope: Scope) -> None:
if not config_path.exists():
return
# ``load_yaml`` returns ``{}`` only for an empty document and the raw parse
# otherwise, so a non-mapping top level (a YAML list or scalar, including
# the falsy ``[]``/``false``/``0``/``''``) is caught here and raised —
# matching the sibling reader commands_impl/catalog_config._read. #3623
# aligned the inner non-list ``catalogs`` value between the two readers.
data = load_yaml(config_path)
catalogs = data.get("catalogs") if isinstance(data, dict) else None
if not isinstance(data, dict):
raise BundlerError(
f"Malformed catalog config at {config_path}: expected a mapping at "
f"the top level, got {type(data).__name__}."
)
catalogs = data.get("catalogs")
if catalogs is None:
return
if not isinstance(catalogs, list):

View File

@@ -55,8 +55,13 @@ class InstalledBundleRecord:
def from_dict(cls, data: Any) -> "InstalledBundleRecord":
if not isinstance(data, dict):
raise BundlerError("Each installed-bundle record must be a mapping.")
components_raw = data.get("contributed_components") or []
if not isinstance(components_raw, list):
components_raw = data.get("contributed_components")
if components_raw is None:
components_raw = []
elif not isinstance(components_raw, list):
# `or []` would coerce a FALSY non-list (0, '', False, {}) to []
# before this guard, silently accepting a corrupt record; only an
# absent/None value means "no components".
raise BundlerError(
"Corrupt record: 'contributed_components' must be a list."
)
@@ -121,8 +126,13 @@ def load_records(project_root: Path) -> list[InstalledBundleRecord]:
if not isinstance(data, dict):
raise BundlerError(f"Corrupt records file: {path}")
_check_schema_version(data.get("schema_version"), path=path, required=True)
bundles = data.get("bundles") or []
if not isinstance(bundles, list):
bundles = data.get("bundles")
if bundles is None:
bundles = []
elif not isinstance(bundles, list):
# `or []` would coerce a FALSY non-list (0, '', False, {}) to [] before
# this guard, silently treating a corrupt file as "no bundles"; only an
# absent/None value means empty.
raise BundlerError(
f"Corrupt records file: {path}'bundles' must be a list."
)

View File

@@ -50,7 +50,10 @@ class InstallResult:
@property
def changed(self) -> bool:
return bool(self.installed or self.refreshed)
# `uninstalled` is a mutating outcome too: a `bundle update` whose new
# manifest drops components (removing them via the refresh path) with no
# new install/refresh must still report changed=True, not a no-op.
return bool(self.installed or self.refreshed or self.uninstalled)
def install_bundle(

View File

@@ -1330,19 +1330,20 @@ class ExtensionManager:
if not skill_md.is_file():
continue
try:
import yaml as _yaml
from ..agents import CommandRegistrar as _Registrar
raw = skill_md.read_text(encoding="utf-8")
source = ""
if raw.startswith("---"):
parts = raw.split("---", 2)
if len(parts) >= 3:
fm = _yaml.safe_load(parts[1]) or {}
source = (
fm.get("metadata", {}).get("source", "")
if isinstance(fm, dict)
else ""
)
# Parse on the ``---`` delimiter *line*, not any ``---``
# substring: a description containing ``---`` would trip a
# raw ``split("---", 2)`` and hide metadata.source, so this
# extension's own skill would look unrelated and be left
# orphaned. Mirrors the #3590 parse_frontmatter fix.
fm, _ = _Registrar.parse_frontmatter(raw)
source = (
fm.get("metadata", {}).get("source", "")
if isinstance(fm, dict)
else ""
)
if source != f"extension:{extension_id}":
continue
except (OSError, UnicodeDecodeError, Exception):
@@ -1386,19 +1387,20 @@ class ExtensionManager:
if not skill_md.is_file():
continue
try:
import yaml as _yaml
from ..agents import CommandRegistrar as _Registrar
raw = skill_md.read_text(encoding="utf-8")
source = ""
if raw.startswith("---"):
parts = raw.split("---", 2)
if len(parts) >= 3:
fm = _yaml.safe_load(parts[1]) or {}
source = (
fm.get("metadata", {}).get("source", "")
if isinstance(fm, dict)
else ""
)
# Parse on the ``---`` delimiter *line*, not any ``---``
# substring: a description containing ``---`` would trip
# a raw ``split("---", 2)`` and hide metadata.source, so
# this extension's own skill would look unrelated and be
# left orphaned. Mirrors the #3590 parse_frontmatter fix.
fm, _ = _Registrar.parse_frontmatter(raw)
source = (
fm.get("metadata", {}).get("source", "")
if isinstance(fm, dict)
else ""
)
# Only remove skills explicitly created by this extension
if source != f"extension:{extension_id}":
continue

View File

@@ -428,10 +428,17 @@ def extension_add(
try:
parsed = urlparse(from_url)
# Read .hostname inside the try: parsing a malformed authority -- or
# accessing .hostname on one, e.g. an invalid bracketed IPv6 host like
# "https://[not-an-ip]/x.zip" -- can raise ValueError. Keeping both the
# parse and the .hostname read inside the guard surfaces a clean
# "Invalid URL" message instead of leaking a raw traceback past the
# CLI. Reuse the value below.
hostname = parsed.hostname
except ValueError:
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}")
raise typer.Exit(1)
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
console.print("[red]Error:[/red] URL must use HTTPS for security.")
@@ -623,16 +630,22 @@ def extension_add(
for warning in manifest.warnings:
console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {_escape_markup(str(warning))}")
is_cline = load_init_options(project_root).get("ai") == "cline"
selected_ai = load_init_options(project_root).get("ai")
is_cline = selected_ai == "cline"
is_forge = selected_ai == "forge"
if is_cline:
from specify_cli.integrations.cline import format_cline_command_name
if is_forge:
from specify_cli.integrations.forge import format_forge_command_name
console.print("\n[bold cyan]Provided commands:[/bold cyan]")
for cmd in manifest.commands:
cmd_name = cmd['name']
if is_cline:
cmd_name = format_cline_command_name(cmd_name)
elif is_forge:
cmd_name = format_forge_command_name(cmd_name)
console.print(f"{_escape_markup(str(cmd_name))} - {_escape_markup(str(cmd.get('description', '')))}")
# Report agent skills registration

View File

@@ -20,6 +20,7 @@ class OmpIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
multi_install_safe = True
def build_exec_args(
self,

View File

@@ -2376,7 +2376,15 @@ def workflow_info(
console.print(f"\n [bold]Steps ({len(definition.steps)}):[/bold]")
for step in definition.steps:
stype = step.get("type", "command")
console.print(f"{step.get('id', '?')} [{stype}]")
# Escape the literal bracket (\[) so Rich renders `[<type>]`
# instead of parsing it as a style tag named after the step
# type (which it silently swallows); escape id/type too, as
# the sibling workflow_list does. Mirrors the `\[disabled]`
# precedent above.
console.print(
f"{_escape_markup(str(step.get('id', '?')))} "
f"\\[{_escape_markup(str(stype))}]"
)
return
# Try catalog

View File

@@ -392,8 +392,14 @@ def _apply_filter(value: Any, filter_expr: str, namespace: dict[str, Any]) -> An
)
return _filter_from_json(value)
# Parse filter name and argument
filter_match = re.match(r"(\w+)\((.+)\)", filter_expr)
# Parse filter name and argument. Use fullmatch (not match) so trailing
# tokens after the closing paren — e.g. a comparison/boolean operator that
# binds looser than the pipe, as in ``count | default(0) > 5`` — are not
# silently discarded but fall through to the "unsupported form" ValueError
# below, mirroring the strict trailing-token handling of the from_json
# branch above. The greedy ``.+`` still handles literal ``)`` and ``|``
# inside quoted args.
filter_match = re.fullmatch(r"(\w+)\((.+)\)", filter_expr)
if filter_match:
fname = filter_match.group(1)
farg = _evaluate_simple_expression(filter_match.group(2).strip(), namespace)

View File

@@ -68,10 +68,44 @@ def test_falsy_non_list_catalogs_still_raises(tmp_path: Path, value: str):
load_source_stack(tmp_path)
@pytest.mark.parametrize("body", ["catalogs:\n", "catalogs: []\n"])
@pytest.mark.parametrize(
"body",
[
"- a\n- b\n", # truthy list
"42\n", # truthy scalar
"[]\n", # falsy list
"false\n", # falsy bool
"0\n", # falsy int
"''\n", # falsy empty string
"null\n", # explicit null scalar (safe_load -> None, but a real node)
"~\n", # explicit null scalar (alt spelling)
],
)
def test_toplevel_non_mapping_raises(tmp_path: Path, body: str):
"""A top-level non-mapping bundle-catalogs.yml (list/scalar/null) must raise,
matching the sibling reader (catalog_config._read) — not silently fall back
to the built-in default stack. This includes FALSY non-mappings ([], false,
0, '') and an explicit null (null/~); the shared load_yaml would coerce those
to {} and hide them, so it distinguishes them from a truly empty document."""
make_project(tmp_path)
(tmp_path / ".specify" / "bundle-catalogs.yml").write_text(body, encoding="utf-8")
with pytest.raises(BundlerError, match="expected a mapping at the top level"):
load_source_stack(tmp_path)
@pytest.mark.parametrize(
"body",
[
"catalogs:\n", # present key, null value
"catalogs: []\n", # present key, empty list
"", # truly empty document
"# only a comment\n", # comment-only == empty document
],
)
def test_absent_or_empty_catalogs_is_noop(tmp_path: Path, body: str):
"""An absent (``None``) or empty-list ``catalogs:`` is valid: it contributes
no project sources and falls back to the built-in default stack."""
"""An empty document, comment-only file, or absent/empty-list ``catalogs:``
is valid: it contributes no project sources and falls back to the built-in
default stack (must not be confused with an explicit top-level null)."""
make_project(tmp_path)
(tmp_path / ".specify" / "bundle-catalogs.yml").write_text(body, encoding="utf-8")
# Does not raise; still yields the built-in defaults.
@@ -207,3 +241,17 @@ def test_catalog_entry_rejects_non_mapping_provides():
data["provides"] = "extensions"
with pytest.raises(BundlerError, match="'provides' must be a mapping"):
CatalogEntry.from_dict(data)
@pytest.mark.parametrize("field", ["requires", "provides"])
@pytest.mark.parametrize("bad", [[], "", 0, False])
def test_catalog_entry_rejects_falsy_non_mapping(field, bad):
# `or {}` coerced a FALSY non-mapping ([], '', 0, False) to {} before the
# isinstance guard, silently accepting a corrupt entry; only absent/None
# means "not present". Mirrors the manifest requires/provides guard.
from specify_cli.bundler.models.catalog import CatalogEntry
data = catalog_entry_dict("demo")
data[field] = bad
with pytest.raises(BundlerError, match=f"'{field}' must be a mapping"):
CatalogEntry.from_dict(data)

View File

@@ -1167,6 +1167,295 @@ class TestAutoCommitBash:
assert "\u2713" not in result.stderr, "Must not use Unicode checkmark"
@requires_bash
class TestAutoCommitBashCommitStyle:
"""Tests for the `commit_style: conventional` option (issue #3390)."""
def test_fixed_is_default_when_commit_style_absent(self, tmp_path: Path):
"""Omitting commit_style preserves the fixed/static message behavior."""
project = _setup_project(tmp_path)
_write_config(project, (
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
' message: "[Spec Kit] Add specification"\n'
))
(project / "new-file.txt").write_text("content")
result = _run_bash("auto-commit.sh", project, "after_specify")
assert result.returncode == 0
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "[Spec Kit] Add specification" in log.stdout
def test_explicit_fixed_style_uses_configured_message(self, tmp_path: Path):
"""commit_style: fixed (explicit) still uses the configured static message,
not just the absent-key default."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: fixed\n"
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
' message: "[Spec Kit] Add specification"\n'
))
(project / "new-file.txt").write_text("content")
result = _run_bash(
"auto-commit.sh", project, "after_specify", "feat: this should be ignored"
)
assert result.returncode == 0
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "[Spec Kit] Add specification" in log.stdout
assert "this should be ignored" not in log.stdout
def test_conventional_message_file_used(self, tmp_path: Path):
"""--message-file reads the generated message from a file instead of argv,
avoiding shell interpolation of agent-controlled content."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventional\n"
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
' message: "[Spec Kit] Add specification"\n'
))
(project / "new-file.txt").write_text("content")
# Write the message file inside the worktree (as an agent invoking
# this from a working directory tool naturally would) to exercise
# the exclusion-from-staging behavior below.
msg_file = project / "commit-msg.txt"
msg_file.write_text("feat: add $(dangerous) `injection` test\n")
result = _run_bash(
"auto-commit.sh", project, "after_specify", "--message-file", str(msg_file)
)
assert result.returncode == 0
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "feat: add $(dangerous) `injection` test" in log.stdout
def test_message_file_not_staged_or_left_behind(self, tmp_path: Path):
"""--message-file written inside the worktree must never be staged or
committed itself, and must be removed once its content is consumed."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventional\n"
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
))
(project / "new-file.txt").write_text("content")
msg_file = project / "commit-msg.txt"
msg_file.write_text("feat: real change\n")
result = _run_bash(
"auto-commit.sh", project, "after_specify", "--message-file", str(msg_file)
)
assert result.returncode == 0
assert not msg_file.exists()
show = subprocess.run(
["git", "show", "--stat", "--oneline", "HEAD"],
cwd=project, capture_output=True, text=True,
)
assert "new-file.txt" in show.stdout
assert "commit-msg.txt" not in show.stdout
def test_message_file_alone_does_not_defeat_no_changes_shortcircuit(self, tmp_path: Path):
"""If the message file is the only 'change' in the worktree (no real
edits), auto-commit must still report no changes rather than
committing the transport file by itself."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventional\n"
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
))
# Baseline-commit the scaffolding (and config) so the tree is
# genuinely clean before introducing the message file — otherwise
# the untracked scaffold files would mask whether the message file
# alone is enough to (incorrectly) trigger a commit.
subprocess.run(["git", "add", "-A"], cwd=project, check=True, capture_output=True)
subprocess.run(
["git", "commit", "-q", "-m", "baseline"],
cwd=project, check=True, capture_output=True, env={**os.environ, **_GIT_ENV},
)
msg_file = project / "commit-msg.txt"
msg_file.write_text("feat: no real changes\n")
result = _run_bash(
"auto-commit.sh", project, "after_specify", "--message-file", str(msg_file)
)
assert result.returncode == 0
assert "No changes to commit" in result.stderr
assert not msg_file.exists()
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "baseline" in log.stdout
def test_message_file_missing_fails(self, tmp_path: Path):
"""--message-file pointing at a nonexistent file fails clearly."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventional\n"
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
))
(project / "new-file.txt").write_text("content")
missing = tmp_path / "does-not-exist.txt"
result = _run_bash(
"auto-commit.sh", project, "after_specify", "--message-file", str(missing)
)
assert result.returncode != 0
assert "not found" in result.stderr.lower()
def test_conventional_uses_generated_message(self, tmp_path: Path):
"""commit_style: conventional uses the generated_message argument as the commit message."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventional\n"
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
' message: "[Spec Kit] Add specification"\n'
))
(project / "new-file.txt").write_text("content")
result = _run_bash(
"auto-commit.sh", project, "after_specify", "feat: add OAuth specification"
)
assert result.returncode == 0
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "feat: add OAuth specification" in log.stdout
assert "[Spec Kit] Add specification" not in log.stdout
def test_conventional_without_generated_message_fails(self, tmp_path: Path):
"""commit_style: conventional fails clearly instead of falling back to the fixed message."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventional\n"
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
' message: "[Spec Kit] Add specification"\n'
))
(project / "new-file.txt").write_text("content")
result = _run_bash("auto-commit.sh", project, "after_specify")
assert result.returncode != 0
assert "conventional" in result.stderr.lower()
# No commit should have been made, and the fixed message must not be used.
log = subprocess.run(
["git", "log", "--oneline"],
cwd=project, capture_output=True, text=True,
)
assert "[Spec Kit] Add specification" not in log.stdout
def test_conventional_skips_cleanly_with_no_changes(self, tmp_path: Path):
"""No pending changes short-circuits before the missing-message failure."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventional\n"
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
))
subprocess.run(["git", "add", "."], cwd=project, check=True)
subprocess.run(["git", "commit", "-m", "setup", "-q"], cwd=project, check=True)
result = _run_bash("auto-commit.sh", project, "after_specify")
assert result.returncode == 0
assert "No changes" in result.stderr
def test_conventional_with_trailing_inline_comment(self, tmp_path: Path):
"""commit_style value with a trailing YAML inline comment is still recognized."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventional # team standard\n"
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
' message: "[Spec Kit] Add specification"\n'
))
(project / "new-file.txt").write_text("content")
result = _run_bash(
"auto-commit.sh", project, "after_specify", "feat: add OAuth specification"
)
assert result.returncode == 0
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "feat: add OAuth specification" in log.stdout
assert "[Spec Kit] Add specification" not in log.stdout
def test_unknown_commit_style_defaults_to_fixed(self, tmp_path: Path):
"""An unrecognized commit_style value falls back to 'fixed' with a warning,
instead of silently mis-parsing or crashing."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventonal\n"
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
' message: "[Spec Kit] Add specification"\n'
))
(project / "new-file.txt").write_text("content")
result = _run_bash("auto-commit.sh", project, "after_specify")
assert result.returncode == 0
assert "unknown commit_style" in result.stderr.lower()
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "[Spec Kit] Add specification" in log.stdout
def test_duplicate_commit_style_lines_use_first_match(self, tmp_path: Path):
"""A config with multiple `commit_style:` lines (e.g. from a bad merge) uses only
the first match instead of concatenating values into an unrecognized style."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventional\n"
"commit_style: fixed\n"
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
' message: "[Spec Kit] Add specification"\n'
))
(project / "new-file.txt").write_text("content")
result = _run_bash(
"auto-commit.sh", project, "after_specify", "feat: add OAuth specification"
)
assert result.returncode == 0
assert "unknown commit_style" not in result.stderr.lower()
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "feat: add OAuth specification" in log.stdout
assert "[Spec Kit] Add specification" not in log.stdout
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
class TestAutoCommitPowerShell:
def test_disabled_by_default(self, tmp_path: Path):
@@ -1227,6 +1516,271 @@ class TestAutoCommitPowerShell:
assert "\u2713" not in result.stdout, "Must not use Unicode checkmark"
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
class TestAutoCommitPowerShellCommitStyle:
"""Tests for the `commit_style: conventional` option (issue #3390)."""
def test_fixed_is_default_when_commit_style_absent(self, tmp_path: Path):
"""Omitting commit_style preserves the fixed/static message behavior."""
project = _setup_project(tmp_path)
_write_config(project, (
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
' message: "[Spec Kit] Add specification"\n'
))
(project / "new-file.txt").write_text("content")
result = _run_pwsh("auto-commit.ps1", project, "after_specify")
assert result.returncode == 0
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "[Spec Kit] Add specification" in log.stdout
def test_explicit_fixed_style_uses_configured_message(self, tmp_path: Path):
"""commit_style: fixed (explicit) still uses the configured static message,
not just the absent-key default."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: fixed\n"
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
' message: "[Spec Kit] Add specification"\n'
))
(project / "new-file.txt").write_text("content")
result = _run_pwsh(
"auto-commit.ps1", project, "after_specify", "feat: this should be ignored"
)
assert result.returncode == 0
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "[Spec Kit] Add specification" in log.stdout
assert "this should be ignored" not in log.stdout
def test_conventional_message_file_used(self, tmp_path: Path):
"""-MessageFile reads the generated message from a file instead of argv,
avoiding shell interpolation of agent-controlled content."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventional\n"
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
' message: "[Spec Kit] Add specification"\n'
))
(project / "new-file.txt").write_text("content")
msg_file = project / "commit-msg.txt"
msg_file.write_text("feat: add $(dangerous) `injection` test\n")
result = _run_pwsh(
"auto-commit.ps1", project, "after_specify", "-MessageFile", str(msg_file)
)
assert result.returncode == 0
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "feat: add $(dangerous) `injection` test" in log.stdout
def test_message_file_not_staged_or_left_behind(self, tmp_path: Path):
"""-MessageFile written inside the worktree must never be staged or
committed itself, and must be removed once its content is consumed."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventional\n"
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
))
(project / "new-file.txt").write_text("content")
msg_file = project / "commit-msg.txt"
msg_file.write_text("feat: real change\n")
result = _run_pwsh(
"auto-commit.ps1", project, "after_specify", "-MessageFile", str(msg_file)
)
assert result.returncode == 0
assert not msg_file.exists()
show = subprocess.run(
["git", "show", "--stat", "--oneline", "HEAD"],
cwd=project, capture_output=True, text=True,
)
assert "new-file.txt" in show.stdout
assert "commit-msg.txt" not in show.stdout
def test_message_file_alone_does_not_defeat_no_changes_shortcircuit(self, tmp_path: Path):
"""If the message file is the only 'change' in the worktree (no real
edits), auto-commit must still report no changes rather than
committing the transport file by itself."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventional\n"
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
))
# Baseline-commit the scaffolding (and config) so the tree is
# genuinely clean before introducing the message file — otherwise
# the untracked scaffold files would mask whether the message file
# alone is enough to (incorrectly) trigger a commit.
subprocess.run(["git", "add", "-A"], cwd=project, check=True, capture_output=True)
subprocess.run(
["git", "commit", "-q", "-m", "baseline"],
cwd=project, check=True, capture_output=True, env={**os.environ, **_GIT_ENV},
)
msg_file = project / "commit-msg.txt"
msg_file.write_text("feat: no real changes\n")
result = _run_pwsh(
"auto-commit.ps1", project, "after_specify", "-MessageFile", str(msg_file)
)
assert result.returncode == 0
assert "No changes to commit" in (result.stdout + result.stderr)
assert not msg_file.exists()
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "baseline" in log.stdout
def test_message_file_missing_fails(self, tmp_path: Path):
"""-MessageFile pointing at a nonexistent file fails clearly."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventional\n"
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
))
(project / "new-file.txt").write_text("content")
missing = tmp_path / "does-not-exist.txt"
result = _run_pwsh(
"auto-commit.ps1", project, "after_specify", "-MessageFile", str(missing)
)
assert result.returncode != 0
assert "not found" in (result.stdout + result.stderr).lower()
def test_conventional_uses_generated_message(self, tmp_path: Path):
"""commit_style: conventional uses the generated_message argument as the commit message."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventional\n"
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
' message: "[Spec Kit] Add specification"\n'
))
(project / "new-file.txt").write_text("content")
result = _run_pwsh(
"auto-commit.ps1", project, "after_specify", "feat: add OAuth specification"
)
assert result.returncode == 0
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "feat: add OAuth specification" in log.stdout
assert "[Spec Kit] Add specification" not in log.stdout
def test_conventional_without_generated_message_fails(self, tmp_path: Path):
"""commit_style: conventional fails clearly instead of falling back to the fixed message."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventional\n"
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
' message: "[Spec Kit] Add specification"\n'
))
(project / "new-file.txt").write_text("content")
result = _run_pwsh("auto-commit.ps1", project, "after_specify")
assert result.returncode != 0
# Write-Warning output placement (stdout vs. stderr) is not deterministic
# across pwsh versions/platforms, so check the combined stream like the
# other pwsh tests above (e.g. test_not_a_repo_still_detected_with_autocrlf).
combined = result.stdout + result.stderr
assert "conventional" in combined.lower()
log = subprocess.run(
["git", "log", "--oneline"],
cwd=project, capture_output=True, text=True,
)
assert "[Spec Kit] Add specification" not in log.stdout
def test_conventional_skips_cleanly_with_no_changes(self, tmp_path: Path):
"""No pending changes short-circuits before the missing-message failure."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventional\n"
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
))
subprocess.run(["git", "add", "."], cwd=project, check=True)
subprocess.run(["git", "commit", "-m", "setup", "-q"], cwd=project, check=True)
result = _run_pwsh("auto-commit.ps1", project, "after_specify")
assert result.returncode == 0
combined = result.stdout + result.stderr
assert "No changes" in combined
def test_conventional_with_trailing_inline_comment(self, tmp_path: Path):
"""commit_style value with a trailing YAML inline comment is still recognized."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventional # team standard\n"
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
' message: "[Spec Kit] Add specification"\n'
))
(project / "new-file.txt").write_text("content")
result = _run_pwsh(
"auto-commit.ps1", project, "after_specify", "feat: add OAuth specification"
)
assert result.returncode == 0
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "feat: add OAuth specification" in log.stdout
assert "[Spec Kit] Add specification" not in log.stdout
def test_unknown_commit_style_defaults_to_fixed(self, tmp_path: Path):
"""An unrecognized commit_style value falls back to 'fixed' with a warning,
instead of silently mis-parsing or crashing."""
project = _setup_project(tmp_path)
_write_config(project, (
"commit_style: conventonal\n"
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
' message: "[Spec Kit] Add specification"\n'
))
(project / "new-file.txt").write_text("content")
result = _run_pwsh("auto-commit.ps1", project, "after_specify")
assert result.returncode == 0
combined = (result.stdout or "") + (result.stderr or "")
assert "unknown commit_style" in combined.lower()
log = subprocess.run(
["git", "log", "--oneline", "-1"],
cwd=project, capture_output=True, text=True,
)
assert "[Spec Kit] Add specification" in log.stdout
# ── auto-commit.ps1 CRLF warning tests (issue #2253) ────────────────────────

View File

@@ -491,3 +491,16 @@ def test_update_keeps_component_still_needed_by_sibling_bundle(tmp_path: Path):
assert ("extensions", "ext-b") not in {
(c.kind, c.id) for c in rec.contributed_components
}
def test_install_result_changed_reports_uninstalled():
# A `bundle update` that only DROPS components (new manifest reduces
# provides) populates uninstalled with nothing installed/refreshed; that is
# still a mutating change, so `changed` must be True — not a false no-op.
from specify_cli.bundler.services.installer import InstallResult
from specify_cli.bundler.models.manifest import ComponentRef
result = InstallResult(bundle_id="x")
assert result.changed is False # empty == no change
result.uninstalled.append(ComponentRef(kind="presets", id="p1"))
assert result.changed is True

View File

@@ -11,6 +11,12 @@ class TestOmpIntegration(MarkdownIntegrationTests):
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".omp/commands"
def test_multi_install_safe(self):
# Omp writes only to its isolated, static root .omp/commands, disjoint
# from every other integration, so it must be co-install safe (mirrors
# qwen/shai/qodercli and the kiro-cli #3471 precedent).
assert get_integration(self.KEY).multi_install_safe is True
def test_build_exec_args_uses_omp_json_mode(self):
i = get_integration(self.KEY)

View File

@@ -163,6 +163,58 @@ def _create_unicode_extension_dir(temp_dir: Path, ext_id: str = "uni-ext") -> Pa
return ext_dir
def _create_dashed_description_extension_dir(
temp_dir: Path, ext_id: str = "dash-ext"
) -> Path:
"""Create an extension whose command description contains a ``---`` run.
A ``---`` inside the description survives into the generated SKILL.md
frontmatter and exercises the delimiter-line parsing used when reading
metadata.source back during removal (regression guard for the
split("---", 2) substring bug, mirroring #3590).
"""
ext_dir = temp_dir / ext_id
ext_dir.mkdir()
description = "Separate sections with --- markers"
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": ext_id,
"name": "Dashed Extension",
"version": "1.0.0",
"description": description,
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": f"speckit.{ext_id}.hello",
"file": "commands/hello.md",
"description": description,
},
]
},
}
with open(ext_dir / "extension.yml", "w", encoding="utf-8") as f:
yaml.safe_dump(manifest_data, f, allow_unicode=True)
commands_dir = ext_dir / "commands"
commands_dir.mkdir()
(commands_dir / "hello.md").write_text(
"---\n"
f'description: "{description}"\n'
"---\n"
"\n"
"# Hello\n"
"\n"
"Body.\n",
encoding="utf-8",
)
return ext_dir
def _can_create_symlink(temp_dir: Path) -> bool:
"""Return True when the current platform/user can create file symlinks."""
target = temp_dir / "symlink-target.txt"
@@ -1658,6 +1710,65 @@ class TestExtensionSkillUnregistration:
assert not (skills_dir / "speckit-test-ext-hello").exists()
assert not (skills_dir / "speckit-test-ext-world").exists()
def test_skills_removed_when_description_contains_dashes(
self, skills_project, temp_dir
):
"""A ``---`` in the command description must not orphan the skill dir.
The removal safety check reads metadata.source back from the generated
SKILL.md. A raw ``split("---", 2)`` stopped at the ``---`` embedded in
the description, so metadata.source parsed empty, the skill looked
unrelated, and its directory was left behind. Regression guard for the
delimiter-line fix (mirrors #3590).
"""
project_dir, skills_dir = skills_project
ext_dir = _create_dashed_description_extension_dir(temp_dir)
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
ext_dir, "0.1.0", register_commands=False
)
skill_dir = skills_dir / "speckit-dash-ext-hello"
skill_md = skill_dir / "SKILL.md"
assert skill_md.exists()
# The dashed description must have survived into the frontmatter.
assert "--- markers" in skill_md.read_text(encoding="utf-8")
result = manager.remove(manifest.id, keep_config=False)
assert result is True
# The extension's own skill must be recognised and removed, not orphaned.
assert not skill_dir.exists()
def test_skills_removed_with_dashes_via_fallback_scan(
self, skills_project, temp_dir
):
"""Same ``---`` guard, but exercised through the fallback scan branch.
The fast path resolves the skills dir from init-options; the fallback
branch scans every candidate agent dir when that resolution returns
None, and it re-reads metadata.source with an independently duplicated
parser. Deleting init-options.json after install forces removal down
the fallback path so a substring-split regression there is caught too.
"""
project_dir, skills_dir = skills_project
ext_dir = _create_dashed_description_extension_dir(temp_dir)
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
ext_dir, "0.1.0", register_commands=False
)
skill_dir = skills_dir / "speckit-dash-ext-hello"
assert (skill_dir / "SKILL.md").exists()
# Drop init-options so _get_skills_dir() returns None and removal takes
# the fallback directory-scan branch instead of the fast path.
(project_dir / ".specify" / "init-options.json").unlink()
result = manager.remove(manifest.id, keep_config=False)
assert result is True
assert not skill_dir.exists()
def test_other_skills_preserved_on_remove(self, skills_project, extension_dir):
"""Non-extension skills should not be affected by extension removal."""
project_dir, skills_dir = skills_project

View File

@@ -6607,6 +6607,80 @@ class TestExtensionAddCLI:
plain = strip_ansi(result.output)
assert "Invalid URL" in plain
def test_add_from_bracketed_non_ip_url_exits_cleanly(self, tmp_path):
"""A bracketed-but-invalid IPv6 host must produce a clean error, not a
ValueError traceback. "https://[not-an-ip]/ext.zip" is a malformed
authority that raises ValueError during URL validation; the try/except
guard around parsing and the .hostname read must turn that into a clean
"Invalid URL" message.
"""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
project_dir = tmp_path / "test-project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app,
["extension", "add", "my-ext", "--from", "https://[not-an-ip]/ext.zip"],
catch_exceptions=True,
)
assert result.exit_code == 1
assert result.exception is None or isinstance(result.exception, SystemExit)
plain = strip_ansi(result.output)
assert "Invalid URL" in plain
def test_add_from_url_lazy_hostname_valueerror_exits_cleanly(self, tmp_path, monkeypatch):
"""Synthetic defensive coverage: monkeypatch urlparse() to return an
object whose .hostname raises ValueError lazily. This does not reproduce
any specific CPython behavior -- it just exercises the case where the
ValueError surfaces on the .hostname read rather than at parse time, so a
raw ValueError would leak if .hostname were read outside the try/except.
"""
import urllib.parse
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
real_urlparse = urllib.parse.urlparse
class _LazyHostnameRaiser:
def __init__(self, parsed):
self._parsed = parsed
@property
def hostname(self):
raise ValueError("simulated lazy IPv6 hostname failure")
def __getattr__(self, name):
return getattr(self._parsed, name)
def _fake_urlparse(url, *args, **kwargs):
return _LazyHostnameRaiser(real_urlparse(url, *args, **kwargs))
monkeypatch.setattr(urllib.parse, "urlparse", _fake_urlparse)
project_dir = tmp_path / "test-project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app,
["extension", "add", "my-ext", "--from", "https://example.com/ext.zip"],
catch_exceptions=True,
)
assert result.exit_code == 1
assert result.exception is None or isinstance(result.exception, SystemExit)
assert "Invalid URL" in strip_ansi(result.output)
def test_add_status_escapes_extension_markup(self, tmp_path):
"""User-controlled extension names must not be parsed as Rich markup."""
from rich.markup import escape as escape_markup
@@ -9010,3 +9084,34 @@ class TestConfigManagerCrossExtensionEnvLeak:
# Must not raise; must fall back to the "no siblings" path.
cfg = ConfigManager(tmp_path, "testext")._get_env_config()
assert cfg == {"url": "v"}
def test_forge_extension_install_listing_hyphenates_command_names(
extension_dir, project_dir
):
"""The post-install 'Provided commands' listing must show hyphenated
/speckit-<name> command names for a Forge project (Forge registers
hyphenated names), mirroring the existing Cline handling."""
import json
import os
from typer.testing import CliRunner
from specify_cli import app
init_options = project_dir / ".specify" / "init-options.json"
init_options.write_text(json.dumps({"ai": "forge", "script": "sh"}))
old_cwd = os.getcwd()
try:
os.chdir(project_dir)
result = CliRunner().invoke(
app, ["extension", "add", str(extension_dir), "--dev"]
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
# Forge registers hyphenated command names, so the summary must match.
assert "speckit-test-ext-hello" in result.output
assert "speckit.test-ext.hello" not in result.output

View File

@@ -5605,6 +5605,58 @@ class TestBundledPresetLocator:
assert "Invalid URL" in output
open_url.assert_not_called()
def test_preset_add_from_bracketed_non_ip_url_exits_cleanly(self, project_dir):
"""A bracketed-but-invalid IPv6 host in --from must exit cleanly.
"https://[not-an-ip]/preset.zip" is a malformed authority that raises
ValueError during URL validation; the try/except guard around parsing
and the .hostname read must turn that into a clean "Invalid URL" message.
"""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("specify_cli.authentication.http.open_url") as open_url:
result = runner.invoke(
app,
["preset", "add", "--from", "https://[not-an-ip]/preset.zip"],
catch_exceptions=True,
)
assert result.exit_code == 1
assert result.exception is None or isinstance(result.exception, SystemExit)
output = strip_ansi(result.output)
assert "Invalid URL" in output
open_url.assert_not_called()
def test_preset_add_from_url_out_of_range_port_exits_cleanly(self, project_dir):
"""An out-of-range port raises ValueError lazily on .port access.
The up-front guard reads ``_parsed.port`` (urllib validates the port
range/syntax there) inside its try/except, so "https://example.com:99999/
preset.zip" must produce a clean "Invalid URL" message rather than
leaking a raw ValueError traceback past the CLI.
"""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("specify_cli.authentication.http.open_url") as open_url:
result = runner.invoke(
app,
["preset", "add", "--from", "https://example.com:99999/preset.zip"],
catch_exceptions=True,
)
assert result.exit_code == 1
assert result.exception is None or isinstance(result.exception, SystemExit)
assert "Invalid URL" in strip_ansi(result.output)
open_url.assert_not_called()
def test_preset_add_bracketed_host_download_url_exits_cleanly(self, project_dir):
"""A catalog download_url with a bracketed non-IP host must render cleanly.

View File

@@ -686,6 +686,28 @@ class TestExpressions:
):
evaluate_expression("{{ inputs.tags | map }}", ctx)
def test_filter_call_with_trailing_tokens_fails_loudly(self):
# A trailing operator/token after a filter's closing paren must not be
# silently discarded (the parser used an unanchored regex). It must
# fall through to the "unsupported form" ValueError, like the from_json
# branch's strict trailing-token handling.
import pytest
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
# A comparison after a filter (binds looser than the pipe) was dropped,
# so `default('7') > '5'` silently returned '7'.
with pytest.raises(ValueError, match="unsupported form"):
evaluate_expression(
"{{ inputs.missing | default('7') > '5' }}", StepContext(inputs={})
)
# Trailing garbage after a valid filter call.
with pytest.raises(ValueError, match="unsupported form"):
evaluate_expression(
"{{ inputs.tags | join(',') extra }}",
StepContext(inputs={"tags": ["a", "b"]}),
)
def test_chained_filters_apply_left_to_right(self):
# Filters chain: each filter's result feeds the next. `map` yields a
# list and `join` is the only filter that renders a list to a string,
@@ -7860,6 +7882,37 @@ class TestWorkflowAddCaseInsensitiveSuffix:
assert "installed" in result.output
class TestWorkflowInfoStepGraph:
"""`workflow info` must render each step as `→ <id> [<type>]` with LITERAL
brackets. Rich parses an unescaped `[<type>]` as a style tag and silently
swallows it, so the step type would vanish from the output."""
def test_step_type_rendered_in_literal_brackets(self, temp_dir, monkeypatch):
import types
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.workflows.engine import WorkflowEngine
(temp_dir / ".specify" / "workflows").mkdir(parents=True)
fake = types.SimpleNamespace(
name="My WF", id="my-wf", version="1.0.0", author="", description="",
default_integration=None, inputs={},
steps=[{"id": "step-one", "type": "gate"}],
)
monkeypatch.setattr(WorkflowEngine, "load_workflow", lambda self, wid: fake)
monkeypatch.chdir(temp_dir)
result = CliRunner().invoke(app, ["workflow", "info", "my-wf"])
assert result.exit_code == 0, result.output
assert "step-one" in result.output
# The step type must survive as a literal bracketed token, not be eaten
# by Rich as an unknown style tag.
assert "[gate]" in result.output
class TestWorkflowAddSymlinkGuard:
def test_add_malformed_ipv6_url_exits_cleanly(self, temp_dir, monkeypatch):
"""A malformed IPv6 URL must produce a clean error, not a ValueError traceback."""

View File

@@ -154,6 +154,20 @@ def test_read_rejects_non_mapping_top_level(tmp_path: Path):
cc._read(project)
@pytest.mark.parametrize("body", ["[]\n", "false\n", "0\n", "''\n", "null\n", "~\n"])
def test_read_rejects_falsy_non_mapping_top_level(tmp_path: Path, body: str):
# A FALSY non-mapping top level ([], false, 0, '') OR an explicit null
# (null/~) must raise like a truthy one. safe_load coerces these to
# None/{}, so load_yaml distinguishes them from a truly empty document —
# staying consistent with models/catalog._merge_config.
project = tmp_path / "proj"
(project / ".specify").mkdir(parents=True)
cc._config_path(project).write_text(body, encoding="utf-8")
with pytest.raises(BundlerError, match="expected a mapping at the top level"):
cc._read(project)
def test_read_rejects_unknown_schema_version(tmp_path: Path):
project = tmp_path / "proj"
(project / ".specify").mkdir(parents=True)

View File

@@ -45,6 +45,27 @@ def test_load_missing_file_returns_empty(tmp_path: Path):
assert load_records(tmp_path) == []
@pytest.mark.parametrize("bad", [0, False, "", {}])
def test_load_records_rejects_falsy_non_list_bundles(tmp_path: Path, bad):
# `data.get("bundles") or []` coerced a FALSY non-list (0, '', False, {})
# to [] before the isinstance guard, silently treating a corrupt records
# file as "no bundles". Only an absent/None value means empty.
(tmp_path / ".specify").mkdir()
records_path(tmp_path).write_text(
json.dumps({"schema_version": "1.0", "bundles": bad}), encoding="utf-8"
)
with pytest.raises(BundlerError, match="'bundles' must be a list"):
load_records(tmp_path)
@pytest.mark.parametrize("bad", [0, False, "", {}])
def test_from_dict_rejects_falsy_non_list_contributed_components(bad):
# Same falsy-coercion hole for a record's 'contributed_components'.
data = {"bundle_id": "a", "version": "1.0.0", "contributed_components": bad}
with pytest.raises(BundlerError, match="'contributed_components' must be a list"):
InstalledBundleRecord.from_dict(data)
def test_corrupt_priority_raises_actionable_error(tmp_path: Path):
(tmp_path / ".specify").mkdir()
rec = _record("a", [("presets", "p1")])