Compare commits

..

35 Commits

Author SHA1 Message Date
github-actions[bot]
ab64de9b6c Add EARS Requirements Syntax extension to community catalog
Add ears extension submitted by @v-dhruv to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #3395

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-08 12:35:43 +00:00
Marsel Safin
882e1e90d0 fix(cli): exit cleanly on malformed IPv6 URLs in extension/preset/workflow add (#3369)
* fix(cli): exit cleanly on malformed IPv6 URLs in extension/preset/workflow add

extension add --from, preset add --from, and workflow add <url> parsed
the user-supplied URL with a bare urlparse before their HTTPS/host
validation, so an unclosed IPv6 bracket escaped as a raw ValueError
traceback. Wrap each parse and emit the surrounding validation's clean
error style + typer.Exit(1) instead.

Fixes #3368

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

* fix(cli): convert malformed redirect URLs to URLError in shared redirect handler

Parse the redirect target once in _StripAuthOnRedirect.redirect_request
before the validator and stdlib handler run, converting ValueError into
URLError which every download path already catches. Also escape from_url
in the preset install message so IPv6 brackets don't break Rich markup.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-07 15:20:13 -05:00
Ali jawwad
a307894709 fix(github-http): return None on malformed GHES port instead of raising (#3379)
resolve_github_release_asset_api_url's is_ghes branch built the authority
with 'parsed.port', which raises ValueError on a malformed port (e.g.
host:notaport). The function's contract is to resolve or return None,
never raise — every other unresolvable case returns None. An allowlisted
GHES host with a bad port therefore crashed the caller. Read parsed.port
defensively and return None on ValueError.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:14:25 -05:00
Ali jawwad
10d4bca64c fix(integrations): guard _sha256 against unreadable managed files (#3376)
manifest.py::_sha256 does an unguarded open(). check_modified() and
uninstall() both call it on a readable-but-unopenable regular file
(e.g. permission denied) without catching OSError, so
'specify integration upgrade/uninstall/switch' surface a raw
PermissionError traceback. Guard both call sites: in check_modified()
treat an unreadable file as modified (consistent with the adjacent
symlink / non-regular-file handling); in uninstall() treat it as skipped
and preserve it (mirroring the existing path.unlink() OSError guard just
below). The force short-circuit is unchanged.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:11:18 -05:00
Manfred Riem
f1a8d8f95b chore: release 0.12.7, begin 0.12.8.dev0 development (#3398)
* chore: bump version to 0.12.7

* chore: begin 0.12.8.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-07 15:01:43 -05:00
Ali jawwad
d5ba062eab fix(bundler): bundle update uninstalls components dropped by new version (#3353)
On refresh (bundle update), install_bundle iterated only the new plan's
components, so a component the previous version owned but the new one no
longer ships was left installed on disk while being dropped from the
rewritten record (contributed only holds plan.components). With no
record referencing it, remove_bundle could never clean it up —
permanently orphaned, violating the provenance invariant (FR-022). After
the component loop, when refresh and a prior record exists, uninstall
each previously-owned component absent from the new plan — unless another
bundle still needs it (components_still_needed refcount, mirroring
remove_bundle), in which case it stays installed and is simply
de-attributed. Runs inside the existing try so a failed removal takes the
same no-record-written rollback path.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:59:27 -05:00
Ali jawwad
12faf7b5b5 fix(workflows): route run/resume errors to stderr under --json (#3352)
workflow run/resume --json is contracted to emit a single JSON object on
stdout, but every error path (workflow-not-found, invalid workflow,
validation failure, execute/resume failure, and the shared
_parse_input_values invalid-input error) used console.print, landing the
human error text on stdout and corrupting the machine-readable stream.
Route those messages through err_console when --json is set (a no-op for
normal text mode), mirroring the stderr-only error routing already used
by 'specify bundle' (_fail) and err_console elsewhere in this module.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:52:36 -05:00
Ali jawwad
220e6fcc4e fix(workflows): fan-in validate() rejects non-mapping output (#3349)
FanInStep.validate() only checked wait_for, so a non-mapping 'output'
(a list or scalar) validated clean; execute() then silently coerces it
to {}, so the author's declared aggregation keys vanish with COMPLETED
status and no diagnostic. Reject a non-mapping output at validation,
mirroring the command-step (#3262) non-mapping fix. execute()'s
defensive coercion is left in place for unvalidated callers.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:49:29 -05:00
Ali jawwad
fb796c2a39 fix(workflows): shell step validate() rejects non-string run (#3348)
ShellStep.validate() only checked that 'run' was present, so run: (null)
or a GitHub-Actions-style list validated clean; execute() then
str()-coerces the value and invokes it under shell=True, literally
running 'None' or "['echo', 'hi']" as a command. Add a type check after
the presence check, mirroring the command-step (#3262) and gate options
validation. Expression strings ('{{ ... }}') are strings, so they stay
valid.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:48:26 -05:00
Ali jawwad
0151d239b5 fix(integrations): agy honors SPECKIT_INTEGRATION_AGY_EXTRA_ARGS (#3347)
AgyIntegration.build_exec_args returned [exe, '--print', prompt] without
calling _apply_extra_args_env_var(), so the documented per-integration
extra-args env hook was silently dropped for agy — same class as the
cursor-agent fix #3265. Append the hook after the positional prompt,
matching the devin integration's shape. agy still ignores model/output
as before.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:38:36 -05:00
github-actions[bot]
f764270d06 Add Orchestration Task Context Management extension to community catalog (#3372)
Add orchestration-task-context-management extension submitted by @benizzio to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #3356

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-07 12:56:34 -05:00
github-actions[bot]
7839acce86 Update DocGuard — CDD Enforcement extension to v0.30.0 (#3371)
Update docguard extension submitted by @raccioly:
- extensions/catalog.community.json (version, download_url, description, updated_at)
- docs/community/extensions.md community extensions table

Closes #3355

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-07 12:30:29 -05:00
github-actions[bot]
1935cf7e48 Update Ripple extension to v1.1.0 (#3370)
Update ripple extension submitted by @chordpli:
- extensions/catalog.community.json (version, download_url, description, requires.tools)
- docs/community/extensions.md community extensions table

Closes #3354

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-07 12:25:54 -05:00
Roland Huss
d4e7d2b888 feat(integrations): generalize post-processing to all format types (#3311)
* feat(integrations): add post_process_command_content() hook for all format types

Add post_process_command_content(self, content: str) -> str to IntegrationBase
with a no-op default. Wire it into register_commands() for non-skills format
types (Markdown, TOML, YAML) after format rendering, before writing to disk.
Also applies to aliases rendered via the inject_name path (cline, forge).

Skills-format agents are excluded to preserve the existing
post_process_skill_content() path and avoid double-processing.

This gives extension authors a clean per-agent content transformation seam
for all 21 non-skills integrations that previously had no post-processing hook.

Ref: #3303

Assisted-By: 🤖 Claude Code

* fix: initialize _integration before conditional branch

Prevents potential UnboundLocalError if the non-skills guard is
refactored without updating the alias path reference.

Assisted-By: 🤖 Claude Code
2026-07-07 11:35:43 -05:00
Manfred Riem
abaed10d00 chore: release 0.12.6, begin 0.12.7.dev0 development (#3393)
* chore: bump version to 0.12.6

* chore: begin 0.12.7.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-07 11:05:13 -05:00
Marsel Safin
2b5e175440 fix(bundler): validate catalog URLs in catalog add (HTTPS-only, require host) (#3367)
* fix(bundler): validate catalog URLs in `catalog add` (HTTPS-only, require host)

add_source persisted remote catalog URLs without the HTTPS/host checks
that specify_cli.catalogs (#3210) and the bundler adapters (#3333)
enforce, and an unclosed IPv6 bracket escaped as a raw ValueError.
Mirror the catalogs.py validation for http(s) schemes and wrap urlparse
so malformed input raises BundlerError.

Fixes #3366

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

* docs: correct config filename and validation reference in comment

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-07 11:01:46 -05:00
github-actions[bot]
0e40438903 Update Ralph Loop extension to v1.2.1 (#3365)
Update ralph extension submitted by @Rubiss:
- extensions/catalog.community.json (version, download_url, speckit_version, tools, tags, updated_at)

Closes #3337

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-07 10:55:18 -05:00
Zhiyao Wen
1930f89d17 fix extension-local script path rewriting (#3364)
Co-authored-by: Zhiyao <zhiyao@ZhiyaodeMacBook-Air.local>
2026-07-07 10:51:50 -05:00
github-actions[bot]
4bb5166445 Add Charter extension to community catalog (#3363)
Add charter extension submitted by @Huljo to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #3322

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-07 09:53:05 -05:00
WOLIKIMCHENG
73f77c200f feat(scripts): add Python check-prerequisites PoC (#3302)
* feat(scripts): add Python check-prerequisites PoC

* fix(scripts): address check-prerequisites parity feedback

* test(scripts): label PowerShell prerequisite parity cases

---------

Co-authored-by: root <kinsonnee@gmail.com>
2026-07-06 17:54:31 -05:00
Pascal THUET
b8d27e472f test: reduce registry manifest test repetition (#3146)
* test: isolate integration test home

Assisted-by: Codex (model: GPT-5, autonomous)

* test: reduce registry manifest test repetition

Assisted-by: Codex (model: GPT-5, autonomous)

* test: clarify disjoint-manifest order rationale and guard safe set

Add a >=2 precondition, explain why two install orders are tested
(manifests are order-independent; the orders only vary the init path),
and build the manifest map with a comprehension.

* test: rotate init coverage for manifest isolation

Assisted-by: Codex (model: GPT-5, autonomous)

* test: assert integration home isolation

Assisted-by: Codex (model: GPT-5, autonomous)

* test: guard multi-install manifest rotations

Assisted-by: Codex (model: GPT-5, autonomous)
2026-07-06 17:50:33 -05:00
Ali jawwad
587b1859fa fix(integrations): hermes honors SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS (#3346)
HermesIntegration.build_exec_args routed argv[0] through
_resolve_executable() but never called _apply_extra_args_env_var(), so
the documented per-integration extra-args env hook was silently dropped
for hermes — the same class of bug fixed for cursor-agent in #3265.
Insert the hook after the base 'chat -Q' command and before Spec Kit's
canonical -m/--json/-s/-q flags (mirrors opencode), so operator args
can't displace or clobber the canonical flags.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 17:38:39 -05:00
Ali jawwad
52480ee50f fix(extensions): coerce non-mapping YAML config roots to {} in ConfigManager (#3345)
ConfigManager._load_yaml_config returned yaml.safe_load(...) or {}, which
only guards falsy roots — a truthy non-mapping root (a YAML list or
scalar) flows straight into _merge_configs, whose .items() raises
AttributeError. get_config()/has_value()/get_value() then crash, and via
should_execute_hook's blanket 'except Exception: return False' every
config-based hook condition for that extension is silently disabled.
Coerce a non-dict root to {}, mirroring the existing non-dict-root guard
in get_project_config(). Hardens all three call sites in one place.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 17:36:35 -05:00
Quratulain-bilal
d3e7b06fa7 fix(yaml): pin goose recipe prompt block-scalar indentation (#3343)
the goose recipe renderer emitted the prompt body under a bare '|' block
scalar. yaml infers a plain block scalar's indentation from its first
non-empty line, so a command body whose first line is itself indented (a
markdown code block, a nested list item) made the parser expect that deeper
indent for the whole block and reject the later, shallower lines - the
generated .goose recipe then failed to parse.

use an explicit '|2' indentation indicator so the block is always read at
2 spaces regardless of the body. added a regression test that round-trips
an indented-first-line body through the yaml parser.
2026-07-06 17:34:16 -05:00
Manfred Riem
de6a04eaad chore: release 0.12.5, begin 0.12.6.dev0 development (#3381)
* chore: bump version to 0.12.5

* chore: begin 0.12.6.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-06 16:51:18 -05:00
Noor ul ain
5217206fdf fix(workflows): match gate reject option case-insensitively (#3335)
`validate` accepts a reject option case-insensitively
(`o.lower() in {"reject", "abort"}`), so a gate authored as
`options: [Approve, Reject]` passes validation. But `execute`
compared the echoed choice case-sensitively, so picking `Reject`
fell through to the approval path and silently ran downstream
steps instead of aborting.

Lower-case `choice` before the reject comparison so the runtime
agrees with the validation that let the option through.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 16:48:33 -05:00
Quratulain-bilal
c978faac57 fix(bundler): reject host-less catalog URLs in adapters (use hostname, not netloc) (#3333)
_validate_remote_url in bundler/services/adapters.py guarded on parsed.netloc,
which is truthy for host-less URLs like "https://:8080" or "https://user@" even
though they carry no host. so those passed the "must be a valid URL with a host"
check. its docstring says it mirrors specify_cli.catalogs validation, but that
site was already fixed to use hostname in #3210/#3227 and this twin was missed.

switch to parsed.hostname (None for host-less URLs), matching catalogs.py. this
guard runs before any network call, so it is a pre-flight safety check.

add parametrized regression tests for the host-less forms plus a valid
host+port sanity case.
2026-07-06 16:44:08 -05:00
Quratulain-bilal
b5f1194168 fix(bundler): resolve catalog search at highest-precedence source before filtering (#3331)
CatalogStack.search() claimed a bundle id in `seen` only when the entry matched
the query. so when the highest-precedence entry for an id did NOT match, a
lower-precedence entry with the same id could match and be returned instead --
even though resolve()/install always use the highest-precedence entry. search
advertised a bundle (name, version, source) the user could never actually get,
contradicting the method's own docstring ("resolved at its highest-precedence
source").

resolve every id to its highest-precedence entry first, then filter the
resolved set by the query. search now agrees with resolve(): a query that only
a shadowed lower-precedence copy matches returns nothing.

add a regression test covering the shadowed-entry case.
2026-07-06 16:42:45 -05:00
Quratulain-bilal
44c112c807 fix(workflows): compare non-numeric strings lexicographically instead of returning False (#3323)
_safe_compare coerced both operands to int/float unconditionally for <, >, <=,
>=. any non-numeric string (an iso date, a version tag, a name) failed that
coercion and the whole comparison silently returned False -- so
`{{ inputs.d < '2026-02-01' }}` was False even when the date was earlier.

only coerce when both operands look numeric; otherwise compare the original
values, so two strings order lexicographically the way python does and two
numeric strings still compare as numbers ("10" > "9"). a number vs a
non-numeric string stays incomparable and yields False.

add a regression test covering dates, plain strings, numeric strings, and the
number-vs-string case.
2026-07-06 16:20:45 -05:00
Quratulain-bilal
3b4e7f3cb6 fix(workflows): quote-aware interpolation so a literal }} in a filter arg doesn't break multi-expression templates (#3307)
* fix(workflows): quote-aware interpolation so a literal }} in a filter arg doesn't break multi-expression templates

#3208/#3228 hardened the single-expression fast path (_is_single_expression)
so a literal {{ or }} inside a string argument like `| default('}}')` stays on
the typed path. the multi-expression interpolation path was left on the old
_EXPR_PATTERN regex, whose non-greedy `(.+?)}}` body stops at the first }}
regardless of quoting. so a multi-expression template with a literal }} in any
block captured a truncated body, hit the filter parser malformed, and raised
ValueError.

e.g. `{{ inputs.name }}: {{ inputs.missing | default('}}') }}` raised instead
of interpolating.

replace _EXPR_PATTERN.sub with _interpolate_expressions, which scans each block
for a }} outside string literals - the same quote handling _is_single_expression
already uses. plain-value passthrough (a literal }} in a resolved value, not an
expression) is unchanged.

add regression tests for a literal }} in the second block and in the first
block, plus a literal {{ guard.

* fix(workflows): surface malformed templates in interpolation instead of emitting verbatim

address copilot review on #3307: when the quote-aware scan finds no block-closing
`}}` (e.g. an unbalanced quote in a filter arg swallowed the delimiter), fall back
to the first raw `}}` in the tail and evaluate it, so the parser raises ValueError
just as the old _EXPR_PATTERN.sub path did. only when there is no `}}` at all is
the tail left verbatim (a genuinely unterminated `{{`, which the regex also could
not match). keeps a typo failing loudly rather than being silently hidden.

add a regression test for an unbalanced quote in a multi-expression template.
2026-07-06 15:46:35 -05:00
Pascal THUET
f494a8e33e Support namespaced git feature branch templates (#3293)
* test: cover namespaced git branch templates

Assisted-by: Codex (model: GPT-5, autonomous)

* feat: support namespaced git branch templates

Assisted-by: Codex (model: GPT-5, autonomous)

* test: cover git branch template edge cases

Assisted-by: Codex (model: GPT-5, autonomous)

* fix: harden git branch template parsing

Assisted-by: Codex (model: GPT-5, autonomous)

* fix: address git branch template review feedback

Address Copilot review feedback for branch_prefix help text, namespaced GIT_BRANCH_NAME fallback behavior, final-segment validation docs, and Bash UTF-8 byte reporting.

Assisted-by: Codex (model: GPT-5, autonomous)

* fix: reject slug-scoped branch templates

Reject branch templates that place {slug} before {number}, because that makes namespace scanning depend on the generated feature slug and can reset numbering per feature name.

Assisted-by: Codex (model: GPT-5, autonomous)

* fix: ignore malformed timestamp refs when numbering

Align branch-number scanning with feature-branch validation so malformed timestamp-looking refs do not inflate sequential numbering. Also updates the stale git-common comment called out in review.

Assisted-by: Codex (model: GPT-5, autonomous)
2026-07-06 15:41:58 -05:00
dependabot[bot]
92b7cf7658 chore(deps): bump actions/setup-dotnet from 5.3.0 to 5.4.0 (#3315)
Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 5.3.0 to 5.4.0.
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](9a946fdbd5...26b0ec14cb)

---
updated-dependencies:
- dependency-name: actions/setup-dotnet
  dependency-version: 5.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-06 08:41:12 -05:00
Ali jawwad
bba473c223 fix(integrations): cursor-agent honors executable/extra-args env overrides (#3265)
* fix(integrations): cursor-agent ignores executable/extra-args env overrides

cursor-agent's build_exec_args() hardcoded self.key as argv[0] and never
called _apply_extra_args_env_var(), so the documented
SPECKIT_INTEGRATION_CURSOR_AGENT_EXECUTABLE (issue #2596) and
SPECKIT_INTEGRATION_CURSOR_AGENT_EXTRA_ARGS (issue #2595) hooks were
silently dropped — unlike every other CLI-dispatch integration (codex,
devin). Route argv[0] through _resolve_executable() and apply the
extra-args hook after the mandatory headless flags, mirroring the twins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(integrations): pin extra-args insertion order for cursor-agent

Per Copilot feedback: the extra-args override test only asserted the
injected tokens were present, not that they land before Spec Kit's
canonical --model / --output-format flags. Exercise build_exec_args with
both a model and JSON output and assert the extra args are inserted
before --model / --output-format (and the canonical flags stay intact and
paired). Verified this fails if the _apply_extra_args_env_var call is
moved after the flag extends.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 08:49:53 -05:00
Quratulain-bilal
288bd679f3 docs: drop stale kimi KIMI.md->AGENTS.md migration note (#3291)
* docs: drop stale kimi KIMI.md->AGENTS.md migration note

#3097 made the agent-context extension a full opt-in and removed the
KIMI.md -> AGENTS.md context migration from the kimi integration
(_migrate_legacy_kimi_context_file and the context_file handling are
gone). kimi's --migrate-legacy now only moves the skills directory. two
lines in the integrations reference still promised the removed context
migration; drop that clause so the docs match the code.

* docs: clarify kimi legacy migration is skill naming, not directory names

address review: the parenthetical said 'dotted->hyphenated directory
names', but the migration is about skill naming (speckit.xxx ->
speckit-xxx), matching the module docstring. reword to match.
2026-07-02 08:40:30 -05:00
Manfred Riem
9bd3512025 chore: release 0.12.4, begin 0.12.5.dev0 development (#3305)
* chore: bump version to 0.12.4

* chore: begin 0.12.5.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-02 05:57:50 -05:00
60 changed files with 3368 additions and 230 deletions

View File

@@ -35,7 +35,7 @@ jobs:
fetch-depth: 0 # Fetch all history for git info
- name: Setup .NET
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
with:
dotnet-version: '8.x'

View File

@@ -2,6 +2,51 @@
<!-- insert new changelog below this comment -->
## [0.12.7] - 2026-07-07
### Changed
- fix(bundler): bundle update uninstalls components dropped by new version (#3353)
- fix(workflows): route run/resume errors to stderr under --json (#3352)
- fix(workflows): fan-in validate() rejects non-mapping output (#3349)
- fix(workflows): shell step validate() rejects non-string run (#3348)
- fix(integrations): agy honors SPECKIT_INTEGRATION_AGY_EXTRA_ARGS (#3347)
- Add Orchestration Task Context Management extension to community catalog (#3372)
- Update DocGuard — CDD Enforcement extension to v0.30.0 (#3371)
- Update Ripple extension to v1.1.0 (#3370)
- feat(integrations): generalize post-processing to all format types (#3311)
- chore: release 0.12.6, begin 0.12.7.dev0 development (#3393)
## [0.12.6] - 2026-07-07
### Changed
- fix(bundler): validate catalog URLs in `catalog add` (HTTPS-only, require host) (#3367)
- Update Ralph Loop extension to v1.2.1 (#3365)
- fix extension-local script path rewriting (#3364)
- Add Charter extension to community catalog (#3363)
- feat(scripts): add Python check-prerequisites PoC (#3302)
- test: reduce registry manifest test repetition (#3146)
- fix(integrations): hermes honors SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS (#3346)
- fix(extensions): coerce non-mapping YAML config roots to {} in ConfigManager (#3345)
- fix(yaml): pin goose recipe prompt block-scalar indentation (#3343)
- chore: release 0.12.5, begin 0.12.6.dev0 development (#3381)
## [0.12.5] - 2026-07-06
### Changed
- fix(workflows): match gate reject option case-insensitively (#3335)
- fix(bundler): reject host-less catalog URLs in adapters (use hostname, not netloc) (#3333)
- fix(bundler): resolve catalog search at highest-precedence source before filtering (#3331)
- fix(workflows): compare non-numeric strings lexicographically instead of returning False (#3323)
- fix(workflows): quote-aware interpolation so a literal }} in a filter arg doesn't break multi-expression templates (#3307)
- Support namespaced git feature branch templates (#3293)
- chore(deps): bump actions/setup-dotnet from 5.3.0 to 5.4.0 (#3315)
- fix(integrations): cursor-agent honors executable/extra-args env overrides (#3265)
- docs: drop stale kimi KIMI.md->AGENTS.md migration note (#3291)
- chore: release 0.12.4, begin 0.12.5.dev0 development (#3305)
## [0.12.4] - 2026-07-02
### Changed

View File

@@ -42,6 +42,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Bugfix Workflow | Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically | `process` | Read+Write | [spec-kit-bugfix](https://github.com/Quratulain-bilal/spec-kit-bugfix) |
| Canon | Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation. | `process` | Read+Write | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon/tree/master/extension) |
| Catalog CI | Automated validation for spec-kit community catalog entries — structure, URLs, diffs, and linting | `process` | Read-only | [spec-kit-catalog-ci](https://github.com/Quratulain-bilal/spec-kit-catalog-ci) |
| Charter | Compose modular project constitutions from shared fragment registries. Centralize governance rules, select per-project fragments, track upstream changes, and keep multi-project setups consistent. | `process` | Read+Write | [spec-kit-charter](https://github.com/Fyloss/spec-kit-charter) |
| CI Guard | Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps | `process` | Read-only | [spec-kit-ci-guard](https://github.com/Quratulain-bilal/spec-kit-ci-guard) |
| Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) |
| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |
@@ -50,7 +51,8 @@ The following community-contributed extensions are available in [`catalog.commun
| Confluence Extension | Create a doc in Confluence summarizing the specifications and planning files | `integration` | Read+Write | [spec-kit-confluence](https://github.com/aaronrsun/spec-kit-confluence) |
| Cost Tracker | Track real LLM dollar cost across SDD workflows — per-feature budgets, per-integration comparison, and finance-ready exports | `visibility` | Read+Write | [spec-kit-cost](https://github.com/Quratulain-bilal/spec-kit-cost) |
| Data Model Diagram | Generates Mermaid ER diagrams from Spec Kit data models after planning | `docs` | Read+Write | [spec-kit-data-model-diagram](https://github.com/benizzio/spec-kit-data-model-diagram) |
| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. One pinned runtime dependency; pure Node.js otherwise. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
| DocGuard — CDD Enforcement | The only doc-integrity engine with an MCP server, SARIF output, and a deterministic zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, GitHub Action with PR annotations, spec-kit hooks. Pure Node.js, one pinned dep. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
| EARS Requirements Syntax | Author, lint, and convert requirements using EARS - the five industry-standard sentence patterns for unambiguous, testable requirements | `docs` | Read+Write | [spec-kit-ears](https://github.com/dhruv-15-03/spec-kit-ears) |
| Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) |
| Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) |
| FixIt Extension | Spec-aware bug fixing — maps bugs to spec artifacts, proposes a plan, applies minimal changes | `code` | Read+Write | [spec-kit-fixit](https://github.com/speckit-community/spec-kit-fixit) |
@@ -85,6 +87,7 @@ The following community-contributed extensions are available in [`catalog.commun
| .NET Framework to Modern .NET Migration | Orchestrate end-to-end .NET Framework to modern .NET migration across 7 phases, with SDD lifecycle integration | `process` | Read+Write | [spec-kit-fx-to-net](https://github.com/RogerBestMsft/spec-kit-FxToNet) |
| Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) |
| Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) |
| Orchestration Task Context Management | Adds subagent work-unit orchestration to generated Spec Kit task files | `process` | Read+Write | [spec-kit-orchestration-task-context-management](https://github.com/benizzio/spec-kit-orchestration-task-context-management) |
| OWASP LLM Threat Model | OWASP Top 10 for LLM Applications 2025 threat analysis on agent artifacts | `code` | Read-only | [spec-kit-threatmodel](https://github.com/NaviaSamal/spec-kit-threatmodel) |
| Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) |
| PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) |
@@ -105,7 +108,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Retro Extension | Sprint retrospective analysis with metrics, spec accuracy assessment, and improvement suggestions | `process` | Read+Write | [spec-kit-retro](https://github.com/arunt14/spec-kit-retro) |
| Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | `docs` | Read+Write | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) |
| Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) |
| Ripple | Detect side effects that tests can't catch after implementation — delta-anchored analysis across 9 domain-agnostic categories | `code` | Read+Write | [spec-kit-ripple](https://github.com/chordpli/spec-kit-ripple) |
| Ripple | Detect side effects that tests can't catch after implementation — surface hidden ripple effects across 9 analysis categories | `code` | Read+Write | [spec-kit-ripple](https://github.com/chordpli/spec-kit-ripple) |
| SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) |
| Security Review | Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews | `code` | Read+Write | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) |
| SFSpeckit | Enterprise Salesforce SDLC with 18 commands for the full SDD lifecycle. | `process` | Read+Write | [spec-kit-sf](https://github.com/ysumanth06/spec-kit-sf) |

View File

@@ -24,7 +24,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent |
| [Junie](https://junie.jetbrains.com/) | `junie` | |
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | |
| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; installs into `.kimi-code/skills/`. `--migrate-legacy` moves old `.kimi/skills/` installs to the new paths, and (when the `agent-context` extension is enabled) migrates `KIMI.md` context into `AGENTS.md` |
| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; installs into `.kimi-code/skills/`. `--migrate-legacy` moves old `.kimi/skills/` installs to the new paths |
| [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Kiro CLI does not substitute `$ARGUMENTS` in file-based prompts, so Spec Kit ships a prose fallback at render time (see [Manage prompts](https://kiro.dev/docs/cli/chat/manage-prompts/) and issue [#1926](https://github.com/github/spec-kit/issues/1926)). Alias: `--integration kiro` |
| [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically |
| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | |
@@ -218,7 +218,7 @@ Some integrations accept additional options via `--integration-options`:
| Integration | Option | Description |
| ----------- | ------------------- | -------------------------------------------------------------- |
| `generic` | `--commands-dir` | Required. Directory for command files |
| `kimi` | `--migrate-legacy` | Migrate legacy `.kimi/skills/` installs to `.kimi-code/skills/` (including dotted→hyphenated directory names); when the `agent-context` extension is enabled, also migrates `KIMI.md` to `AGENTS.md` |
| `kimi` | `--migrate-legacy` | Migrate legacy `.kimi/skills/` installs to `.kimi-code/skills/` (including dotted→hyphenated skill naming, e.g. `speckit.xxx``speckit-xxx`) |
Example:

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-01T00:00:00Z",
"updated_at": "2026-07-08T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -670,6 +670,40 @@
"created_at": "2026-04-11T18:00:00Z",
"updated_at": "2026-04-11T18:00:00Z"
},
"charter": {
"name": "Charter",
"id": "charter",
"description": "Compose modular project constitutions from shared fragment registries. Centralize governance rules, select per-project fragments, track upstream changes, and keep multi-project setups consistent.",
"author": "Fyloss",
"version": "0.3.1",
"download_url": "https://github.com/Fyloss/spec-kit-charter/archive/refs/tags/v0.3.1.zip",
"repository": "https://github.com/Fyloss/spec-kit-charter",
"homepage": "https://github.com/Fyloss/spec-kit-charter",
"documentation": "https://github.com/Fyloss/spec-kit-charter/tree/master/docs",
"changelog": "https://github.com/Fyloss/spec-kit-charter/blob/master/CHANGELOG.md",
"license": "MIT",
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.11.9"
},
"provides": {
"commands": 5,
"hooks": 1
},
"tags": [
"constitution",
"governance",
"modular",
"fragments",
"registry"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-06T00:00:00Z",
"updated_at": "2026-07-06T00:00:00Z"
},
"ci-guard": {
"name": "CI Guard",
"id": "ci-guard",
@@ -1072,10 +1106,10 @@
"docguard": {
"name": "DocGuard — CDD Enforcement",
"id": "docguard",
"description": "Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. One pinned runtime dependency; pure Node.js otherwise.",
"description": "The only doc-integrity engine with an MCP server, SARIF output, and a deterministic zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, GitHub Action with PR annotations, spec-kit hooks. Pure Node.js, one pinned dep.",
"author": "raccioly",
"version": "0.28.0",
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.28.0/spec-kit-docguard-v0.28.0.zip",
"version": "0.30.0",
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.30.0/spec-kit-docguard-v0.30.0.zip",
"repository": "https://github.com/raccioly/docguard",
"homepage": "https://www.npmjs.com/package/docguard-cli",
"documentation": "https://github.com/raccioly/docguard/blob/main/extensions/spec-kit-docguard/README.md",
@@ -1111,7 +1145,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-03-13T00:00:00Z",
"updated_at": "2026-06-23T00:00:00Z"
"updated_at": "2026-07-06T00:00:00Z"
},
"doctor": {
"name": "Project Health Check",
@@ -1146,6 +1180,39 @@
"created_at": "2026-03-13T00:00:00Z",
"updated_at": "2026-03-13T00:00:00Z"
},
"ears": {
"name": "EARS Requirements Syntax",
"id": "ears",
"description": "Author, lint, and convert requirements using EARS (Easy Approach to Requirements Syntax) - the five industry-standard sentence patterns for unambiguous, testable requirements.",
"author": "dhruv-15-03",
"version": "1.0.0",
"download_url": "https://github.com/dhruv-15-03/spec-kit-ears/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/dhruv-15-03/spec-kit-ears",
"homepage": "https://github.com/dhruv-15-03/spec-kit-ears",
"documentation": "https://github.com/dhruv-15-03/spec-kit-ears/blob/main/README.md",
"changelog": "https://github.com/dhruv-15-03/spec-kit-ears/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "docs",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.9.0"
},
"provides": {
"commands": 3,
"hooks": 0
},
"tags": [
"ears",
"requirements",
"specification",
"quality"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-08T00:00:00Z",
"updated_at": "2026-07-08T00:00:00Z"
},
"extensify": {
"name": "Extensify",
"id": "extensify",
@@ -2362,6 +2429,39 @@
"created_at": "2026-04-03T00:00:00Z",
"updated_at": "2026-04-03T00:00:00Z"
},
"orchestration-task-context-management": {
"name": "Orchestration Task Context Management",
"id": "orchestration-task-context-management",
"description": "Adds subagent work-unit orchestration to generated Spec Kit task files",
"author": "Igor Benicio de Mesquita",
"version": "0.0.0",
"download_url": "https://github.com/benizzio/spec-kit-orchestration-task-context-management/archive/refs/tags/v0.0.0.zip",
"repository": "https://github.com/benizzio/spec-kit-orchestration-task-context-management",
"homepage": "https://github.com/benizzio/spec-kit-orchestration-task-context-management",
"documentation": "https://github.com/benizzio/spec-kit-orchestration-task-context-management/blob/main/README.md",
"changelog": "https://github.com/benizzio/spec-kit-orchestration-task-context-management/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.7.2"
},
"provides": {
"commands": 2,
"hooks": 2
},
"tags": [
"agent",
"orchestration",
"tasks",
"context"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-06T00:00:00Z",
"updated_at": "2026-07-06T00:00:00Z"
},
"orchestrator": {
"name": "Spec Orchestrator",
"id": "orchestrator",
@@ -2679,8 +2779,8 @@
"id": "ralph",
"description": "Autonomous implementation loop using AI agent CLI",
"author": "Rubiss",
"version": "1.1.1",
"download_url": "https://github.com/Rubiss-Projects/spec-kit-ralph/archive/refs/tags/v1.1.1.zip",
"version": "1.2.1",
"download_url": "https://github.com/Rubiss-Projects/spec-kit-ralph/archive/refs/tags/v1.2.1.zip",
"repository": "https://github.com/Rubiss-Projects/spec-kit-ralph",
"homepage": "https://github.com/Rubiss-Projects/spec-kit-ralph",
"documentation": "https://github.com/Rubiss-Projects/spec-kit-ralph/blob/main/README.md",
@@ -2689,7 +2789,7 @@
"category": "code",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.1.0",
"speckit_version": ">=0.8.5",
"tools": [
{
"name": "copilot",
@@ -2699,6 +2799,10 @@
"name": "codex",
"required": false
},
{
"name": "claude",
"required": false
},
{
"name": "git",
"required": true
@@ -2714,13 +2818,14 @@
"automation",
"loop",
"copilot",
"codex"
"codex",
"claude"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-03-09T00:00:00Z",
"updated_at": "2026-06-05T03:11:06Z"
"updated_at": "2026-07-06T00:00:00Z"
},
"reconcile": {
"name": "Reconcile Extension",
@@ -3047,10 +3152,10 @@
"ripple": {
"name": "Ripple",
"id": "ripple",
"description": "Detect side effects that tests can't catch after implementation — delta-anchored analysis across 9 domain-agnostic categories with fix-induced side effect detection",
"description": "Detect side effects that tests can't catch after implementation — surface hidden ripple effects across 9 analysis categories",
"author": "chordpli",
"version": "1.0.0",
"download_url": "https://github.com/chordpli/spec-kit-ripple/archive/refs/tags/v1.0.0.zip",
"version": "1.1.0",
"download_url": "https://github.com/chordpli/spec-kit-ripple/archive/refs/tags/v1.1.0.zip",
"repository": "https://github.com/chordpli/spec-kit-ripple",
"homepage": "https://github.com/chordpli/spec-kit-ripple",
"documentation": "https://github.com/chordpli/spec-kit-ripple/blob/main/README.md",
@@ -3059,7 +3164,13 @@
"category": "code",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.2.0"
"speckit_version": ">=0.2.0",
"tools": [
{
"name": "git",
"required": true
}
]
},
"provides": {
"commands": 3,
@@ -3076,7 +3187,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-04-20T00:00:00Z",
"updated_at": "2026-04-20T00:00:00Z"
"updated_at": "2026-07-06T00:00:00Z"
},
"roadmap": {
"name": "Spec Roadmap",

View File

@@ -7,7 +7,7 @@ Git repository initialization, feature branch creation, numbering (sequential/ti
This extension provides Git operations as an optional, self-contained module. It manages:
- **Repository initialization** with configurable commit messages
- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering
- **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)
@@ -53,6 +53,16 @@ Configuration is stored in `.specify/extensions/git/git-config.yml`:
# Branch numbering strategy: "sequential" or "timestamp"
branch_numbering: sequential
# Optional branch name template. Leave empty for the default "{number}-{slug}".
# Supported tokens: {author}, {app}, {number}, {slug}; {slug} must not appear
# before {number}, and the final path segment must start with {number}-.
# Example for monorepos: "{author}/{app}/{number}-{slug}"
branch_template: ""
# Optional shorthand namespace. Leave empty to use branch_template/default behavior.
# Example: "features/{app}" expands to "features/{app}/{number}-{slug}"
branch_prefix: ""
# Custom commit message for git init
init_commit_message: "[Spec Kit] Initial commit"
@@ -65,6 +75,10 @@ auto_commit:
message: "[Spec Kit] Add specification"
```
`{author}` is derived from Git config and sanitized for branch names. `{app}` is derived from the Spec Kit init directory name. Custom templates must not put `{slug}` before `{number}`, and must put `{number}-` at the start of the final path segment so generated names remain valid feature branches. For a monorepo project at `apps/web/.specify/`, a template such as `{author}/{app}/{number}-{slug}` produces branches like `jdoe/web/008-guided-tour`.
For simple namespace-only customization, `branch_prefix` is also accepted as a shorthand and expands to `<branch_prefix>/{number}-{slug}`.
## Installation
```bash

View File

@@ -19,7 +19,7 @@ You **MUST** consider the user input before proceeding (if not empty).
If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set:
- The script uses the exact value as the branch name, bypassing all prefix/suffix generation
- `--short-name`, `--number`, and `--timestamp` flags are ignored
- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name
- `FEATURE_NUM` is extracted when the final path segment starts with a numeric or timestamp feature marker (for example `042-name`, `feat/042-name`, or `jdoe/app/042-name`), otherwise set to the full branch name
## Prerequisites
@@ -35,6 +35,19 @@ Determine the branch numbering strategy by checking configuration in this order:
3. Check `.specify/init-options.json` for `branch_numbering` value (deprecated, backward compatibility — will be removed in a future release)
4. Default to `sequential` if none of the above exist
## Branch Name Template
Check `.specify/extensions/git/git-config.yml` for an optional `branch_template` value. If it is empty or missing, use the default branch shape `{number}-{slug}`. If it is set, `{slug}` must not appear before `{number}`, its final path segment must start with `{number}-`, and the script expands these tokens:
- `{author}`: sanitized Git config author (`user.name`, falling back to the email local part)
- `{app}`: sanitized Spec Kit init directory name
- `{number}`: sequential number or timestamp
- `{slug}`: generated short branch slug
For monorepos, a template such as `{author}/{app}/{number}-{slug}` creates names like `jdoe/web/008-guided-tour` while preserving per-project feature numbering.
The script also accepts `branch_prefix` as a shorthand for simple namespaces; it expands to `<branch_prefix>/{number}-{slug}`.
## Execution
Generate a concise short name (2-4 words) for the branch:
@@ -54,6 +67,7 @@ Run the appropriate script based on your platform:
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
- You must only ever run this script once per feature
- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM`
- Do not manually expand `branch_template`; the script reads the git extension config and applies it consistently
## Graceful Degradation
@@ -64,5 +78,5 @@ If Git is not installed or the current directory is not a Git repository:
## Output
The script outputs JSON with:
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth`, `20260319-143022-user-auth`, or `jdoe/web/003-user-auth`)
- `FEATURE_NUM`: The numeric or timestamp prefix used

View File

@@ -22,24 +22,24 @@ Get the current branch name:
git rev-parse --abbrev-ref HEAD
```
The branch name must match one of these patterns:
The branch name's final path segment must start with one of these feature markers:
1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`)
2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`)
1. **Sequential**: `[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`, `jdoe/web/008-guided-tour`)
2. **Timestamp**: `[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`, `jdoe/web/20260319-143022-feature-name`)
## Execution
If on a feature branch (matches either pattern):
- Output: `✓ On feature branch: <branch-name>`
- Check if the corresponding spec directory exists under `specs/`:
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion, regardless of branch namespace prefixes
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion, regardless of branch namespace prefixes
- If spec directory exists: `✓ Spec directory found: <path>`
- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
If NOT on a feature branch:
- Output: `✗ Not on a feature branch. Current branch: <branch-name>`
- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name`
- Output: `Feature branches should be named like: 001-feature-name, 20260319-143022-feature-name, or <namespace>/001-feature-name`
## Graceful Degradation

View File

@@ -4,6 +4,16 @@
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
branch_numbering: sequential
# Optional branch name template. Leave empty for the default "{number}-{slug}".
# Supported tokens: {author}, {app}, {number}, {slug}
# {slug} must not appear before {number}; final path segment must start with {number}-.
# Example for monorepos: "{author}/{app}/{number}-{slug}"
branch_template: ""
# Optional shorthand namespace. Leave empty to use branch_template/default behavior.
# Example: "features/{app}" expands to "features/{app}/{number}-{slug}"
branch_prefix: ""
# Commit message used by `git commit` during repository initialization
init_commit_message: "[Spec Kit] Initial commit"

View File

@@ -4,7 +4,7 @@ extension:
id: git
name: "Git Branching Workflow"
version: "1.0.0"
description: "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection"
description: "Feature branch creation, numbering (sequential/timestamp), templating, validation, and Git remote detection"
author: spec-kit-core
repository: https://github.com/github/spec-kit
license: MIT
@@ -19,7 +19,7 @@ provides:
commands:
- name: speckit.git.feature
file: commands/speckit.git.feature.md
description: "Create a feature branch with sequential or timestamp numbering"
description: "Create a feature branch with sequential or timestamp numbering and optional templates"
- name: speckit.git.validate
file: commands/speckit.git.validate.md
description: "Validate current branch follows feature branch naming conventions"
@@ -137,4 +137,6 @@ tags:
config:
defaults:
branch_numbering: sequential
branch_template: ""
branch_prefix: ""
init_commit_message: "[Spec Kit] Initial commit"

View File

@@ -4,6 +4,16 @@
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
branch_numbering: sequential
# Optional branch name template. Leave empty for the default "{number}-{slug}".
# Supported tokens: {author}, {app}, {number}, {slug}
# {slug} must not appear before {number}; final path segment must start with {number}-.
# Example for monorepos: "{author}/{app}/{number}-{slug}"
branch_template: ""
# Optional shorthand namespace. Leave empty to use branch_template/default behavior.
# Example: "features/{app}" expands to "features/{app}/{number}-{slug}"
branch_prefix: ""
# Commit message used by `git commit` during repository initialization
init_commit_message: "[Spec Kit] Initial commit"

View File

@@ -75,6 +75,10 @@ while [ $i -le $# ]; do
echo "Environment variables:"
echo " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
echo ""
echo "Configuration:"
echo " branch_template Optional git-config.yml template with {author}, {app}, {number}, {slug}"
echo " branch_prefix Optional shorthand namespace expanded before {number}-{slug}"
echo ""
echo "Examples:"
echo " $0 'Add user authentication system' --short-name 'user-auth'"
echo " $0 'Implement OAuth2 integration for API' --number 5"
@@ -127,16 +131,28 @@ get_highest_from_specs() {
# Function to get highest number from git branches
get_highest_from_branches() {
git branch -a 2>/dev/null | sed -E 's/^[+*][[:space:]]+//; s/^[[:space:]]+//; s|^remotes/[^/]*/||' | _extract_highest_number
local scope_prefix="${1:-}"
git branch -a 2>/dev/null | sed -E 's/^[+*][[:space:]]+//; s/^[[:space:]]+//; s|^remotes/[^/]*/||' | _extract_highest_number "$scope_prefix"
}
# Extract the highest sequential feature number from a list of ref names (one per line).
_extract_highest_number() {
local scope_prefix="${1:-}"
local highest=0
while IFS= read -r name; do
[ -z "$name" ] && continue
if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0")
if [ -n "$scope_prefix" ]; then
case "$name" in
"$scope_prefix"*) name="${name#"$scope_prefix"}" ;;
*) continue ;;
esac
fi
name="${name##*/}"
if echo "$name" | grep -Eq '^[0-9]{3,}-' \
&& ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-' \
&& ! echo "$name" | grep -Eq '^[0-9]{7}-[0-9]{6}-' \
&& ! echo "$name" | grep -Eq '^[0-9]{7,8}-[0-9]{6}$'; then
number=$(echo "$name" | grep -Eo '^[0-9]{3,}-' | sed -E 's/-$//' || echo "0")
number=$((10#$number))
if [ "$number" -gt "$highest" ]; then
highest=$number
@@ -148,11 +164,12 @@ _extract_highest_number() {
# Function to get highest number from remote branches without fetching (side-effect-free)
get_highest_from_remote_refs() {
local scope_prefix="${1:-}"
local highest=0
for remote in $(git remote 2>/dev/null); do
local remote_highest
remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number)
remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number "$scope_prefix")
if [ "$remote_highest" -gt "$highest" ]; then
highest=$remote_highest
fi
@@ -165,16 +182,17 @@ get_highest_from_remote_refs() {
check_existing_branches() {
local specs_dir="$1"
local skip_fetch="${2:-false}"
local scope_prefix="${3:-}"
if [ "$skip_fetch" = true ]; then
local highest_remote=$(get_highest_from_remote_refs)
local highest_branch=$(get_highest_from_branches)
local highest_remote=$(get_highest_from_remote_refs "$scope_prefix")
local highest_branch=$(get_highest_from_branches "$scope_prefix")
if [ "$highest_remote" -gt "$highest_branch" ]; then
highest_branch=$highest_remote
fi
else
git fetch --all --prune >/dev/null 2>&1 || true
local highest_branch=$(get_highest_from_branches)
local highest_branch=$(get_highest_from_branches "$scope_prefix")
fi
local highest_spec=$(get_highest_from_specs "$specs_dir")
@@ -273,6 +291,152 @@ fi
cd "$REPO_ROOT"
SPECS_DIR="$REPO_ROOT/specs"
CONFIG_FILE="$REPO_ROOT/.specify/extensions/git/git-config.yml"
read_git_config_value() {
local key="$1"
[ -f "$CONFIG_FILE" ] || return 0
grep -E "^[[:space:]]*${key}:" "$CONFIG_FILE" 2>/dev/null \
| head -n 1 \
| sed -E "s/^[[:space:]]*${key}:[[:space:]]*//" \
| sed -E 's/[[:space:]]+#.*$//' \
| sed -E 's/^[[:space:]]+|[[:space:]]+$//g' \
| sed -E 's/^"//; s/"$//' \
| sed -E "s/^'//; s/'$//"
}
branch_token() {
local value="$1"
local fallback="$2"
local cleaned
cleaned=$(clean_branch_name "$value")
if [ -n "$cleaned" ]; then
printf '%s\n' "$cleaned"
else
printf '%s\n' "$fallback"
fi
}
get_author_token() {
local author=""
if command -v git >/dev/null 2>&1; then
author=$(git config user.name 2>/dev/null || true)
if [ -z "$author" ]; then
author=$(git config user.email 2>/dev/null | sed 's/@.*$//' || true)
fi
fi
if [ -z "$author" ]; then
author="${USER:-unknown}"
fi
branch_token "$author" "unknown"
}
get_app_token() {
branch_token "$(basename "$REPO_ROOT")" "app"
}
resolve_branch_template() {
local template
local prefix
template=$(read_git_config_value "branch_template")
if [ -n "$template" ]; then
printf '%s\n' "$template"
return
fi
prefix=$(read_git_config_value "branch_prefix")
if [ -z "$prefix" ]; then
printf '%s\n' ""
return
fi
case "$prefix" in
*/) printf '%s%s\n' "$prefix" "{number}-{slug}" ;;
*) printf '%s/%s\n' "$prefix" "{number}-{slug}" ;;
esac
}
render_branch_template() {
local template="$1"
local feature_num="$2"
local branch_suffix="$3"
local rendered="$template"
rendered=${rendered//\{author\}/$AUTHOR_TOKEN}
rendered=${rendered//\{app\}/$APP_TOKEN}
rendered=${rendered//\{number\}/$feature_num}
rendered=${rendered//\{slug\}/$branch_suffix}
printf '%s\n' "$rendered"
}
validate_branch_template() {
local template="$1"
[ -n "$template" ] || return 0
local feature_segment
feature_segment="${template##*/}"
case "$template" in
*"{number}"*) ;;
*)
>&2 echo "Error: branch_template must include the {number} token so generated branches remain valid feature branches."
exit 1
;;
esac
case "$template" in
*"{slug}"*"{number}"*)
>&2 echo "Error: branch_template must not place {slug} before {number}; use {slug} only in the final feature segment."
exit 1
;;
esac
case "$feature_segment" in
"{number}-"*) ;;
*)
>&2 echo "Error: branch_template must put {number}- at the start of the final path segment so generated branches remain valid feature branches."
exit 1
;;
esac
}
build_branch_name() {
local feature_num="$1"
local branch_suffix="$2"
if [ -n "$BRANCH_TEMPLATE" ]; then
render_branch_template "$BRANCH_TEMPLATE" "$feature_num" "$branch_suffix"
else
printf '%s-%s\n' "$feature_num" "$branch_suffix"
fi
}
branch_scope_prefix() {
local template="$1"
local prefix="$template"
[ -n "$prefix" ] || return 0
case "$prefix" in
*"{number}"*) prefix="${prefix%%\{number\}*}" ;;
*"{slug}"*) prefix="${prefix%%\{slug\}*}" ;;
*) return 0 ;;
esac
render_branch_template "$prefix" "" "$BRANCH_SUFFIX"
}
extract_feature_num_from_branch() {
local branch_name="$1"
local feature_segment="${branch_name##*/}"
local match
match=$(printf '%s\n' "$feature_segment" | grep -Eo '^[0-9]{8}-[0-9]{6}-' | head -n 1 || true)
if [ -n "$match" ]; then
printf '%s\n' "$match" | sed -E 's/-$//'
return
fi
match=$(printf '%s\n' "$feature_segment" | grep -Eo '^[0-9]+-' | head -n 1 || true)
if [ -n "$match" ]; then
printf '%s\n' "$match" | sed -E 's/-$//'
return
fi
printf '%s\n' "$branch_name"
}
AUTHOR_TOKEN=$(get_author_token)
APP_TOKEN=$(get_app_token)
BRANCH_TEMPLATE=$(resolve_branch_template)
validate_branch_template "$BRANCH_TEMPLATE"
# Function to generate branch name with stop word filtering
generate_branch_name() {
@@ -318,18 +482,8 @@ generate_branch_name() {
# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
if [ -n "${GIT_BRANCH_NAME:-}" ]; then
BRANCH_NAME="$GIT_BRANCH_NAME"
# Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
# Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^[0-9]+ pattern
if echo "$BRANCH_NAME" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]{8}-[0-9]{6}')
BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
elif echo "$BRANCH_NAME" | grep -Eq '^[0-9]+-'; then
FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]+')
BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
else
FEATURE_NUM="$BRANCH_NAME"
BRANCH_SUFFIX="$BRANCH_NAME"
fi
FEATURE_NUM=$(extract_feature_num_from_branch "$BRANCH_NAME")
BRANCH_SUFFIX="$BRANCH_NAME"
else
# Generate branch name
if [ -n "$SHORT_NAME" ]; then
@@ -347,16 +501,17 @@ else
# Determine branch prefix
if [ "$USE_TIMESTAMP" = true ]; then
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
else
BRANCH_SCOPE_PREFIX=$(branch_scope_prefix "$BRANCH_TEMPLATE")
if [ -z "$BRANCH_NUMBER" ]; then
if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true "$BRANCH_SCOPE_PREFIX")
elif [ "$DRY_RUN" = true ]; then
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$((HIGHEST + 1))
elif [ "$HAS_GIT" = true ]; then
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" false "$BRANCH_SCOPE_PREFIX")
else
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$((HIGHEST + 1))
@@ -364,7 +519,7 @@ else
fi
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
fi
fi
@@ -376,18 +531,23 @@ if [ -n "${GIT_BRANCH_NAME:-}" ] && [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH
>&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is ${BRANCH_BYTE_LEN} bytes."
exit 1
elif [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
TRUNCATED_SUFFIX="$BRANCH_SUFFIX"
while [ "$(_byte_length "$BRANCH_NAME")" -gt "$MAX_BRANCH_LENGTH" ] && [ -n "$TRUNCATED_SUFFIX" ]; do
TRUNCATED_SUFFIX="${TRUNCATED_SUFFIX%?}"
TRUNCATED_SUFFIX="${TRUNCATED_SUFFIX%-}"
BRANCH_NAME=$(build_branch_name "$FEATURE_NUM" "$TRUNCATED_SUFFIX")
done
if [ "$(_byte_length "$BRANCH_NAME")" -gt "$MAX_BRANCH_LENGTH" ]; then
>&2 echo "Error: Branch template prefix exceeds GitHub's 244-byte branch name limit."
exit 1
fi
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
ORIGINAL_BRANCH_BYTE_LEN=$(_byte_length "$ORIGINAL_BRANCH_NAME")
TRUNCATED_BRANCH_BYTE_LEN=$(_byte_length "$BRANCH_NAME")
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${ORIGINAL_BRANCH_BYTE_LEN} bytes)"
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${TRUNCATED_BRANCH_BYTE_LEN} bytes)"
fi
if [ "$DRY_RUN" != true ]; then

View File

@@ -23,8 +23,9 @@ spec_kit_effective_branch_name() {
}
# Validate that a branch name matches the expected feature branch pattern.
# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats.
# Logic aligned with scripts/bash/common.sh check_feature_branch after effective-name normalization.
# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats,
# either at the start of the branch or after path-style namespace prefixes.
# Logic aligned with the git extension's PowerShell Test-FeatureBranch twin.
check_feature_branch() {
local raw="$1"
local has_git_repo="$2"
@@ -37,16 +38,17 @@ check_feature_branch() {
local branch
branch=$(spec_kit_effective_branch_name "$raw")
local feature_segment="${branch##*/}"
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
local is_sequential=false
if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
if [[ "$feature_segment" =~ ^[0-9]{3,}- ]] && [[ ! "$feature_segment" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$feature_segment" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
is_sequential=true
fi
if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
if [[ "$is_sequential" != "true" ]] && [[ ! "$feature_segment" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
echo "ERROR: Not on a feature branch. Current branch: $raw" >&2
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, 20260319-143022-feature-name, or <prefix>/001-feature-name" >&2
return 1
fi

View File

@@ -34,6 +34,10 @@ if ($Help) {
Write-Host "Environment variables:"
Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
Write-Host ""
Write-Host "Configuration:"
Write-Host " branch_template Optional git-config.yml template with {author}, {app}, {number}, {slug}"
Write-Host " branch_prefix Optional shorthand namespace expanded before {number}-{slug}"
Write-Host ""
exit 0
}
@@ -67,11 +71,23 @@ function Get-HighestNumberFromSpecs {
}
function Get-HighestNumberFromNames {
param([string[]]$Names)
param(
[string[]]$Names,
[string]$ScopePrefix = ''
)
[long]$highest = 0
foreach ($name in $Names) {
if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') {
if ($ScopePrefix -and -not $name.StartsWith($ScopePrefix, [System.StringComparison]::Ordinal)) {
continue
}
if ($ScopePrefix) {
$name = $name.Substring($ScopePrefix.Length)
}
$name = ($name -split '/')[-1]
$hasTimestampPrefix = $name -match '^\d{8}-\d{6}-'
$hasMalformedTimestamp = ($name -match '^\d{7}-\d{6}-') -or ($name -match '^(?:\d{7}|\d{8})-\d{6}$')
if ($name -match '^(\d{3,})-' -and -not $hasTimestampPrefix -and -not $hasMalformedTimestamp) {
[long]$num = 0
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
$highest = $num
@@ -82,7 +98,7 @@ function Get-HighestNumberFromNames {
}
function Get-HighestNumberFromBranches {
param()
param([string]$ScopePrefix = '')
try {
$branches = git branch -a 2>$null
@@ -90,7 +106,7 @@ function Get-HighestNumberFromBranches {
$cleanNames = $branches | ForEach-Object {
$_.Trim() -replace '^[+*]?\s+', '' -replace '^remotes/[^/]+/', ''
}
return Get-HighestNumberFromNames -Names $cleanNames
return Get-HighestNumberFromNames -Names $cleanNames -ScopePrefix $ScopePrefix
}
} catch {
Write-Verbose "Could not check Git branches: $_"
@@ -99,6 +115,8 @@ function Get-HighestNumberFromBranches {
}
function Get-HighestNumberFromRemoteRefs {
param([string]$ScopePrefix = '')
[long]$highest = 0
try {
$remotes = git remote 2>$null
@@ -111,7 +129,7 @@ function Get-HighestNumberFromRemoteRefs {
$refNames = $refs | ForEach-Object {
if ($_ -match 'refs/heads/(.+)$') { $matches[1] }
} | Where-Object { $_ }
$remoteHighest = Get-HighestNumberFromNames -Names $refNames
$remoteHighest = Get-HighestNumberFromNames -Names $refNames -ScopePrefix $ScopePrefix
if ($remoteHighest -gt $highest) { $highest = $remoteHighest }
}
}
@@ -125,18 +143,19 @@ function Get-HighestNumberFromRemoteRefs {
function Get-NextBranchNumber {
param(
[string]$SpecsDir,
[switch]$SkipFetch
[switch]$SkipFetch,
[string]$ScopePrefix = ''
)
if ($SkipFetch) {
$highestBranch = Get-HighestNumberFromBranches
$highestRemote = Get-HighestNumberFromRemoteRefs
$highestBranch = Get-HighestNumberFromBranches -ScopePrefix $ScopePrefix
$highestRemote = Get-HighestNumberFromRemoteRefs -ScopePrefix $ScopePrefix
$highestBranch = [Math]::Max($highestBranch, $highestRemote)
} else {
try {
git fetch --all --prune 2>$null | Out-Null
} catch { }
$highestBranch = Get-HighestNumberFromBranches
$highestBranch = Get-HighestNumberFromBranches -ScopePrefix $ScopePrefix
}
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
@@ -232,6 +251,145 @@ if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) {
Set-Location $repoRoot
$specsDir = Join-Path $repoRoot 'specs'
$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
function Read-GitConfigValue {
param([string]$Key)
if (-not (Test-Path -LiteralPath $configFile -PathType Leaf)) { return '' }
$escapedKey = [regex]::Escape($Key)
foreach ($line in Get-Content -LiteralPath $configFile) {
if ($line -match "^\s*$escapedKey\s*:\s*(.*)$") {
$val = ($matches[1] -replace '\s+#.*$', '').Trim()
$val = $val -replace '^["'']', '' -replace '["'']$', ''
return $val
}
}
return ''
}
function ConvertTo-BranchToken {
param(
[string]$Value,
[string]$Fallback
)
$cleaned = ConvertTo-CleanBranchName -Name $Value
if ($cleaned) { return $cleaned }
return $Fallback
}
function Get-GitAuthorToken {
$author = ''
if (Get-Command git -ErrorAction SilentlyContinue) {
try { $author = (git config user.name 2>$null | Out-String).Trim() } catch {}
if (-not $author) {
try {
$email = (git config user.email 2>$null | Out-String).Trim()
if ($email) { $author = ($email -split '@')[0] }
} catch {}
}
}
if (-not $author) { $author = if ($env:USER) { $env:USER } elseif ($env:USERNAME) { $env:USERNAME } else { 'unknown' } }
return ConvertTo-BranchToken -Value $author -Fallback 'unknown'
}
function Get-AppToken {
return ConvertTo-BranchToken -Value (Split-Path $repoRoot -Leaf) -Fallback 'app'
}
function Resolve-BranchTemplate {
$template = Read-GitConfigValue -Key 'branch_template'
if ($template) { return $template }
$prefix = Read-GitConfigValue -Key 'branch_prefix'
if (-not $prefix) { return '' }
if ($prefix.EndsWith('/')) { return "${prefix}{number}-{slug}" }
return "$prefix/{number}-{slug}"
}
function Expand-BranchTemplate {
param(
[string]$Template,
[string]$FeatureNum,
[string]$BranchSuffix
)
$rendered = $Template.Replace('{author}', $authorToken)
$rendered = $rendered.Replace('{app}', $appToken)
$rendered = $rendered.Replace('{number}', $FeatureNum)
$rendered = $rendered.Replace('{slug}', $BranchSuffix)
return $rendered
}
function Assert-BranchTemplateValid {
param([string]$Template)
if ($Template -and -not $Template.Contains('{number}')) {
throw "branch_template must include the {number} token so generated branches remain valid feature branches."
}
if ($Template) {
$numberIndex = $Template.IndexOf('{number}', [System.StringComparison]::Ordinal)
$slugIndex = $Template.IndexOf('{slug}', [System.StringComparison]::Ordinal)
if ($slugIndex -ge 0 -and $slugIndex -lt $numberIndex) {
throw "branch_template must not place {slug} before {number}; use {slug} only in the final feature segment."
}
$featureSegment = ($Template -split '/')[-1]
if (-not $featureSegment.StartsWith('{number}-', [System.StringComparison]::Ordinal)) {
throw "branch_template must put {number}- at the start of the final path segment so generated branches remain valid feature branches."
}
}
}
function New-BranchName {
param(
[string]$FeatureNum,
[string]$BranchSuffix
)
if ($branchTemplate) {
return Expand-BranchTemplate -Template $branchTemplate -FeatureNum $FeatureNum -BranchSuffix $BranchSuffix
}
return "$FeatureNum-$BranchSuffix"
}
function Get-BranchScopePrefix {
param(
[string]$Template,
[string]$BranchSuffix
)
if (-not $Template) { return '' }
$numberIndex = $Template.IndexOf('{number}', [System.StringComparison]::Ordinal)
$slugIndex = $Template.IndexOf('{slug}', [System.StringComparison]::Ordinal)
$indexes = @($numberIndex, $slugIndex) | Where-Object { $_ -ge 0 } | Sort-Object
if (-not $indexes) { return '' }
$prefix = $Template.Substring(0, $indexes[0])
return Expand-BranchTemplate -Template $prefix -FeatureNum '' -BranchSuffix $BranchSuffix
}
function Get-FeatureNumberFromBranchName {
param([string]$BranchName)
$featureSegment = ($BranchName -split '/')[-1]
if ($featureSegment -match '^(\d{8}-\d{6})-') {
return $matches[1]
}
if ($featureSegment -match '^(\d+)-') {
return $matches[1]
}
return $BranchName
}
function Get-Utf8ByteCount {
param([string]$Value)
return [System.Text.Encoding]::UTF8.GetByteCount($Value)
}
$authorToken = Get-GitAuthorToken
$appToken = Get-AppToken
$branchTemplate = Resolve-BranchTemplate
Assert-BranchTemplateValid -Template $branchTemplate
function Get-BranchName {
param([string]$Description)
@@ -276,19 +434,11 @@ function Get-BranchName {
if ($env:GIT_BRANCH_NAME) {
$branchName = $env:GIT_BRANCH_NAME
# Check 244-byte limit (UTF-8) for override names
$branchNameUtf8ByteCount = [System.Text.Encoding]::UTF8.GetByteCount($branchName)
$branchNameUtf8ByteCount = Get-Utf8ByteCount -Value $branchName
if ($branchNameUtf8ByteCount -gt 244) {
throw "GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is $branchNameUtf8ByteCount bytes; please supply a shorter override branch name."
}
# Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
# Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^\d+ pattern
if ($branchName -match '^(\d{8}-\d{6})-') {
$featureNum = $matches[1]
} elseif ($branchName -match '^(\d+)-') {
$featureNum = $matches[1]
} else {
$featureNum = $branchName
}
$featureNum = Get-FeatureNumberFromBranchName -BranchName $branchName
} else {
if ($ShortName) {
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
@@ -303,39 +453,41 @@ if ($env:GIT_BRANCH_NAME) {
if ($Timestamp) {
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
$branchName = "$featureNum-$branchSuffix"
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
} else {
$branchScopePrefix = Get-BranchScopePrefix -Template $branchTemplate -BranchSuffix $branchSuffix
if ($Number -eq 0) {
if ($DryRun -and $hasGit) {
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch -ScopePrefix $branchScopePrefix
} elseif ($DryRun) {
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
} elseif ($hasGit) {
$Number = Get-NextBranchNumber -SpecsDir $specsDir
$Number = Get-NextBranchNumber -SpecsDir $specsDir -ScopePrefix $branchScopePrefix
} else {
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
}
}
$featureNum = ('{0:000}' -f $Number)
$branchName = "$featureNum-$branchSuffix"
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
}
}
$maxBranchLength = 244
if ($branchName.Length -gt $maxBranchLength) {
$prefixLength = $featureNum.Length + 1
$maxSuffixLength = $maxBranchLength - $prefixLength
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
if ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength) {
$originalBranchName = $branchName
$branchName = "$featureNum-$truncatedSuffix"
$truncatedSuffix = $branchSuffix
while ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength -and $truncatedSuffix.Length -gt 0) {
$truncatedSuffix = $truncatedSuffix.Substring(0, $truncatedSuffix.Length - 1) -replace '-$', ''
$branchName = New-BranchName -FeatureNum $featureNum -BranchSuffix $truncatedSuffix
}
if ((Get-Utf8ByteCount -Value $branchName) -gt $maxBranchLength) {
throw "Branch template prefix exceeds GitHub's 244-byte branch name limit."
}
Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit"
Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)"
Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)"
Write-Warning "[specify] Original: $originalBranchName ($(Get-Utf8ByteCount -Value $originalBranchName) bytes)"
Write-Warning "[specify] Truncated to: $branchName ($(Get-Utf8ByteCount -Value $branchName) bytes)"
}
if (-not $DryRun) {

View File

@@ -37,14 +37,15 @@ function Test-FeatureBranch {
$raw = $Branch
$Branch = Get-SpecKitEffectiveBranchName $raw
$featureSegment = ($Branch -split '/')[-1]
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
$hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
$isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') {
# Accept sequential prefix (3+ digits), at the start or after namespace
# segments, but exclude malformed timestamps.
$hasMalformedTimestamp = ($featureSegment -match '^[0-9]{7}-[0-9]{6}-') -or ($featureSegment -match '^(?:\d{7}|\d{8})-\d{6}$')
$isSequential = ($featureSegment -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
if (-not $isSequential -and $featureSegment -notmatch '^\d{8}-\d{6}-') {
[Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw")
[Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name")
[Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, 20260319-143022-feature-name, or <prefix>/001-feature-name")
return $false
}
return $true

View File

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

View File

@@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""Consolidated prerequisite checking script."""
from __future__ import annotations
import json
import sys
from dataclasses import dataclass
from pathlib import Path
try:
from common import FeaturePaths, format_speckit_command, get_feature_paths
except ImportError: # pragma: no cover - direct execution from unusual cwd
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import FeaturePaths, format_speckit_command, get_feature_paths
def _json_line(payload: object) -> str:
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
HELP_TEXT = """Usage: check_prerequisites.py [OPTIONS]
Consolidated prerequisite checking for Spec-Driven Development workflow.
OPTIONS:
--json Output in JSON format
--require-tasks Require tasks.md to exist (for implementation phase)
--include-tasks Include tasks.md in AVAILABLE_DOCS list
--paths-only Only output path variables (no prerequisite validation)
--help, -h Show this help message
EXAMPLES:
# Check task prerequisites (plan.md required)
./check_prerequisites.py --json
# Check implementation prerequisites (plan.md + tasks.md required)
./check_prerequisites.py --json --require-tasks --include-tasks
# Get feature paths only (no validation)
./check_prerequisites.py --paths-only
"""
@dataclass(frozen=True)
class Args:
json_mode: bool = False
require_tasks: bool = False
include_tasks: bool = False
paths_only: bool = False
def _parse_args(argv: list[str]) -> Args:
json_mode = False
require_tasks = False
include_tasks = False
paths_only = False
for arg in argv:
if arg == "--json":
json_mode = True
elif arg == "--require-tasks":
require_tasks = True
elif arg == "--include-tasks":
include_tasks = True
elif arg == "--paths-only":
paths_only = True
elif arg in {"--help", "-h"}:
sys.stdout.write(HELP_TEXT)
raise SystemExit(0)
else:
print(
f"ERROR: Unknown option '{arg}'. Use --help for usage information.",
file=sys.stderr,
)
raise SystemExit(1)
return Args(
json_mode=json_mode,
require_tasks=require_tasks,
include_tasks=include_tasks,
paths_only=paths_only,
)
def _dir_has_entries(path: Path) -> bool:
try:
return path.is_dir() and any(path.iterdir())
except OSError:
return False
def _available_docs(paths: FeaturePaths, include_tasks: bool) -> list[str]:
docs: list[str] = []
if paths.research.is_file():
docs.append("research.md")
if paths.data_model.is_file():
docs.append("data-model.md")
if _dir_has_entries(paths.contracts_dir):
docs.append("contracts/")
if paths.quickstart.is_file():
docs.append("quickstart.md")
if include_tasks and paths.tasks.is_file():
docs.append("tasks.md")
return docs
def _print_paths_only(paths: FeaturePaths, json_mode: bool) -> None:
if json_mode:
sys.stdout.write(
_json_line(
{
"REPO_ROOT": str(paths.repo_root),
"BRANCH": paths.current_branch,
"FEATURE_DIR": str(paths.feature_dir),
"FEATURE_SPEC": str(paths.feature_spec),
"IMPL_PLAN": str(paths.impl_plan),
"TASKS": str(paths.tasks),
}
)
)
return
print(f"REPO_ROOT: {paths.repo_root}")
print(f"BRANCH: {paths.current_branch}")
print(f"FEATURE_DIR: {paths.feature_dir}")
print(f"FEATURE_SPEC: {paths.feature_spec}")
print(f"IMPL_PLAN: {paths.impl_plan}")
print(f"TASKS: {paths.tasks}")
def _check_file(path: Path, description: str) -> None:
marker = "" if path.is_file() else ""
print(f" {marker} {description}")
def _check_dir(path: Path, description: str) -> None:
marker = "" if _dir_has_entries(path) else ""
print(f" {marker} {description}")
def _print_text_results(paths: FeaturePaths, include_tasks: bool) -> None:
print(f"FEATURE_DIR:{paths.feature_dir}")
print("AVAILABLE_DOCS:")
_check_file(paths.research, "research.md")
_check_file(paths.data_model, "data-model.md")
_check_dir(paths.contracts_dir, "contracts/")
_check_file(paths.quickstart, "quickstart.md")
if include_tasks:
_check_file(paths.tasks, "tasks.md")
def main(argv: list[str] | None = None) -> int:
args = _parse_args(list(argv if argv is not None else sys.argv[1:]))
try:
paths = get_feature_paths(
no_persist=args.paths_only,
script_file=Path(__file__),
)
except SystemExit as exc:
if exc.code == 0:
return 0
print("ERROR: Failed to resolve feature paths", file=sys.stderr)
return int(exc.code) if isinstance(exc.code, int) else 1
if args.paths_only:
_print_paths_only(paths, args.json_mode)
return 0
if not paths.feature_dir.is_dir():
print(f"ERROR: Feature directory not found: {paths.feature_dir}", file=sys.stderr)
print(
f"Run {format_speckit_command('specify', paths.repo_root)} first to create the feature structure.",
file=sys.stderr,
)
return 1
if not paths.impl_plan.is_file():
print(f"ERROR: plan.md not found in {paths.feature_dir}", file=sys.stderr)
print(
f"Run {format_speckit_command('plan', paths.repo_root)} first to create the implementation plan.",
file=sys.stderr,
)
return 1
if args.require_tasks and not paths.tasks.is_file():
print(f"ERROR: tasks.md not found in {paths.feature_dir}", file=sys.stderr)
print(
f"Run {format_speckit_command('tasks', paths.repo_root)} first to create the task list.",
file=sys.stderr,
)
return 1
docs = _available_docs(paths, args.include_tasks)
if args.json_mode:
sys.stdout.write(
_json_line({"FEATURE_DIR": str(paths.feature_dir), "AVAILABLE_DOCS": docs})
)
else:
_print_text_results(paths, args.include_tasks)
return 0
if __name__ == "__main__":
raise SystemExit(main())

210
scripts/python/common.py Normal file
View File

@@ -0,0 +1,210 @@
"""Shared helpers for Spec Kit Python scripts."""
from __future__ import annotations
import json
import os
import sys
from dataclasses import dataclass
from pathlib import Path
def _trim_trailing_separators(value: Path) -> str:
text = str(value)
while len(text) > 1 and text.endswith((os.sep, "/")):
text = text[:-1]
return text
def find_specify_root(start_dir: Path | None = None) -> Path | None:
current = (start_dir or Path.cwd()).resolve()
while True:
if (current / ".specify").is_dir():
return current
parent = current.parent
if parent == current:
return None
current = parent
def resolve_specify_init_dir() -> Path:
raw = os.environ.get("SPECIFY_INIT_DIR", "")
candidate = Path(raw)
if not candidate.is_absolute():
candidate = Path.cwd() / candidate
try:
init_root = candidate.resolve(strict=True)
except OSError:
print(
f"ERROR: SPECIFY_INIT_DIR does not point to an existing directory: {raw}",
file=sys.stderr,
)
raise SystemExit(1)
if not init_root.is_dir():
print(
f"ERROR: SPECIFY_INIT_DIR does not point to an existing directory: {raw}",
file=sys.stderr,
)
raise SystemExit(1)
if not (init_root / ".specify").is_dir():
print(
"ERROR: SPECIFY_INIT_DIR is not a Spec Kit project "
f"(no .specify/ directory): {init_root}",
file=sys.stderr,
)
raise SystemExit(1)
return init_root
def get_repo_root(script_file: Path | None = None) -> Path:
if os.environ.get("SPECIFY_INIT_DIR"):
return resolve_specify_init_dir()
specify_root = find_specify_root()
if specify_root is not None:
return specify_root
if script_file is not None:
script_root = find_specify_root(script_file.resolve().parent)
if script_root is not None:
return script_root
# Installed scripts live at .specify/scripts/python/<script>.py.
return script_file.resolve().parents[3]
return Path.cwd().resolve()
def get_current_branch() -> str:
return os.environ.get("SPECIFY_FEATURE", "")
def read_feature_json_feature_directory(repo_root: Path) -> str:
feature_json = repo_root / ".specify" / "feature.json"
if not feature_json.is_file():
return ""
try:
data = json.loads(feature_json.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return ""
value = data.get("feature_directory") if isinstance(data, dict) else None
return value if isinstance(value, str) else ""
def _json_dump(data: dict[str, str]) -> str:
return json.dumps(data, ensure_ascii=False, separators=(",", ":")) + "\n"
def persist_feature_json(repo_root: Path, feature_dir_value: str) -> None:
value = feature_dir_value
try:
relative = Path(value)
if relative.is_absolute():
try:
value = relative.resolve().relative_to(repo_root.resolve()).as_posix()
except ValueError:
value = str(relative)
except OSError:
pass
current = read_feature_json_feature_directory(repo_root)
if current == value:
return
specify_dir = repo_root / ".specify"
specify_dir.mkdir(parents=True, exist_ok=True)
(specify_dir / "feature.json").write_text(
_json_dump({"feature_directory": value}),
encoding="utf-8",
)
@dataclass(frozen=True)
class FeaturePaths:
repo_root: Path
current_branch: str
feature_dir: Path
feature_spec: Path
impl_plan: Path
tasks: Path
research: Path
data_model: Path
quickstart: Path
contracts_dir: Path
def get_feature_paths(
*, no_persist: bool = False, script_file: Path | None = None
) -> FeaturePaths:
repo_root = get_repo_root(script_file)
current_branch = get_current_branch()
feature_dir_raw = os.environ.get("SPECIFY_FEATURE_DIRECTORY", "")
if feature_dir_raw:
feature_dir = Path(feature_dir_raw)
if not feature_dir.is_absolute():
feature_dir = repo_root / feature_dir
if not no_persist:
persist_feature_json(repo_root, feature_dir_raw)
elif (repo_root / ".specify" / "feature.json").is_file():
stored = read_feature_json_feature_directory(repo_root)
if not stored:
print(
"ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY "
"or ensure .specify/feature.json contains feature_directory.",
file=sys.stderr,
)
raise SystemExit(1)
feature_dir = Path(stored)
if not feature_dir.is_absolute():
feature_dir = repo_root / feature_dir
else:
print(
"ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY "
"or run the specify command to create .specify/feature.json.",
file=sys.stderr,
)
raise SystemExit(1)
if not current_branch:
current_branch = Path(_trim_trailing_separators(feature_dir)).name
return FeaturePaths(
repo_root=repo_root,
current_branch=current_branch,
feature_dir=feature_dir,
feature_spec=feature_dir / "spec.md",
impl_plan=feature_dir / "plan.md",
tasks=feature_dir / "tasks.md",
research=feature_dir / "research.md",
data_model=feature_dir / "data-model.md",
quickstart=feature_dir / "quickstart.md",
contracts_dir=feature_dir / "contracts",
)
def get_invoke_separator(repo_root: Path) -> str:
integration_json = repo_root / ".specify" / "integration.json"
if not integration_json.is_file():
return "."
try:
state = json.loads(integration_json.read_text(encoding="utf-8"))
key = state.get("default_integration") or state.get("integration") or ""
settings = state.get("integration_settings")
if isinstance(key, str) and isinstance(settings, dict):
entry = settings.get(key)
if isinstance(entry, dict) and entry.get("invoke_separator") in {".", "-"}:
return entry["invoke_separator"]
except (OSError, json.JSONDecodeError):
pass
return "."
def format_speckit_command(command_name: str, repo_root: Path) -> str:
separator = get_invoke_separator(repo_root)
name = command_name.lstrip("/")
if name.startswith("speckit."):
name = name[len("speckit.") :]
elif name.startswith("speckit-"):
name = name[len("speckit-") :]
name = name.replace(".", separator)
return f"/speckit{separator}{name}"

View File

@@ -127,7 +127,14 @@ def resolve_github_release_asset_api_url(
if hostname == "github.com":
api_base = "https://api.github.com"
elif is_ghes:
authority = hostname if parsed.port is None else f"{hostname}:{parsed.port}"
# ``parsed.port`` raises ValueError on a malformed port (e.g.
# ``host:notaport``); the function's contract is to return None for
# anything it can't resolve, not to raise.
try:
port = parsed.port
except ValueError:
return None
authority = hostname if port is None else f"{hostname}:{port}"
api_base = f"{parsed.scheme}://{authority}/api/v3"
else:
return None

View File

@@ -148,7 +148,9 @@ class CommandRegistrar:
)
return f"---\n{yaml_str}---\n"
def _adjust_script_paths(self, frontmatter: dict) -> dict:
def _adjust_script_paths(
self, frontmatter: dict, extension_id: Optional[str] = None
) -> dict:
"""Normalize script paths in frontmatter to generated project locations.
Rewrites known repo-relative and top-level script paths under the
@@ -158,6 +160,7 @@ class CommandRegistrar:
Args:
frontmatter: Frontmatter dictionary
extension_id: Extension id when rendering extension-owned commands.
Returns:
Modified frontmatter with normalized project paths
@@ -168,11 +171,15 @@ class CommandRegistrar:
if isinstance(scripts, dict):
for key, script_path in scripts.items():
if isinstance(script_path, str):
scripts[key] = self.rewrite_project_relative_paths(script_path)
scripts[key] = self.rewrite_project_relative_paths(
script_path, extension_id=extension_id
)
return frontmatter
@staticmethod
def rewrite_project_relative_paths(text: str) -> str:
def rewrite_project_relative_paths(
text: str, extension_id: Optional[str] = None
) -> str:
"""Rewrite repo-relative paths to their generated project locations."""
if not isinstance(text, str) or not text:
return text
@@ -184,10 +191,18 @@ class CommandRegistrar:
):
text = text.replace(old, new)
# Only rewrite top-level style references so extension-local paths like
# ".specify/extensions/<ext>/scripts/..." remain intact.
# Only rewrite top-level style references so existing generated paths
# like ".specify/extensions/<ext>/scripts/..." remain intact. When
# rendering extension commands, top-level "scripts/" is extension-local.
scripts_replacement = (
f".specify/extensions/{extension_id}/scripts/"
if extension_id
else ".specify/scripts/"
)
text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?memory/', r"\1.specify/memory/", text)
text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?scripts/', r"\1.specify/scripts/", text)
text = re.sub(
r'(^|[\s`"\'(])(?:\.?/)?scripts/', rf"\1{scripts_replacement}", text
)
text = re.sub(
r'(^|[\s`"\'(])(?:\.?/)?templates/', r"\1.specify/templates/", text
)
@@ -312,6 +327,7 @@ class CommandRegistrar:
source_id: str,
source_file: str,
project_root: Path,
extension_id: Optional[str] = None,
) -> str:
"""Render a command override as a SKILL.md file.
@@ -331,7 +347,7 @@ class CommandRegistrar:
agent_config = self.AGENT_CONFIGS.get(agent_name, {})
if agent_config.get("extension") == "/SKILL.md":
body = self.resolve_skill_placeholders(
agent_name, frontmatter, body, project_root
agent_name, frontmatter, body, project_root, extension_id=extension_id
)
description = frontmatter.get(
@@ -393,7 +409,11 @@ class CommandRegistrar:
@staticmethod
def resolve_skill_placeholders(
agent_name: str, frontmatter: dict, body: str, project_root: Path
agent_name: str,
frontmatter: dict,
body: str,
project_root: Path,
extension_id: Optional[str] = None,
) -> str:
"""Resolve script placeholders for skills-backed agents."""
if not isinstance(frontmatter, dict):
@@ -433,7 +453,9 @@ class CommandRegistrar:
body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name)
return CommandRegistrar.rewrite_project_relative_paths(body)
return CommandRegistrar.rewrite_project_relative_paths(
body, extension_id=extension_id
)
def _convert_argument_placeholder(
self, content: str, from_placeholder: str, to_placeholder: str
@@ -528,6 +550,7 @@ class CommandRegistrar:
context_note: str = None,
_resolved_dir: Path = None,
link_outputs: bool = False,
extension_id: Optional[str] = None,
) -> List[str]:
"""Register commands for a specific agent.
@@ -545,6 +568,7 @@ class CommandRegistrar:
link_outputs: If True, write rendered output to a source-local
dev cache and symlink the agent command file to it. Falls back
to a normal file write when symlinks are unavailable.
extension_id: Extension id when rendering extension-owned commands.
Returns:
List of registered command names
@@ -614,7 +638,9 @@ class CommandRegistrar:
frontmatter[key] = core_frontmatter[key]
frontmatter.pop("strategy", None)
frontmatter = self._adjust_script_paths(frontmatter)
frontmatter = self._adjust_script_paths(
frontmatter, extension_id=extension_id
)
for key in agent_config.get("strip_frontmatter_keys", []):
frontmatter.pop(key, None)
@@ -653,10 +679,11 @@ class CommandRegistrar:
source_id,
cmd_file,
project_root,
extension_id=extension_id,
)
elif agent_config["format"] == "markdown":
body = self.resolve_skill_placeholders(
agent_name, frontmatter, body, project_root
agent_name, frontmatter, body, project_root, extension_id=extension_id
)
body = self._convert_argument_placeholder(
body, "$ARGUMENTS", agent_config["args"]
@@ -666,7 +693,7 @@ class CommandRegistrar:
)
elif agent_config["format"] == "toml":
body = self.resolve_skill_placeholders(
agent_name, frontmatter, body, project_root
agent_name, frontmatter, body, project_root, extension_id=extension_id
)
body = self._convert_argument_placeholder(
body, "$ARGUMENTS", agent_config["args"]
@@ -679,6 +706,17 @@ class CommandRegistrar:
else:
raise ValueError(f"Unsupported format: {agent_config['format']}")
# -- Post-process for non-skills agents -----------------------
_integration = None
if agent_config["extension"] != "/SKILL.md":
from specify_cli.integrations import ( # noqa: PLC0415
get_integration,
)
_integration = get_integration(agent_name)
if _integration is not None:
output = _integration.post_process_command_content(output)
dest_file = commands_dir / f"{output_name}{agent_config['extension']}"
self._ensure_inside(dest_file, commands_dir)
dest_file.parent.mkdir(parents=True, exist_ok=True)
@@ -721,6 +759,7 @@ class CommandRegistrar:
source_id,
cmd_file,
project_root,
extension_id=extension_id,
)
elif agent_config["format"] == "markdown":
alias_output = self.render_markdown_command(
@@ -738,6 +777,9 @@ class CommandRegistrar:
raise ValueError(
f"Unsupported format: {agent_config['format']}"
)
if agent_config["extension"] != "/SKILL.md" and _integration is not None:
alias_output = _integration.post_process_command_content(alias_output)
else:
# For other agents, reuse the primary output
alias_output = output
@@ -750,6 +792,7 @@ class CommandRegistrar:
source_id,
cmd_file,
project_root,
extension_id=extension_id,
)
alias_file = (
@@ -881,6 +924,7 @@ class CommandRegistrar:
context_note: str = None,
link_outputs: bool = False,
create_missing_active_skills_dir: bool = False,
extension_id: Optional[str] = None,
) -> Dict[str, List[str]]:
"""Register commands for all detected agents in the project.
@@ -897,6 +941,7 @@ class CommandRegistrar:
Recovery requires active skills mode (or Kimi's existing native
skills directory) and is skipped when safe resolution or
creation fails.
extension_id: Extension id when rendering extension-owned commands.
Returns:
Dictionary mapping agent names to list of registered commands
@@ -999,6 +1044,7 @@ class CommandRegistrar:
context_note=context_note,
_resolved_dir=agent_dir,
link_outputs=link_outputs,
extension_id=extension_id,
)
if registered:
results[agent_name] = registered
@@ -1023,6 +1069,7 @@ class CommandRegistrar:
project_root: Path,
context_note: Optional[str] = None,
link_outputs: bool = False,
extension_id: Optional[str] = None,
) -> Dict[str, List[str]]:
"""Register commands for all non-skill agents in the project.
@@ -1038,6 +1085,7 @@ class CommandRegistrar:
context_note: Custom context comment for markdown output
link_outputs: If True, create dev-mode symlinks for rendered
command files when supported by the OS.
extension_id: Extension id when rendering extension-owned commands.
Returns:
Dictionary mapping agent names to list of registered commands
@@ -1066,6 +1114,7 @@ class CommandRegistrar:
context_note=context_note,
_resolved_dir=agent_dir,
link_outputs=link_outputs,
extension_id=extension_id,
)
if registered:
results[agent_name] = registered

View File

@@ -73,6 +73,13 @@ class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
self._redirect_validator = redirect_validator
def redirect_request(self, req, fp, code, msg, headers, newurl):
try:
new_parsed = urlparse(newurl)
except ValueError as exc:
# Malformed redirect target (e.g. unterminated IPv6 bracket).
# Surface as URLError so callers' download error handling applies.
raise urllib.error.URLError(f"malformed redirect URL: {exc}") from exc
if self._redirect_validator is not None:
self._redirect_validator(req.full_url, newurl)
@@ -83,7 +90,6 @@ class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
new_req = super().redirect_request(req, fp, code, msg, headers, newurl)
if new_req is not None:
old_scheme = urlparse(req.full_url).scheme
new_parsed = urlparse(newurl)
hostname = (new_parsed.hostname or "").lower()
is_https_downgrade = old_scheme == "https" and new_parsed.scheme != "https"
if _hostname_in_hosts(hostname, self._hosts) and not is_https_downgrade:

View File

@@ -95,7 +95,11 @@ def _is_local_path(url: str) -> bool:
"""True when *url* denotes a local filesystem path rather than a URL."""
if _WINDOWS_DRIVE_RE.match(url):
return True
scheme = urlparse(url).scheme.lower()
try:
scheme = urlparse(url).scheme.lower()
except ValueError:
# Malformed URLs (e.g. an unclosed IPv6 bracket) are not local paths.
return False
return scheme not in _REMOTE_SCHEMES
@@ -137,7 +141,10 @@ def add_source(
url = url.strip()
if not url:
raise BundlerError("A catalog url is required.")
parsed = urlparse(url)
try:
parsed = urlparse(url)
except ValueError as exc:
raise BundlerError(f"Invalid catalog url: '{url}'.") from exc
if not (parsed.scheme or parsed.path):
raise BundlerError(f"Invalid catalog url: '{url}'.")
# Reject unsupported URL schemes (e.g. ssh://, ftp://) up front so they are
@@ -148,6 +155,20 @@ def add_source(
f"Unsupported catalog url scheme '{parsed.scheme}://' in '{url}'. "
"Use http(s)://, file://, builtin://, or a local path."
)
if parsed.scheme.lower() in {"http", "https"}:
# Mirror specify_cli.catalogs._validate_catalog_url (#3209/#3210):
# HTTPS only (HTTP just for localhost), and check hostname, not
# netloc — netloc is truthy for host-less URLs like "https://:8080"
# or "https://user@". Validating here keeps junk out of
# bundle-catalogs.yml instead of failing later at fetch time.
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme.lower() != "https" and not is_localhost:
raise BundlerError(
f"Catalog url must use HTTPS (got {parsed.scheme}://). "
"HTTP is only allowed for localhost."
)
if not parsed.hostname:
raise BundlerError(f"Catalog url must be a valid URL with a host: {url}")
url = _canonicalize_url(url)
install_policy = InstallPolicy.parse(policy)

View File

@@ -75,7 +75,11 @@ def _validate_remote_url(source_id: str, url: str) -> None:
f"Catalog '{source_id}' URL must use HTTPS (got {parsed.scheme}://). "
"HTTP is only allowed for localhost."
)
if not parsed.netloc:
# Check hostname, not netloc: netloc is truthy for host-less URLs like
# "https://:8080" or "https://user@...", so requiring netloc would let
# those through even though they carry no host. hostname is None in those
# cases. Mirrors the fix in ``specify_cli.catalogs`` (#3210).
if not parsed.hostname:
raise BundlerError(
f"Catalog '{source_id}' URL must be a valid URL with a host: {url}"
)

View File

@@ -88,17 +88,25 @@ class CatalogStack:
Results are sorted by bundle id for deterministic output.
"""
needle = query.strip().lower()
seen: dict[str, ResolvedBundle] = {}
# Resolve each id to its highest-precedence entry FIRST, then filter by
# the query. Claiming an id only when it matches would let a lower-
# precedence entry with the same id surface when the highest-precedence
# one doesn't match the query — but that shadowed entry is not what
# `resolve()`/install would use, so search would advertise a bundle
# (name, version, author) the user can never actually get.
resolved: dict[str, ResolvedBundle] = {}
for source in self._sources:
for bundle_id, entry in self._entries_for(source).items():
if bundle_id in seen:
if bundle_id in resolved:
continue
if needle and not _matches(entry, needle):
continue
seen[bundle_id] = ResolvedBundle(
resolved[bundle_id] = ResolvedBundle(
entry=entry.with_provenance(source), source=source
)
return [seen[k] for k in sorted(seen)]
return [
resolved[k]
for k in sorted(resolved)
if not needle or _matches(resolved[k].entry, needle)
]
def _matches(entry: CatalogEntry, needle: str) -> bool:

View File

@@ -130,6 +130,28 @@ def install_bundle(
done.append(component)
result.installed.append(component)
contributed.append(component)
# On update (refresh), uninstall components this bundle used to own
# that the new version no longer ships. Otherwise they are dropped
# from the record below (contributed only holds plan.components) yet
# left on disk — permanently orphaned, since no bundle record can
# ever remove them. A stale component still owned by another bundle
# is kept installed and simply de-attributed here (it stays in that
# bundle's record). Mirrors remove_bundle's refcount logic.
if refresh and existing is not None:
planned = {(c.kind, c.id) for c in plan.components}
still_needed = components_still_needed(
records, exclude_bundle_id=plan.bundle_id
)
for component in existing.contributed_components:
key = (component.kind, component.id)
if key in planned:
continue
if key in still_needed:
continue
if installer.is_installed(project_root, component):
installer.remove(project_root, component)
result.uninstalled.append(component)
except BundlerError:
_rollback(project_root, installer, done)
raise

View File

@@ -1075,9 +1075,11 @@ class ExtensionManager:
pass # best-effort cleanup
continue
frontmatter, body = registrar.parse_frontmatter(content)
frontmatter = registrar._adjust_script_paths(frontmatter)
frontmatter = registrar._adjust_script_paths(
frontmatter, extension_id=manifest.id
)
body = registrar.resolve_skill_placeholders(
selected_ai, frontmatter, body, self.project_root
selected_ai, frontmatter, body, self.project_root, extension_id=manifest.id
)
original_desc = frontmatter.get("description", "")
@@ -1958,6 +1960,7 @@ class CommandRegistrar:
project_root,
context_note=context_note,
link_outputs=link_outputs,
extension_id=manifest.id,
)
def register_commands_for_all_agents(
@@ -1978,6 +1981,7 @@ class CommandRegistrar:
context_note=context_note,
link_outputs=link_outputs,
create_missing_active_skills_dir=create_missing_active_skills_dir,
extension_id=manifest.id,
)
def unregister_commands(
@@ -2688,7 +2692,12 @@ class ConfigManager:
return {}
try:
return yaml.safe_load(file_path.read_text(encoding="utf-8")) or {}
data = yaml.safe_load(file_path.read_text(encoding="utf-8"))
# Coerce a non-mapping root (list/scalar, or None for an empty
# file) to {} so callers that iterate/merge the result — e.g.
# _merge_configs' .items() — never crash. Mirrors the same
# non-dict-root guard in get_project_config().
return data if isinstance(data, dict) else {}
except (yaml.YAMLError, OSError, UnicodeError):
return {}

View File

@@ -426,7 +426,11 @@ def extension_add(
if from_url and not dev:
from urllib.parse import urlparse
parsed = urlparse(from_url)
try:
parsed = urlparse(from_url)
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")
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):

View File

@@ -89,7 +89,11 @@ class AgyIntegration(SkillsIntegration):
output_json: bool = True,
) -> list[str] | None:
# agy does not support --model or JSON output; both params are ignored
return [self._resolve_executable(), "--print", prompt]
args = [self._resolve_executable(), "--print", prompt]
# Honor SPECKIT_INTEGRATION_AGY_EXTRA_ARGS (operator-supplied flags),
# appended after the positional prompt like the devin integration.
self._apply_extra_args_env_var(args)
return args
def setup(
self,

View File

@@ -123,6 +123,19 @@ class IntegrationBase(ABC):
integration that sets this flag.
"""
def post_process_command_content(self, content: str) -> str:
"""Transform command content after format rendering.
Called by ``register_commands()`` for non-skills format types
(Markdown, TOML, YAML) after the command has been rendered into
its target format and before writing to disk. Skills-format
agents use ``post_process_skill_content()`` instead.
Subclasses may override to inject agent-specific content.
The default implementation returns *content* unchanged.
"""
return content
# -- Public API -------------------------------------------------------
@classmethod
@@ -1178,12 +1191,18 @@ class YamlIntegration(IntegrationBase):
default_flow_style=False,
).strip()
# Indent the body for YAML block scalar
# Indent the body for YAML block scalar. Use an explicit indentation
# indicator ("|2") rather than a bare "|": YAML infers a plain block
# scalar's indentation from its first non-empty line, so a body whose
# first line is itself indented (e.g. a markdown code block or a nested
# list item) would make the parser expect that deeper indent for the
# whole block and reject the later, less-indented lines. Pinning the
# indent to 2 keeps the recipe parseable whatever the body looks like.
indented = "\n".join(f" {line}" for line in body.split("\n"))
lines = [
header_yaml,
"prompt: |",
"prompt: |2",
indented,
"",
f"# Source: {source_id}",

View File

@@ -75,7 +75,15 @@ class CursorAgentIntegration(SkillsIntegration):
either drops tool calls or exits non-zero on the first approval
prompt.
"""
args = [self.key, "-p", "--trust", "--approve-mcps", "--force", prompt]
args = [
self._resolve_executable(),
"-p",
"--trust",
"--approve-mcps",
"--force",
prompt,
]
self._apply_extra_args_env_var(args)
if model:
args.extend(["--model", model])
if output_json:

View File

@@ -253,6 +253,11 @@ class HermesIntegration(SkillsIntegration):
"""
args = [self._resolve_executable(), "chat", "-Q"]
# Operator-supplied SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS go here —
# after the base command but before Spec Kit's canonical -m/--json/-s/-q
# flags — so they can't displace or clobber them (mirrors opencode).
self._apply_extra_args_env_var(args)
if model:
args.extend(["-m", model])
if output_json:

View File

@@ -309,7 +309,14 @@ class IntegrationManifest:
if abs_path.is_symlink() or not abs_path.is_file():
modified.append(rel)
continue
if _sha256(abs_path) != expected_hash:
try:
changed = _sha256(abs_path) != expected_hash
except OSError:
# Unreadable regular file (e.g. permission denied): treat as
# modified, consistent with the symlink / non-regular-file
# handling above, rather than letting the OSError escape.
changed = True
if changed:
modified.append(rel)
return modified
@@ -358,9 +365,17 @@ class IntegrationManifest:
skipped.append(path)
continue
else:
if not force and _sha256(path) != expected_hash:
skipped.append(path)
continue
if not force:
try:
matches = _sha256(path) == expected_hash
except OSError:
# Unreadable: can't verify it's ours, so preserve it
# (mirrors the path.unlink() OSError guard below).
skipped.append(path)
continue
if not matches:
skipped.append(path)
continue
try:
path.unlink()
except OSError:

View File

@@ -104,7 +104,13 @@ def preset_add(
from ipaddress import ip_address
from urllib.parse import urlparse as _urlparse
_parsed = _urlparse(from_url)
try:
_parsed = _urlparse(from_url)
except ValueError:
from rich.markup import escape as _escape_markup
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}")
raise typer.Exit(1)
def _is_allowed_download_url(parsed_url):
host = parsed_url.hostname
@@ -135,7 +141,9 @@ def preset_add(
)
raise typer.Exit(1)
console.print(f"Installing preset from [cyan]{from_url}[/cyan]...")
from rich.markup import escape as _esc
console.print(f"Installing preset from [cyan]{_esc(from_url)}[/cyan]...")
import urllib.error
import tempfile
import shutil

View File

@@ -50,7 +50,17 @@ workflow_step_catalog_app = typer.Typer(
workflow_step_app.add_typer(workflow_step_catalog_app, name="catalog")
def _parse_input_values(input_values: list[str] | None) -> dict[str, Any]:
def _error_console(json_output: bool):
"""Console for error text: stderr under ``--json`` so the JSON stdout
stream stays parseable, the normal console otherwise. Mirrors the
stderr-only error routing already used by ``specify bundle``.
"""
return err_console if json_output else console
def _parse_input_values(
input_values: list[str] | None, *, json_output: bool = False
) -> dict[str, Any]:
"""Parse repeated ``key=value`` CLI inputs into a dict.
Shared by ``workflow run`` and ``workflow resume``. Exits with an error
@@ -59,7 +69,9 @@ def _parse_input_values(input_values: list[str] | None) -> dict[str, Any]:
inputs: dict[str, Any] = {}
for kv in input_values or []:
if "=" not in kv:
console.print(f"[red]Error:[/red] Invalid input format: {kv!r} (expected key=value)")
_error_console(json_output).print(
f"[red]Error:[/red] Invalid input format: {kv!r} (expected key=value)"
)
raise typer.Exit(1)
key, _, value = kv.partition("=")
inputs[key.strip()] = value.strip()
@@ -335,25 +347,26 @@ def workflow_run(
if not json_output:
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
err = _error_console(json_output)
try:
definition = engine.load_workflow(source_path if is_file_source else source)
except FileNotFoundError:
console.print(f"[red]Error:[/red] Workflow not found: {source}")
err.print(f"[red]Error:[/red] Workflow not found: {source}")
raise typer.Exit(1)
except ValueError as exc:
console.print(f"[red]Error:[/red] Invalid workflow: {exc}")
err.print(f"[red]Error:[/red] Invalid workflow: {exc}")
raise typer.Exit(1)
# Validate
errors = engine.validate(definition)
if errors:
console.print("[red]Workflow validation failed:[/red]")
for err in errors:
console.print(f"{err}")
err.print("[red]Workflow validation failed:[/red]")
for verr in errors:
err.print(f"{verr}")
raise typer.Exit(1)
# Parse inputs
inputs = _parse_input_values(input_values)
inputs = _parse_input_values(input_values, json_output=json_output)
if not json_output:
console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})")
@@ -363,10 +376,10 @@ def workflow_run(
with _stdout_to_stderr_when(json_output):
state = engine.execute(definition, inputs)
except ValueError as exc:
console.print(f"[red]Error:[/red] {exc}")
err.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
except Exception as exc:
console.print(f"[red]Workflow failed:[/red] {exc}")
err.print(f"[red]Workflow failed:[/red] {exc}")
raise typer.Exit(1)
if json_output:
@@ -411,19 +424,20 @@ def workflow_resume(
if not json_output:
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
inputs = _parse_input_values(input_values)
inputs = _parse_input_values(input_values, json_output=json_output)
err = _error_console(json_output)
try:
with _stdout_to_stderr_when(json_output):
state = engine.resume(run_id, inputs or None)
except FileNotFoundError:
console.print(f"[red]Error:[/red] Run not found: {run_id}")
err.print(f"[red]Error:[/red] Run not found: {run_id}")
raise typer.Exit(1)
except ValueError as exc:
console.print(f"[red]Error:[/red] {exc}")
err.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
except Exception as exc:
console.print(f"[red]Resume failed:[/red] {exc}")
err.print(f"[red]Resume failed:[/red] {exc}")
raise typer.Exit(1)
if json_output:
@@ -617,7 +631,11 @@ def workflow_add(
from urllib.parse import urlparse
from specify_cli.authentication.http import open_url as _open_url
parsed_src = urlparse(source)
try:
parsed_src = urlparse(source)
except ValueError:
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(source)}")
raise typer.Exit(1)
src_host = parsed_src.hostname or ""
src_loopback = src_host == "localhost"
if not src_loopback:

View File

@@ -183,6 +183,65 @@ def _is_single_expression(stripped: str) -> bool:
return True
def _interpolate_expressions(template: str, namespace: dict[str, Any]) -> str:
"""Substitute every top-level ``{{ ... }}`` block in *template*, quote-aware.
Walks the template and, for each block, finds the closing ``}}`` that lies
outside string literals -- the same quote-scanning used by
``_is_single_expression``. This keeps a literal ``}}`` inside a string
argument (e.g. ``| default('}}')``) from prematurely closing a block.
``_EXPR_PATTERN.sub`` cannot do this: its non-greedy body stops at the first
``}}`` regardless of quoting, so in a multi-expression template any block
whose argument contains a literal ``}}`` is captured truncated and mis-parsed
(raising ``ValueError`` from the filter parser). #3208/#3228 fixed exactly
this for the single-expression fast path but left the interpolation path on
the old regex.
"""
out: list[str] = []
i = 0
n = len(template)
while i < n:
start = template.find("{{", i)
if start == -1:
out.append(template[i:])
break
out.append(template[i:start])
# Scan for the block-closing ``}}`` that is outside any string literal.
j = start + 2
quote: str | None = None
close = -1
while j < n:
ch = template[j]
if quote is not None:
if ch == quote:
quote = None
elif ch in ("'", '"'):
quote = ch
elif ch == "}" and j + 1 < n and template[j + 1] == "}":
close = j
break
j += 1
if close == -1:
# No quote-aware close. Two sub-cases, both kept identical to the old
# regex so a malformed template is never silently hidden:
# * a raw ``}}`` still exists in the tail (e.g. an unbalanced quote
# in a filter arg swallowed the real delimiter) -- fall back to
# that first raw ``}}`` and evaluate, letting the parser surface
# a ValueError just as ``_EXPR_PATTERN.sub`` would have.
# * no ``}}`` at all -- a genuinely unterminated ``{{``; leave the
# tail verbatim, again matching the regex (which cannot match).
raw_close = template.find("}}", start + 2)
if raw_close == -1:
out.append(template[start:])
break
close = raw_close
val = _evaluate_simple_expression(template[start + 2:close].strip(), namespace)
out.append(str(val) if val is not None else "")
i = close + 2
return "".join(out)
def _split_top_level_commas(text: str) -> list[str]:
"""Split *text* on commas that are not inside quotes or nested brackets.
@@ -408,15 +467,34 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
return _resolve_dot_path(namespace, expr)
def _coerce_number(value: Any) -> Any:
"""Return *value* as int/float if it is a numeric string, else unchanged."""
if isinstance(value, str):
try:
return float(value) if "." in value else int(value)
except ValueError:
return value
return value
def _safe_compare(left: Any, right: Any, op: str) -> bool:
"""Safely compare two values, coercing types when possible."""
try:
if isinstance(left, str):
left = float(left) if "." in left else int(left)
if isinstance(right, str):
right = float(right) if "." in right else int(right)
except (ValueError, TypeError):
return False
"""Compare two values for ordering, coercing numeric strings when possible.
Numeric coercion is applied only when *both* operands look numeric, so a
pair like ``"10"`` and ``"9"`` compares as numbers (10 > 9). When either
side is a non-numeric string, both fall back to their original values and
are compared directly -- so ordinary strings (dates, semver-ish tags,
names) compare lexicographically the way Python does, instead of every
such comparison silently returning ``False`` after a failed int()/float()
coercion. A genuinely incomparable pair (e.g. number vs non-numeric string)
raises ``TypeError`` and yields ``False``.
"""
cl, cr = _coerce_number(left), _coerce_number(right)
# Only use the coerced numbers when both converted; otherwise a numeric
# string paired with a plain string would become an int-vs-str mismatch
# (always False) rather than a lexicographic string comparison.
if isinstance(cl, (int, float)) and isinstance(cr, (int, float)):
left, right = cl, cr
try:
if op == ">":
return left > right # type: ignore[operator]
@@ -472,12 +550,11 @@ def evaluate_expression(template: str, context: Any) -> Any:
if _is_single_expression(stripped):
return _evaluate_simple_expression(stripped[2:-2].strip(), namespace)
# Multi-expression: string interpolation
def _replacer(m: re.Match[str]) -> str:
val = _evaluate_simple_expression(m.group(1).strip(), namespace)
return str(val) if val is not None else ""
return _EXPR_PATTERN.sub(_replacer, template)
# Multi-expression: interpolate each block inline. Uses a quote-aware scan
# (not ``_EXPR_PATTERN.sub``) so a literal ``}}`` inside a string argument
# in any block does not close that block early -- matching the handling the
# single-expression path above already got in #3208/#3228.
return _interpolate_expressions(template, namespace)
def evaluate_condition(condition: str, context: Any) -> bool:

View File

@@ -58,4 +58,13 @@ class FanInStep(StepBase):
f"Fan-in step {config.get('id', '?')!r}: "
f"'wait_for' must be a non-empty list of step IDs."
)
output = config.get("output")
if output is not None and not isinstance(output, dict):
# execute() silently coerces a non-mapping output to {}, so the
# author's declared aggregation keys would vanish with no error.
# Reject at validation, mirroring the command-step (#3262) fix.
errors.append(
f"Fan-in step {config.get('id', '?')!r}: 'output' must be a "
f"mapping of key -> expression, got {type(output).__name__}."
)
return errors

View File

@@ -73,7 +73,14 @@ class GateStep(StepBase):
choice = self._prompt(self._compose_prompt(message, show_file), options)
output["choice"] = choice
if choice in ("reject", "abort"):
# Match rejection case-insensitively. ``_prompt`` echoes the option's
# original casing, and ``validate`` accepts a reject option
# case-insensitively (``o.lower() in {"reject", "abort"}``), so a gate
# authored as ``options: [Approve, Reject]`` passes validation. Comparing
# ``choice`` case-sensitively here would then treat a ``Reject`` pick as
# approval and silently skip the abort — the reject path must agree with
# the check that let the option through.
if choice.lower() in ("reject", "abort"):
if on_reject == "abort":
output["aborted"] = True
return StepResult(

View File

@@ -90,6 +90,16 @@ class ShellStep(StepBase):
errors.append(
f"Shell step {config.get('id', '?')!r} is missing 'run' field."
)
elif not isinstance(config["run"], str):
# execute() str()-coerces run and invokes it under shell=True, so a
# null or list 'run' would run the Python repr ('None', "['echo']")
# as a command. Reject non-strings at validation, mirroring the
# command-step input/options and gate options type checks. An
# expression like "{{ ... }}" is still a str, so it stays valid.
errors.append(
f"Shell step {config.get('id', '?')!r}: 'run' must be a string, "
f"got {type(config['run']).__name__}."
)
output_format = config.get("output_format")
if output_format is not None and output_format != "json":
errors.append(

View File

@@ -122,10 +122,10 @@ def _run_bash(script_name: str, cwd: Path, *args: str, env_extra: dict | None =
)
def _run_pwsh(script_name: str, cwd: Path, *args: str) -> subprocess.CompletedProcess:
def _run_pwsh(script_name: str, cwd: Path, *args: str, env_extra: dict | None = None) -> subprocess.CompletedProcess:
"""Run an extension PowerShell script."""
script = cwd / ".specify" / "extensions" / "git" / "scripts" / "powershell" / script_name
env = {**os.environ, **_GIT_ENV}
env = {**os.environ, **_GIT_ENV, **(env_extra or {})}
return subprocess.run(
["pwsh", "-NoProfile", "-File", str(script), *args],
cwd=cwd,
@@ -320,6 +320,15 @@ class TestCreateFeatureBash:
assert rt.returncode == 0, rt.stderr
assert "HAS_GIT" not in rt.stdout
def test_help_documents_branch_prefix(self, tmp_path: Path):
"""--help documents both template config knobs."""
project = _setup_project(tmp_path)
result = _run_bash("create-new-feature-branch.sh", project, "--help")
assert result.returncode == 0
assert "branch_template" in result.stdout
assert "branch_prefix" in result.stdout
def test_branch_name_short_word_case_sensitivity(self, tmp_path: Path):
"""A short word is dropped from the derived branch name unless it appears
as an acronym in UPPERCASE in the description (case-sensitive, must match the
@@ -363,6 +372,183 @@ class TestCreateFeatureBash:
data = json.loads(result.stdout)
assert data["FEATURE_NUM"] == "003"
def test_branch_template_adds_author_and_app_namespace(self, tmp_path: Path):
"""branch_template namespaces generated branch names for monorepos."""
project = _setup_project(tmp_path / "app-a")
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "--short-name", "guided-tour", "Add guided tour",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/app-a/001-guided-tour"
assert data["FEATURE_NUM"] == "001"
def test_branch_prefix_shorthand_adds_namespace(self, tmp_path: Path):
"""branch_prefix expands to a namespace before the default branch shape."""
project = _setup_project(tmp_path / "app-a")
_write_config(project, 'branch_prefix: "features/{app}"\n')
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "--short-name", "guided-tour", "Add guided tour",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "features/app-a/001-guided-tour"
assert data["FEATURE_NUM"] == "001"
def test_branch_template_scopes_number_after_numeric_app_namespace(self, tmp_path: Path):
"""Numeric-looking namespace segments must not be parsed as feature numbers."""
project = _setup_project(tmp_path / "2026-app")
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
subprocess.run(["git", "branch", "jdoe/2026-app/007-existing"], cwd=project, check=True)
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "--dry-run", "--short-name", "next", "Next feature",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/2026-app/008-next"
assert data["FEATURE_NUM"] == "008"
def test_branch_template_ignores_malformed_timestamp_branches_when_numbering(self, tmp_path: Path):
"""Malformed timestamp-looking branches must not inflate sequential numbering."""
project = _setup_project(tmp_path / "app-a")
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
subprocess.run(["git", "branch", "jdoe/app-a/007-existing"], cwd=project, check=True)
subprocess.run(["git", "branch", "jdoe/app-a/2026031-143022-invalid"], cwd=project, check=True)
subprocess.run(["git", "branch", "jdoe/app-a/20260319-143022"], cwd=project, check=True)
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "--dry-run", "--short-name", "next", "Next feature",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/app-a/008-next"
assert data["FEATURE_NUM"] == "008"
def test_branch_template_scopes_existing_branch_numbers(self, tmp_path: Path):
"""Templated branch numbering ignores branches outside the current namespace."""
project = _setup_project(tmp_path / "app-a")
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
subprocess.run(["git", "branch", "jdoe/app-a/007-existing"], cwd=project, check=True)
subprocess.run(["git", "branch", "jdoe/app-b/010-other-app"], cwd=project, check=True)
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "--dry-run", "--short-name", "next", "Next feature",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/app-a/008-next"
assert data["FEATURE_NUM"] == "008"
def test_branch_template_requires_number_token(self, tmp_path: Path):
"""Configured templates must include {number} so generated branches validate."""
project = _setup_project(tmp_path / "app-a")
_write_config(project, 'branch_template: "{author}/{app}/{slug}"\n')
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "--dry-run", "--short-name", "guided-tour", "Add guided tour",
)
assert result.returncode != 0
assert "branch_template" in result.stderr
assert "{number}" in result.stderr
def test_branch_template_requires_feature_segment_to_start_with_number(self, tmp_path: Path):
"""Templates must render a final path segment that validation accepts."""
project = _setup_project(tmp_path / "app-a")
_write_config(project, 'branch_template: "{author}/{app}/feature-{number}"\n')
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "--dry-run", "--short-name", "guided-tour", "Add guided tour",
)
assert result.returncode != 0
assert "branch_template" in result.stderr
assert "{number}-" in result.stderr
def test_branch_template_rejects_slug_before_number(self, tmp_path: Path):
"""{slug} before {number} would make branch-number scanning slug-specific."""
project = _setup_project(tmp_path / "app-a")
_write_config(project, 'branch_template: "{author}/{slug}/{number}-{slug}"\n')
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "--dry-run", "--short-name", "guided-tour", "Add guided tour",
)
assert result.returncode != 0
assert "branch_template" in result.stderr
assert "{slug}" in result.stderr
assert "{number}" in result.stderr
def test_git_branch_name_override_extracts_number_after_namespace(self, tmp_path: Path):
"""GIT_BRANCH_NAME extracts FEATURE_NUM from a namespaced branch."""
project = _setup_project(tmp_path / "app-a")
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "Ignored description",
env_extra={"GIT_BRANCH_NAME": "jdoe/app-a/042-custom-branch"},
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/app-a/042-custom-branch"
assert data["FEATURE_NUM"] == "042"
def test_git_branch_name_override_ignores_numeric_namespace_segments(self, tmp_path: Path):
"""GIT_BRANCH_NAME uses the feature segment, not numeric namespace segments."""
project = _setup_project(tmp_path / "2026-app")
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "Ignored description",
env_extra={"GIT_BRANCH_NAME": "jdoe/2026-app/042-custom-branch"},
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/2026-app/042-custom-branch"
assert data["FEATURE_NUM"] == "042"
def test_git_branch_name_override_without_feature_marker_preserves_full_name(self, tmp_path: Path):
"""GIT_BRANCH_NAME without a feature marker keeps the historical FEATURE_NUM."""
project = _setup_project(tmp_path / "app-a")
result = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "Ignored description",
env_extra={"GIT_BRANCH_NAME": "jdoe/app-a/custom-branch"},
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/app-a/custom-branch"
assert data["FEATURE_NUM"] == "jdoe/app-a/custom-branch"
def test_truncation_warning_reports_utf8_bytes(self):
"""Bash truncation warnings should use the same byte counter as enforcement."""
source = (EXT_BASH / "create-new-feature-branch.sh").read_text(encoding="utf-8")
assert '_byte_length "$ORIGINAL_BRANCH_NAME"' in source
assert '_byte_length "$BRANCH_NAME"' in source
def test_dry_run_counts_branches_checked_out_in_worktrees(self, tmp_path: Path):
"""Branches checked out in sibling worktrees still reserve their prefix."""
project = _setup_project(tmp_path / "project")
@@ -484,6 +670,15 @@ class TestCreateFeaturePowerShell:
assert rt.returncode == 0, rt.stderr
assert "HAS_GIT" not in rt.stdout
def test_help_documents_branch_prefix(self, tmp_path: Path):
"""-Help documents both template config knobs."""
project = _setup_project(tmp_path)
result = _run_pwsh("create-new-feature-branch.ps1", project, "-Help")
assert result.returncode == 0
assert "branch_template" in result.stdout
assert "branch_prefix" in result.stdout
def test_branch_name_short_word_case_sensitivity(self, tmp_path: Path):
"""PowerShell must match the bash twin: a short word is dropped unless it
appears as an acronym in UPPERCASE (case-sensitive -cmatch, not -match)."""
@@ -525,6 +720,176 @@ class TestCreateFeaturePowerShell:
data = json.loads(result.stdout)
assert re.match(r"^\d{8}-\d{6}-feat$", data["BRANCH_NAME"])
def test_branch_template_adds_author_and_app_namespace(self, tmp_path: Path):
"""PowerShell supports branch_template namespaces."""
project = _setup_project(tmp_path / "app-a")
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "-ShortName", "guided-tour", "Add guided tour",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/app-a/001-guided-tour"
assert data["FEATURE_NUM"] == "001"
def test_branch_prefix_shorthand_adds_namespace(self, tmp_path: Path):
"""PowerShell supports branch_prefix shorthand namespaces."""
project = _setup_project(tmp_path / "app-a")
_write_config(project, 'branch_prefix: "features/{app}"\n')
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "-ShortName", "guided-tour", "Add guided tour",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "features/app-a/001-guided-tour"
assert data["FEATURE_NUM"] == "001"
def test_branch_template_scopes_number_after_numeric_app_namespace(self, tmp_path: Path):
"""PowerShell ignores numeric-looking namespace segments when numbering."""
project = _setup_project(tmp_path / "2026-app")
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
subprocess.run(["git", "branch", "jdoe/2026-app/007-existing"], cwd=project, check=True)
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "-DryRun", "-ShortName", "next", "Next feature",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/2026-app/008-next"
assert data["FEATURE_NUM"] == "008"
def test_branch_template_ignores_malformed_timestamp_branches_when_numbering(self, tmp_path: Path):
"""PowerShell skips malformed timestamp-looking refs during sequential numbering."""
project = _setup_project(tmp_path / "app-a")
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
subprocess.run(["git", "branch", "jdoe/app-a/007-existing"], cwd=project, check=True)
subprocess.run(["git", "branch", "jdoe/app-a/2026031-143022-invalid"], cwd=project, check=True)
subprocess.run(["git", "branch", "jdoe/app-a/20260319-143022"], cwd=project, check=True)
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "-DryRun", "-ShortName", "next", "Next feature",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/app-a/008-next"
assert data["FEATURE_NUM"] == "008"
def test_branch_template_scopes_existing_branch_numbers(self, tmp_path: Path):
"""PowerShell templated numbering ignores branches outside the namespace."""
project = _setup_project(tmp_path / "app-a")
subprocess.run(["git", "config", "user.name", "jdoe"], cwd=project, check=True)
_write_config(project, 'branch_template: "{author}/{app}/{number}-{slug}"\n')
subprocess.run(["git", "branch", "jdoe/app-a/007-existing"], cwd=project, check=True)
subprocess.run(["git", "branch", "jdoe/app-b/010-other-app"], cwd=project, check=True)
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "-DryRun", "-ShortName", "next", "Next feature",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/app-a/008-next"
assert data["FEATURE_NUM"] == "008"
def test_branch_template_requires_number_token(self, tmp_path: Path):
"""PowerShell rejects templates without {number}."""
project = _setup_project(tmp_path / "app-a")
_write_config(project, 'branch_template: "{author}/{app}/{slug}"\n')
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "-DryRun", "-ShortName", "guided-tour", "Add guided tour",
)
assert result.returncode != 0
assert "branch_template" in result.stderr
assert "{number}" in result.stderr
def test_branch_template_requires_feature_segment_to_start_with_number(self, tmp_path: Path):
"""PowerShell rejects templates whose final segment cannot validate."""
project = _setup_project(tmp_path / "app-a")
_write_config(project, 'branch_template: "{author}/{app}/feature-{number}"\n')
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "-DryRun", "-ShortName", "guided-tour", "Add guided tour",
)
assert result.returncode != 0
assert "branch_template" in result.stderr
assert "{number}-" in result.stderr
def test_branch_template_rejects_slug_before_number(self, tmp_path: Path):
"""PowerShell rejects templates where {slug} scopes number scanning."""
project = _setup_project(tmp_path / "app-a")
_write_config(project, 'branch_template: "{author}/{slug}/{number}-{slug}"\n')
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "-DryRun", "-ShortName", "guided-tour", "Add guided tour",
)
assert result.returncode != 0
assert "branch_template" in result.stderr
assert "{slug}" in result.stderr
assert "{number}" in result.stderr
def test_git_branch_name_override_extracts_number_after_namespace(self, tmp_path: Path):
"""PowerShell GIT_BRANCH_NAME extracts FEATURE_NUM from a namespaced branch."""
project = _setup_project(tmp_path / "app-a")
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "Ignored description",
env_extra={"GIT_BRANCH_NAME": "jdoe/app-a/042-custom-branch"},
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/app-a/042-custom-branch"
assert data["FEATURE_NUM"] == "042"
def test_git_branch_name_override_ignores_numeric_namespace_segments(self, tmp_path: Path):
"""PowerShell GIT_BRANCH_NAME ignores numeric namespace segments."""
project = _setup_project(tmp_path / "2026-app")
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "Ignored description",
env_extra={"GIT_BRANCH_NAME": "jdoe/2026-app/042-custom-branch"},
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/2026-app/042-custom-branch"
assert data["FEATURE_NUM"] == "042"
def test_git_branch_name_override_without_feature_marker_preserves_full_name(self, tmp_path: Path):
"""PowerShell keeps the full override name when no feature marker exists."""
project = _setup_project(tmp_path / "app-a")
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "Ignored description",
env_extra={"GIT_BRANCH_NAME": "jdoe/app-a/custom-branch"},
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "jdoe/app-a/custom-branch"
assert data["FEATURE_NUM"] == "jdoe/app-a/custom-branch"
def test_no_git_graceful_degradation(self, tmp_path: Path):
"""create-new-feature-branch.ps1 works without git."""
project = _setup_project(tmp_path, git=False)
@@ -1011,13 +1376,31 @@ class TestGitCommonBash:
)
assert result.returncode == 0
def test_check_feature_branch_rejects_nested_prefix(self, tmp_path: Path):
def test_check_feature_branch_accepts_nested_prefix(self, tmp_path: Path):
project = _setup_project(tmp_path)
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
result = subprocess.run(
["bash", "-c", f'source "{script}" && check_feature_branch "feat/fix/001-x" "true"'],
capture_output=True, text=True,
)
assert result.returncode == 0
def test_check_feature_branch_rejects_nested_prefix_without_number(self, tmp_path: Path):
project = _setup_project(tmp_path)
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
result = subprocess.run(
["bash", "-c", f'source "{script}" && check_feature_branch "feat/fix/no-number" "true"'],
capture_output=True, text=True,
)
assert result.returncode != 0
def test_check_feature_branch_rejects_numeric_namespace_without_feature_number(self, tmp_path: Path):
project = _setup_project(tmp_path)
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
result = subprocess.run(
["bash", "-c", f'source "{script}" && check_feature_branch "jdoe/2026-app/no-number" "true"'],
capture_output=True, text=True,
)
assert result.returncode != 0
@@ -1037,3 +1420,33 @@ class TestGitCommonPowerShell:
text=True,
)
assert result.returncode == 0
def test_test_feature_branch_accepts_nested_prefix(self, tmp_path: Path):
project = _setup_project(tmp_path)
script = project / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1"
result = subprocess.run(
[
"pwsh",
"-NoProfile",
"-Command",
f'. "{script}"; if (Test-FeatureBranch -Branch "jdoe/app-a/001-x" -HasGit $true) {{ exit 0 }} else {{ exit 1 }}',
],
capture_output=True,
text=True,
)
assert result.returncode == 0
def test_test_feature_branch_rejects_numeric_namespace_without_feature_number(self, tmp_path: Path):
project = _setup_project(tmp_path)
script = project / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1"
result = subprocess.run(
[
"pwsh",
"-NoProfile",
"-Command",
f'. "{script}"; if (Test-FeatureBranch -Branch "jdoe/2026-app/no-number" -HasGit $true) {{ exit 0 }} else {{ exit 1 }}',
],
capture_output=True,
text=True,
)
assert result.returncode != 0

View File

@@ -71,6 +71,47 @@ def test_search_dedupes_by_precedence_and_filters():
assert [r.entry.id for r in qa_only] == ["beta"]
def test_search_does_not_surface_a_shadowed_lower_precedence_entry():
"""Search must resolve each id at its highest-precedence source, then
filter — never fall through to a shadowed lower-precedence entry the query
happens to match.
If the query matched only the lower-precedence copy of an id, search used
to return that copy, even though `resolve()`/install always use the
higher-precedence one. That advertised a bundle (name/version/source) the
user could never actually get.
"""
sources = [_source("high", 1, "install-allowed"), _source("low", 2, "install-allowed")]
payloads = {
# Highest-precedence entry for 'shared' does NOT match "widget".
"high": catalog_payload({
"shared": catalog_entry_dict(
"shared", name="Alpha Tool", role="developer",
description="nothing relevant", version="2.0.0",
),
}),
# Lower-precedence entry for the same id DOES match "widget".
"low": catalog_payload({
"shared": catalog_entry_dict(
"shared", name="Searchable Widget", version="1.0.0",
),
}),
}
stack = _stack(sources, payloads)
# resolve() uses the high-precedence entry.
assert stack.resolve("shared").source.id == "high"
# A query that only the shadowed low-precedence entry matches returns
# nothing — search agrees with resolve().
assert stack.search("widget") == []
# And a query the high-precedence entry matches returns it (from 'high').
alpha = stack.search("alpha tool")
assert [r.entry.id for r in alpha] == ["shared"]
assert alpha[0].source.id == "high"
def test_unreachable_source_raises_named_error():
def fetcher(src):
raise RuntimeError("boom")

View File

@@ -220,3 +220,68 @@ def test_pre_existing_component_is_not_attributed_or_removed(tmp_path: Path):
remove_bundle(tmp_path, "demo-bundle", installer)
assert ("extensions", "ext-a") in installer.installed
def _bundle(manifest_id, ext_ids, *, version="1.0.0"):
data = valid_manifest_dict()
data["bundle"]["id"] = manifest_id
data["bundle"]["version"] = version
data["provides"] = {
"extensions": [{"id": e, "version": version} for e in ext_ids]
}
return BundleManifest.from_dict(data)
def test_update_uninstalls_components_dropped_by_new_version(tmp_path: Path):
"""`bundle update` must uninstall components the new version no longer
ships, instead of orphaning them (installed on disk, tracked by nothing)."""
make_project(tmp_path)
installer = FakeInstaller()
man_v1 = _bundle("demo", ["ext-a", "ext-b"])
install_bundle(tmp_path, _plan(man_v1), installer, manifest=man_v1)
assert ("extensions", "ext-b") in installer.installed
man_v2 = _bundle("demo", ["ext-a"], version="2.0.0")
result = install_bundle(
tmp_path, _plan(man_v2), installer, manifest=man_v2, refresh=True
)
# ext-b was dropped by v2 -> uninstalled and reported.
assert ("extensions", "ext-b") in installer.remove_calls
assert ("extensions", "ext-b") in {(c.kind, c.id) for c in result.uninstalled}
assert ("extensions", "ext-b") not in installer.installed
assert ("extensions", "ext-a") in installer.installed
# The saved record lists only ext-a.
rec = next(r for r in load_records(tmp_path) if r.bundle_id == "demo")
keys = {(c.kind, c.id) for c in rec.contributed_components}
assert ("extensions", "ext-a") in keys
assert ("extensions", "ext-b") not in keys
def test_update_keeps_component_still_needed_by_sibling_bundle(tmp_path: Path):
"""A dropped component still owned by another bundle stays installed."""
make_project(tmp_path)
installer = FakeInstaller()
man_sib = _bundle("sibling", ["ext-b"])
install_bundle(tmp_path, _plan(man_sib), installer, manifest=man_sib)
man_v1 = _bundle("demo", ["ext-a", "ext-b"])
install_bundle(tmp_path, _plan(man_v1), installer, manifest=man_v1)
man_v2 = _bundle("demo", ["ext-a"], version="2.0.0")
install_bundle(
tmp_path, _plan(man_v2), installer, manifest=man_v2, refresh=True
)
# ext-b is still needed by 'sibling' -> not removed, stays installed.
assert ("extensions", "ext-b") not in installer.remove_calls
assert ("extensions", "ext-b") in installer.installed
# But demo's record no longer attributes it.
rec = next(r for r in load_records(tmp_path) if r.bundle_id == "demo")
assert ("extensions", "ext-b") not in {
(c.kind, c.id) for c in rec.contributed_components
}

View File

@@ -1,8 +1,24 @@
"""Shared test helpers for integration tests."""
import pytest
from specify_cli.integrations.base import MarkdownIntegration
@pytest.fixture(autouse=True)
def _isolate_integration_home(monkeypatch: pytest.MonkeyPatch, tmp_path):
"""Keep integration tests from reading or writing the real user home."""
home = tmp_path / "home"
for path in (home, home / ".cache", home / ".config", home / ".local" / "share"):
path.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("USERPROFILE", str(home))
monkeypatch.setenv("XDG_CACHE_HOME", str(home / ".cache"))
monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config"))
monkeypatch.setenv("XDG_DATA_HOME", str(home / ".local" / "share"))
class StubIntegration(MarkdownIntegration):
"""Minimal concrete integration for testing."""

View File

@@ -0,0 +1,21 @@
"""Regression tests for integration-test environment isolation."""
from __future__ import annotations
import os
from pathlib import Path
def test_integration_tests_use_tmp_home(tmp_path: Path) -> None:
home = tmp_path / "home"
assert Path(os.environ["HOME"]) == home
assert Path(os.environ["USERPROFILE"]) == home
assert Path(os.environ["XDG_CACHE_HOME"]) == home / ".cache"
assert Path(os.environ["XDG_CONFIG_HOME"]) == home / ".config"
assert Path(os.environ["XDG_DATA_HOME"]) == home / ".local" / "share"
assert home.is_dir()
assert (home / ".cache").is_dir()
assert (home / ".config").is_dir()
assert (home / ".local" / "share").is_dir()

View File

@@ -81,6 +81,26 @@ class TestAgyBuildExecArgs:
result = i.build_exec_args("my prompt", output_json=False)
assert result == ["agy", "--print", "my prompt"]
def test_build_exec_args_honors_extra_args(self, monkeypatch):
"""SPECKIT_INTEGRATION_AGY_EXTRA_ARGS must be appended after the prompt.
agy previously skipped _apply_extra_args_env_var entirely, so the
documented per-integration extra-args hook was silently ignored
(same class as the merged cursor-agent fix #3265).
"""
from specify_cli.integrations import get_integration
monkeypatch.setenv("SPECKIT_INTEGRATION_AGY_EXTRA_ARGS", "--verbose")
i = get_integration("agy")
assert i.build_exec_args("my prompt") == [
"agy", "--print", "my prompt", "--verbose",
]
def test_build_exec_args_honors_executable_override(self, monkeypatch):
from specify_cli.integrations import get_integration
monkeypatch.setenv("SPECKIT_INTEGRATION_AGY_EXECUTABLE", "/custom/agy")
i = get_integration("agy")
assert i.build_exec_args("my prompt")[0] == "/custom/agy"
class TestAgyHookCommandNote:
"""Verify dot-to-hyphen normalization note is injected into hook sections."""

View File

@@ -184,6 +184,23 @@ class YamlIntegrationTests:
assert "scripts:" not in parsed["prompt"]
assert "---" not in parsed["prompt"]
def test_yaml_prompt_with_indented_first_line_stays_valid(self):
"""A body whose first line is indented must still parse.
A bare ``|`` block scalar infers its indentation from the first
non-empty line, so a body starting with an indented line (e.g. a
markdown code block or nested list item) made the parser expect that
deeper indent for the whole block and reject the later, shallower
lines. The explicit ``|2`` indicator pins the indent so it parses."""
body = " indented first line\nback to normal\n indented again"
rendered = YamlIntegration._render_yaml("Title", "Desc", body, "src")
yaml_lines = [
ln for ln in rendered.split("\n") if not ln.startswith("# Source:")
]
parsed = yaml.safe_load("\n".join(yaml_lines))
assert parsed["prompt"].rstrip("\n") == body
def test_plan_command_has_no_context_placeholder(self, tmp_path):
"""The generated plan command must not carry a context-file placeholder.

View File

@@ -125,6 +125,55 @@ class TestCursorAgentCliDispatch:
assert argv is not None
assert argv[0] == "cursor-agent"
def test_build_exec_args_honors_executable_override(self, monkeypatch):
"""``SPECKIT_INTEGRATION_CURSOR_AGENT_EXECUTABLE`` overrides argv[0].
Every other CLI-dispatch integration (codex, devin, ...) routes
argv[0] through ``_resolve_executable()`` so operators can pin a
binary path (issue #2596). cursor-agent hardcoded ``self.key`` and
silently ignored the documented override.
"""
monkeypatch.setenv(
"SPECKIT_INTEGRATION_CURSOR_AGENT_EXECUTABLE", "/custom/cursor"
)
i = get_integration("cursor-agent")
args = i.build_exec_args("/speckit-plan", output_json=False)
assert args[0] == "/custom/cursor"
# The mandatory headless flags must still be present.
for flag in ("-p", "--trust", "--approve-mcps", "--force"):
assert flag in args
def test_build_exec_args_honors_extra_args_override(self, monkeypatch):
"""``SPECKIT_INTEGRATION_CURSOR_AGENT_EXTRA_ARGS`` flags are injected
*before* Spec Kit's canonical ``--model`` / ``--output-format`` flags.
The ``_apply_extra_args_env_var()`` hook (issue #2595) was never
invoked by cursor-agent, so operator-supplied flags were dropped.
Insertion order is the real contract: extra args must land after the
mandatory headless flags but before ``--model`` / ``--output-format``,
so they cannot clobber, displace, or reorder Spec Kit's canonical
trailing flags. Exercise with both a model and JSON output so both
canonical flags are present to pin against.
"""
monkeypatch.setenv(
"SPECKIT_INTEGRATION_CURSOR_AGENT_EXTRA_ARGS", "--foo bar"
)
i = get_integration("cursor-agent")
args = i.build_exec_args(
"/speckit-plan", model="sonnet-4-thinking", output_json=True
)
assert "--foo" in args
assert "bar" in args
# "bar" is the value of "--foo": the tokens stay adjacent and in order.
assert args.index("bar") == args.index("--foo") + 1
# Extra args are inserted before the canonical flags, so they cannot
# clobber or reorder them (the behavioral contract this test guards).
assert args.index("--foo") < args.index("--model")
assert args.index("--foo") < args.index("--output-format")
# The canonical flags themselves remain intact and correctly paired.
assert args[args.index("--model") + 1] == "sonnet-4-thinking"
assert args[args.index("--output-format") + 1] == "json"
def test_build_command_invocation_uses_hyphenated_skill_name(self):
"""SkillsIntegration: /speckit-plan (not /speckit.plan)."""
i = get_integration("cursor-agent")

View File

@@ -353,3 +353,38 @@ class TestHermesInitFlow:
if "agent-context" not in d.name
]
assert local_skills == [], f"Local skills dir should be empty, got: {local_skills}"
class TestHermesBuildExecArgs:
"""CLI dispatch argv, including the operator extra-args env hook."""
def test_build_exec_args_default_shape(self):
i = get_integration("hermes")
assert i.build_exec_args("/speckit-plan hi", output_json=True) == [
"hermes", "chat", "-Q", "--json", "-s", "speckit-plan", "-q", "hi",
]
def test_build_exec_args_honors_extra_args(self, monkeypatch):
"""SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS is injected before the
canonical -m/--json/-s/-q flags (same env hook as codex/opencode/
devin; hermes previously skipped _apply_extra_args_env_var entirely).
"""
monkeypatch.setenv(
"SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS", "--temperature 0.2"
)
i = get_integration("hermes")
args = i.build_exec_args("/speckit-plan hi", output_json=True)
assert args == [
"hermes", "chat", "-Q", "--temperature", "0.2",
"--json", "-s", "speckit-plan", "-q", "hi",
]
# Injected before the canonical flags so it can't displace them.
assert args.index("--temperature") < args.index("--json")
assert args.index("--temperature") < args.index("-s")
def test_build_exec_args_honors_executable_override(self, monkeypatch):
monkeypatch.setenv(
"SPECKIT_INTEGRATION_HERMES_EXECUTABLE", "/custom/hermes"
)
i = get_integration("hermes")
assert i.build_exec_args("/speckit-plan hi")[0] == "/custom/hermes"

View File

@@ -481,3 +481,40 @@ class TestRecordExistingNewGuards:
m = IntegrationManifest("test", tmp_path)
with pytest.raises(ValueError, match=r"canonical|'\.\.' segments"):
m.record_existing("dir/../file.txt")
class TestManifestUnreadableFile:
"""A managed file that is unreadable (e.g. PermissionError) must not crash
check_modified()/uninstall() — the CLI handlers surfaced a raw traceback."""
def _mk(self, tmp_path):
m = IntegrationManifest("test", tmp_path)
m.record_file("sub/f.md", "content")
return m
def test_check_modified_treats_unreadable_as_modified(self, tmp_path, monkeypatch):
m = self._mk(tmp_path)
def raise_perm(_path):
raise PermissionError("unreadable")
monkeypatch.setattr(
"specify_cli.integrations.manifest._sha256", raise_perm
)
# Before the fix this raised PermissionError.
assert m.check_modified() == ["sub/f.md"]
def test_uninstall_preserves_unreadable_file(self, tmp_path, monkeypatch):
m = self._mk(tmp_path)
def raise_perm(_path):
raise PermissionError("unreadable")
monkeypatch.setattr(
"specify_cli.integrations.manifest._sha256", raise_perm
)
removed, skipped = m.uninstall(force=False)
# Can't verify ownership => preserve, don't crash and don't delete.
assert removed == []
assert (tmp_path / "sub" / "f.md") in skipped
assert (tmp_path / "sub" / "f.md").exists()

View File

@@ -48,6 +48,19 @@ def _multi_install_safe_pairs() -> list[tuple[str, str]]:
]
def _multi_install_safe_orders() -> list[list[str]]:
safe_keys = _multi_install_safe_keys()
if len(safe_keys) < 2:
return [safe_keys]
return [safe_keys[index:] + safe_keys[:index] for index in range(len(safe_keys))]
def _multi_install_safe_order_id(ordered_keys: list[str]) -> str:
if not ordered_keys:
return "no-safe-integrations"
return f"init-{ordered_keys[0]}"
def _posix_path(value: str | None) -> str | None:
if not value:
return None
@@ -87,16 +100,6 @@ def _paths_overlap(first: str | None, second: str | None) -> bool:
return False
def _path_is_inside(path: str | None, directory: str | None) -> bool:
if not path or not directory:
return False
try:
PurePosixPath(path).relative_to(PurePosixPath(directory))
return True
except ValueError:
return False
class TestRegistry:
def test_registry_is_dict(self):
assert isinstance(INTEGRATION_REGISTRY, dict)
@@ -162,6 +165,15 @@ class TestRegistrarKeyAlignment:
class TestMultiInstallSafeContracts:
"""Declared safe integrations must stay isolated from each other."""
def test_safe_install_orders_rotate_each_integration_through_init(self):
safe_keys = _multi_install_safe_keys()
orders = _multi_install_safe_orders()
assert len(safe_keys) >= 2
assert [order[0] for order in orders] == safe_keys
assert len({tuple(order) for order in orders}) == len(safe_keys)
assert all(sorted(order) == safe_keys for order in orders)
@pytest.mark.parametrize("key", _multi_install_safe_keys())
def test_safe_integrations_have_static_isolated_paths(self, key):
assert _integration_root_dir(key), (
@@ -187,62 +199,77 @@ class TestMultiInstallSafeContracts:
f"{_integration_commands_dir(second)!r}"
)
@pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs())
@pytest.mark.parametrize(
"ordered_keys",
_multi_install_safe_orders(),
ids=_multi_install_safe_order_id,
)
def test_safe_integrations_have_disjoint_manifests(
self,
tmp_path,
first,
second,
ordered_keys,
):
for initial, additional in ((first, second), (second, first)):
project_root = tmp_path / f"project-{initial}-{additional}"
project_root.mkdir()
runner = CliRunner()
# The pairwise disjointness contract is only meaningful with at least
# two safe integrations. Guard so a shrunken registry fails loudly here
# rather than passing vacuously (or tripping over ordered_keys[0] below).
assert len(ordered_keys) >= 2, (
f"expected at least two multi-install-safe integrations, got {ordered_keys}"
)
original_cwd = os.getcwd()
try:
os.chdir(project_root)
init_result = runner.invoke(
app,
[
"init",
"--here",
"--integration",
initial,
"--script",
"sh",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
assert init_result.exit_code == 0, init_result.output
project_root = tmp_path / "project"
project_root.mkdir()
runner = CliRunner()
# Install every safe integration once into a single project, then assert
# pairwise manifest isolation. Each safe integration writes only to its
# own (disjoint) directories and always records what it writes, so a
# manifest's contents are independent of install order and of which other
# integrations are co-installed. The parametrized rotations keep the
# aggregate setup while placing each safe integration first once, so each
# one still exercises the `specify init --integration ...` path.
original_cwd = os.getcwd()
try:
os.chdir(project_root)
init_result = runner.invoke(
app,
[
"init",
"--here",
"--integration",
ordered_keys[0],
"--script",
"sh",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
assert init_result.exit_code == 0, init_result.output
for key in ordered_keys[1:]:
install_result = runner.invoke(
app,
["integration", "install", additional, "--script", "sh"],
["integration", "install", key, "--script", "sh"],
catch_exceptions=False,
)
assert install_result.exit_code == 0, install_result.output
finally:
os.chdir(original_cwd)
finally:
os.chdir(original_cwd)
initial_manifest = json.loads(
(
project_root / ".specify" / "integrations" / f"{initial}.manifest.json"
).read_text(encoding="utf-8")
)
additional_manifest = json.loads(
(
project_root / ".specify" / "integrations" / f"{additional}.manifest.json"
).read_text(encoding="utf-8")
integrations_dir = project_root / ".specify" / "integrations"
manifests = {}
for key in ordered_keys:
manifest = json.loads(
(integrations_dir / f"{key}.manifest.json").read_text(encoding="utf-8")
)
files = manifest.get("files", {})
assert isinstance(files, dict), f"{key} manifest files must be an object"
manifests[key] = set(files.keys())
initial_files = set(initial_manifest.get("files", {}))
additional_files = set(additional_manifest.get("files", {}))
assert initial_files.isdisjoint(additional_files), (
f"{initial} and {additional} are declared multi-install safe but both manage "
f"these files: {sorted(initial_files & additional_files)}"
for first, second in _multi_install_safe_pairs():
overlap = manifests[first] & manifests[second]
assert not overlap, (
f"{first} and {second} are declared multi-install safe but both manage "
f"these files: {sorted(overlap)}"
)

View File

@@ -845,6 +845,22 @@ class TestRedirectStripping:
auth3 = req3.get_header("Authorization") or req3.unredirected_hdrs.get("Authorization")
assert auth3 == "Bearer tok"
def test_malformed_redirect_url_raises_urlerror_not_valueerror(self):
"""A redirect to a malformed URL (unterminated IPv6 bracket) surfaces
as URLError, which download paths already handle, rather than an
unhandled ValueError traceback."""
import urllib.error
from specify_cli.authentication.http import _StripAuthOnRedirect
from urllib.request import Request
import io
handler = _StripAuthOnRedirect(("github.com",))
req = Request("https://github.com/org/repo")
with pytest.raises(urllib.error.URLError):
handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
"https://[::1/asset")
# ---------------------------------------------------------------------------
# _fetch_latest_release_tag delegation

View File

@@ -0,0 +1,339 @@
"""Parity tests for the Python check-prerequisites PoC."""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
from tests.conftest import requires_bash
PROJECT_ROOT = Path(__file__).resolve().parent.parent
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
CHECK_PREREQS_SH = PROJECT_ROOT / "scripts" / "bash" / "check-prerequisites.sh"
COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
CHECK_PREREQS_PS = PROJECT_ROOT / "scripts" / "powershell" / "check-prerequisites.ps1"
COMMON_PY = PROJECT_ROOT / "scripts" / "python" / "common.py"
CHECK_PREREQS_PY = PROJECT_ROOT / "scripts" / "python" / "check_prerequisites.py"
HAS_PWSH = shutil.which("pwsh") is not None
_WINDOWS_POWERSHELL = (
shutil.which("powershell.exe") or shutil.which("powershell")
) if os.name == "nt" else None
def _install_scripts(repo: Path) -> None:
bash_dir = repo / ".specify" / "scripts" / "bash"
bash_dir.mkdir(parents=True, exist_ok=True)
shutil.copy(COMMON_SH, bash_dir / "common.sh")
shutil.copy(CHECK_PREREQS_SH, bash_dir / "check-prerequisites.sh")
ps_dir = repo / ".specify" / "scripts" / "powershell"
ps_dir.mkdir(parents=True, exist_ok=True)
shutil.copy(COMMON_PS, ps_dir / "common.ps1")
shutil.copy(CHECK_PREREQS_PS, ps_dir / "check-prerequisites.ps1")
py_dir = repo / ".specify" / "scripts" / "python"
py_dir.mkdir(parents=True, exist_ok=True)
shutil.copy(COMMON_PY, py_dir / "common.py")
shutil.copy(CHECK_PREREQS_PY, py_dir / "check_prerequisites.py")
def _write_feature_json(
repo: Path, feature_directory: str = "specs/001-my-feature"
) -> None:
(repo / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": feature_directory}, separators=(",", ":"))
+ "\n",
encoding="utf-8",
)
def _clean_env() -> dict[str, str]:
env = os.environ.copy()
for key in list(env):
if key.startswith("SPECIFY_"):
env.pop(key)
return env
def _git_init(repo: Path) -> None:
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
subprocess.run(
["git", "config", "user.email", "test@example.com"], cwd=repo, check=True
)
subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True)
subprocess.run(
["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=repo, check=True
)
@pytest.fixture
def prereq_repo(tmp_path: Path) -> Path:
repo = tmp_path / "proj"
repo.mkdir()
_git_init(repo)
(repo / ".specify").mkdir()
_install_scripts(repo)
return repo
def _py_cmd(repo: Path, *args: str) -> list[str]:
script = repo / ".specify" / "scripts" / "python" / "check_prerequisites.py"
return [sys.executable, str(script), *args]
def _repo_copy_py_cmd(repo: Path, *args: str) -> list[str]:
script = repo / "scripts" / "python" / "check_prerequisites.py"
return [sys.executable, str(script), *args]
def _bash_cmd(repo: Path, *args: str) -> list[str]:
script = repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
return ["bash", str(script), *args]
def _ps_cmd(repo: Path, *args: str) -> list[str]:
script = repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
return [exe, "-NoProfile", "-File", str(script), *args]
def _run(
cmd: list[str], repo: Path, env: dict[str, str] | None = None
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
cmd,
cwd=repo,
capture_output=True,
text=True,
check=False,
env=env or _clean_env(),
)
def _json_stdout(result: subprocess.CompletedProcess[str]) -> object:
return json.loads(result.stdout)
def _normalize_status_text(text: str) -> str:
return (
text.replace("", " [OK] ")
.replace("", " [FAIL] ")
.replace("\r\n", "\n")
)
def _normalize_help_text(text: str) -> str:
normalized = text.replace("\r\n", "\n").replace(
"check-prerequisites.sh", "check_prerequisites.py"
)
return "\n".join("" if not line.strip() else line for line in normalized.split("\n"))
@requires_bash
@pytest.mark.parametrize(
"args",
[
("--json",),
("--json", "--include-tasks"),
("--json", "--require-tasks", "--include-tasks"),
("--json", "--paths-only"),
],
)
def test_python_json_output_matches_bash(prereq_repo: Path, args: tuple[str, ...]) -> None:
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True)
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
(feat / "tasks.md").write_text("# tasks\n", encoding="utf-8")
(feat / "research.md").write_text("# research\n", encoding="utf-8")
(feat / "data-model.md").write_text("# model\n", encoding="utf-8")
(feat / "quickstart.md").write_text("# quickstart\n", encoding="utf-8")
(feat / "contracts" / "v1").mkdir(parents=True)
_write_feature_json(prereq_repo)
bash = _run(_bash_cmd(prereq_repo, *args), prereq_repo)
py = _run(_py_cmd(prereq_repo, *args), prereq_repo)
assert py.returncode == bash.returncode == 0
assert py.stderr == bash.stderr == ""
assert _json_stdout(py) == _json_stdout(bash)
@requires_bash
def test_python_text_output_matches_bash(prereq_repo: Path) -> None:
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True)
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
(feat / "contracts").mkdir()
_write_feature_json(prereq_repo)
bash = _run(_bash_cmd(prereq_repo, "--include-tasks"), prereq_repo)
py = _run(_py_cmd(prereq_repo, "--include-tasks"), prereq_repo)
assert py.returncode == bash.returncode == 0
assert py.stderr == bash.stderr == ""
assert _normalize_status_text(py.stdout) == _normalize_status_text(bash.stdout)
@requires_bash
def test_python_help_output_matches_bash(prereq_repo: Path) -> None:
bash = _run(_bash_cmd(prereq_repo, "--help"), prereq_repo)
py = _run(_py_cmd(prereq_repo, "--help"), prereq_repo)
assert py.returncode == bash.returncode == 0
assert py.stderr == bash.stderr == ""
assert _normalize_help_text(py.stdout) == _normalize_help_text(bash.stdout)
@requires_bash
def test_python_unknown_option_matches_bash_error_shape(prereq_repo: Path) -> None:
bash = _run(_bash_cmd(prereq_repo, "--bogus"), prereq_repo)
py = _run(_py_cmd(prereq_repo, "--bogus"), prereq_repo)
assert py.returncode == bash.returncode == 1
assert py.stdout == bash.stdout == ""
assert py.stderr == bash.stderr
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
@pytest.mark.parametrize(
("py_args", "ps_args"),
[
(("--json",), ("-Json",)),
(("--json", "--include-tasks"), ("-Json", "-IncludeTasks")),
(
("--json", "--require-tasks", "--include-tasks"),
("-Json", "-RequireTasks", "-IncludeTasks"),
),
(("--json", "--paths-only"), ("-Json", "-PathsOnly")),
],
ids=[
"json",
"json_include_tasks",
"json_require_tasks_include_tasks",
"json_paths_only",
],
)
def test_python_json_output_matches_powershell(
prereq_repo: Path, py_args: tuple[str, ...], ps_args: tuple[str, ...]
) -> None:
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True)
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
(feat / "tasks.md").write_text("# tasks\n", encoding="utf-8")
(feat / "research.md").write_text("# research\n", encoding="utf-8")
(feat / "data-model.md").write_text("# model\n", encoding="utf-8")
(feat / "quickstart.md").write_text("# quickstart\n", encoding="utf-8")
(feat / "contracts" / "v1").mkdir(parents=True)
_write_feature_json(prereq_repo)
ps = _run(_ps_cmd(prereq_repo, *ps_args), prereq_repo)
py = _run(_py_cmd(prereq_repo, *py_args), prereq_repo)
assert py.returncode == ps.returncode == 0
assert py.stderr == ps.stderr == ""
assert _json_stdout(py) == _json_stdout(ps)
def test_python_repo_copy_script_file_fallback_finds_repo_root(tmp_path: Path) -> None:
repo = tmp_path / "proj"
outside = tmp_path / "outside"
repo.mkdir()
outside.mkdir()
_git_init(repo)
(repo / ".specify").mkdir()
_write_feature_json(repo)
(repo / "specs" / "001-my-feature").mkdir(parents=True)
py_dir = repo / "scripts" / "python"
py_dir.mkdir(parents=True)
shutil.copy(COMMON_PY, py_dir / "common.py")
shutil.copy(CHECK_PREREQS_PY, py_dir / "check_prerequisites.py")
py = _run(_repo_copy_py_cmd(repo, "--json", "--paths-only"), outside)
assert py.returncode == 0, py.stderr
assert Path(_json_stdout(py)["REPO_ROOT"]) == repo
def test_python_paths_only_does_not_persist_feature_json(prereq_repo: Path) -> None:
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True)
(prereq_repo / "specs" / "002-other").mkdir(parents=True)
_write_feature_json(prereq_repo, "specs/001-my-feature")
feature_json = prereq_repo / ".specify" / "feature.json"
before = feature_json.read_text(encoding="utf-8")
env = _clean_env()
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other"
py = _run(_py_cmd(prereq_repo, "--json", "--paths-only"), prereq_repo, env=env)
assert py.returncode == 0, py.stderr
assert "002-other" in _json_stdout(py)["FEATURE_DIR"]
assert feature_json.read_text(encoding="utf-8") == before
def test_python_normal_mode_persists_feature_json(prereq_repo: Path) -> None:
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True)
feat = prereq_repo / "specs" / "002-other"
feat.mkdir(parents=True)
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
_write_feature_json(prereq_repo, "specs/001-my-feature")
env = _clean_env()
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other"
py = _run(_py_cmd(prereq_repo, "--json"), prereq_repo, env=env)
assert py.returncode == 0, py.stderr
data = json.loads(
(prereq_repo / ".specify" / "feature.json").read_text(encoding="utf-8")
)
assert data["feature_directory"] == "specs/002-other"
@pytest.mark.parametrize(
("args", "expected"),
[
(("--json",), "Feature directory not found"),
(("--json",), "plan.md not found"),
(("--json", "--require-tasks"), "tasks.md not found"),
],
ids=["missing_feature_context", "missing_plan", "missing_tasks"],
)
def test_python_negative_errors_are_stderr_only(
tmp_path: Path, args: tuple[str, ...], expected: str
) -> None:
repo = tmp_path / "proj"
repo.mkdir()
_git_init(repo)
(repo / ".specify").mkdir()
_install_scripts(repo)
if expected in {"plan.md not found", "tasks.md not found"}:
feat = repo / "specs" / "001-my-feature"
feat.mkdir(parents=True)
_write_feature_json(repo)
if expected == "tasks.md not found":
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
py = _run(_py_cmd(repo, *args), repo)
assert py.returncode != 0
assert expected in py.stderr
assert expected not in py.stdout
assert py.stdout.strip() == ""
def test_python_branch_falls_back_to_feature_dir_basename(prereq_repo: Path) -> None:
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True)
_write_feature_json(prereq_repo)
py = _run(_py_cmd(prereq_repo, "--json", "--paths-only"), prereq_repo)
assert py.returncode == 0, py.stderr
assert _json_stdout(py)["BRANCH"] == "001-my-feature"

View File

@@ -850,6 +850,67 @@ class TestExtensionSkillRegistration:
assert ".specify/templates/checklist.md" in content
assert ".specify/memory/constitution.md" in content
def test_skill_registration_uses_extension_local_script_paths(self, project_dir, temp_dir):
"""Auto-registered skills should not rewrite extension scripts into core scripts."""
_create_init_options(project_dir, ai="claude", ai_skills=True)
skills_dir = _create_skills_dir(project_dir, ai="claude")
ext_dir = temp_dir / "scripted-ext"
ext_dir.mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "scripted-ext",
"name": "Scripted Extension",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.scripted-ext.check",
"file": "commands/check.md",
"description": "Scripted check command",
}
]
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.safe_dump(manifest_data, f)
(ext_dir / "commands").mkdir()
(ext_dir / "scripts" / "bash").mkdir(parents=True)
(ext_dir / "scripts" / "bash" / "resolve-skill.sh").write_text(
"#!/usr/bin/env bash\n"
)
(ext_dir / "scripts" / "bash" / "ensure-skills.sh").write_text(
"#!/usr/bin/env bash\n"
)
(ext_dir / "commands" / "check.md").write_text(
"---\n"
"description: Scripted check command\n"
"scripts:\n"
' sh: scripts/bash/resolve-skill.sh "{ARGS}"\n'
"---\n\n"
"Run {SCRIPT}\n"
"Then run scripts/bash/ensure-skills.sh.\n"
)
manager = ExtensionManager(project_dir)
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
content = (skills_dir / "speckit-scripted-ext-check" / "SKILL.md").read_text()
assert "{SCRIPT}" not in content
assert "{ARGS}" not in content
assert (
'.specify/extensions/scripted-ext/scripts/bash/resolve-skill.sh "$ARGUMENTS"'
in content
)
assert ".specify/extensions/scripted-ext/scripts/bash/ensure-skills.sh" in content
assert ".specify/scripts/bash/resolve-skill.sh" not in content
assert ".specify/scripts/bash/ensure-skills.sh" not in content
def test_missing_command_file_skipped(self, skills_project, temp_dir):
"""Commands with missing source files should be skipped gracefully."""
project_dir, skills_dir = skills_project

View File

@@ -31,6 +31,7 @@ from specify_cli.extensions import (
ExtensionRegistry,
ExtensionManager,
CommandRegistrar,
ConfigManager,
HookExecutor,
ExtensionCatalog,
ExtensionError,
@@ -1146,10 +1147,12 @@ class TestExtensionManager:
context_note=None,
link_outputs=False,
create_missing_active_skills_dir=False,
extension_id=None,
):
captured["create_missing_active_skills_dir"] = (
create_missing_active_skills_dir
)
captured["extension_id"] = extension_id
return {}
monkeypatch.setattr(
@@ -1163,6 +1166,7 @@ class TestExtensionManager:
registrar.register_commands_for_all_agents(manifest, extension_dir, project_dir)
assert captured["create_missing_active_skills_dir"] is False
assert captured["extension_id"] == manifest.id
def test_install_duplicate(self, extension_dir, project_dir):
"""Test installing already installed extension."""
@@ -1694,6 +1698,29 @@ $ARGUMENTS
assert adjusted["scripts"]["sh"] == ".specify/extensions/test-ext/scripts/setup.sh {ARGS}"
assert adjusted["scripts"]["ps"] == ".specify/scripts/powershell/setup-plan.ps1 {ARGS}"
def test_adjust_script_paths_rewrites_extension_top_level_scripts(self):
"""Extension command-local scripts should resolve under the installed extension."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
registrar = AgentCommandRegistrar()
original = {
"scripts": {
"sh": "scripts/bash/resolve-skill.sh {ARGS}",
"ps": "../../scripts/powershell/setup-plan.ps1 -Json",
}
}
adjusted = registrar._adjust_script_paths(original, extension_id="test-ext")
assert (
adjusted["scripts"]["sh"]
== ".specify/extensions/test-ext/scripts/bash/resolve-skill.sh {ARGS}"
)
assert (
adjusted["scripts"]["ps"]
== ".specify/scripts/powershell/setup-plan.ps1 -Json"
)
def test_rewrite_project_relative_paths_preserves_extension_local_body_paths(self):
"""Body rewrites should preserve extension-local assets while fixing top-level refs."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
@@ -1708,6 +1735,24 @@ $ARGUMENTS
assert ".specify/extensions/test-ext/templates/spec.md" in rewritten
assert ".specify/scripts/bash/setup-plan.sh" in rewritten
def test_rewrite_project_relative_paths_uses_extension_context_for_scripts(self):
"""Extension source bodies treat top-level scripts/ as extension-local."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
body = (
"Run scripts/bash/ensure-skills.sh\n"
"Fallback ../../scripts/bash/setup-plan.sh\n"
"Read templates/checklist.md\n"
)
rewritten = AgentCommandRegistrar.rewrite_project_relative_paths(
body, extension_id="test-ext"
)
assert ".specify/extensions/test-ext/scripts/bash/ensure-skills.sh" in rewritten
assert ".specify/scripts/bash/setup-plan.sh" in rewritten
assert ".specify/templates/checklist.md" in rewritten
def test_render_toml_command_handles_embedded_triple_double_quotes(self):
"""TOML renderer should stay valid when body includes triple double-quotes."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
@@ -5396,6 +5441,29 @@ class TestExtensionAddCLI:
f"confirm must precede spinner, got: {call_order}"
assert result.exit_code == 0 # user declined → clean exit
def test_add_from_malformed_ipv6_url_exits_cleanly(self, tmp_path):
"""A malformed IPv6 URL must produce a clean error, not a ValueError traceback."""
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://[::1/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_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
@@ -7492,3 +7560,52 @@ def test_extension_wrapper_resolves_ghes_asset_when_host_configured(tmp_path, mo
)
assert resolved == "https://ghes.example/api/v3/repos/o/r/releases/assets/7"
assert captured == ["https://ghes.example/api/v3/repos/o/r/releases/tags/v1"]
class TestConfigManagerNonMappingYaml:
"""A non-mapping YAML config root must not crash config/hook resolution."""
def _make(self, tmp_path, body: str):
ext_dir = tmp_path / ".specify" / "extensions" / "jira"
ext_dir.mkdir(parents=True)
(ext_dir / "jira-config.yml").write_text(body, encoding="utf-8")
return ConfigManager(tmp_path, "jira")
def test_get_config_coerces_list_root(self, tmp_path):
"""A YAML list root previously raised AttributeError in _merge_configs."""
cm = self._make(tmp_path, "- foo\n- bar\n")
assert cm.get_config() == {}
def test_get_config_coerces_scalar_root(self, tmp_path):
cm = self._make(tmp_path, "just a string\n")
assert cm.get_config() == {}
def test_has_value_and_get_value_do_not_raise(self, tmp_path):
cm = self._make(tmp_path, "- foo\n")
assert cm.has_value("anything") is False
assert cm.get_value("anything") is None
def test_valid_local_config_layers_over_list_root_project_config(self, tmp_path):
"""A malformed project config must not block a valid local config."""
ext_dir = tmp_path / ".specify" / "extensions" / "jira"
ext_dir.mkdir(parents=True)
(ext_dir / "jira-config.yml").write_text("- foo\n- bar\n", encoding="utf-8")
(ext_dir / "local-config.yml").write_text(
"notifications:\n enabled: true\n", encoding="utf-8"
)
cm = ConfigManager(tmp_path, "jira")
assert cm.get_value("notifications.enabled") is True
def test_hook_condition_returns_false_without_raising(self, tmp_path):
"""`config.x is set` on a scalar-root config must evaluate cleanly.
Before the fix, _merge_configs raised AttributeError and the
exception was swallowed by should_execute_hook, silently disabling
every config-based hook for the extension. Assert on
_evaluate_condition directly so the crash isn't masked.
"""
ext_dir = tmp_path / ".specify" / "extensions" / "jira"
ext_dir.mkdir(parents=True)
(ext_dir / "jira-config.yml").write_text("just a string\n", encoding="utf-8")
executor = HookExecutor(tmp_path)
assert executor._evaluate_condition("config.x is set", "jira") is False

View File

@@ -233,6 +233,23 @@ class TestResolveGitHubReleaseAssetApiUrl:
assert result is None
assert called == []
def test_returns_none_on_malformed_ghes_port(self):
"""A malformed port on an allowlisted GHES host returns None, not a
ValueError (contract: resolve or return None, never raise)."""
called = []
def open_never(url, timeout=None, extra_headers=None):
called.append(url)
raise AssertionError("open_url_fn must not be called")
result = resolve_github_release_asset_api_url(
"https://ghes.example:notaport/o/r/releases/download/v1/ext.zip",
open_never,
github_hosts=("ghes.example",),
)
assert result is None
assert called == []
def test_passthrough_for_unlisted_ghes_api_asset_url(self):
"""A direct GHES /api/v3 asset URL passes through even when the host is
not allowlisted: passthrough issues no API request, and the download

245
tests/test_post_process.py Normal file
View File

@@ -0,0 +1,245 @@
"""Tests for post_process_command_content() hook on IntegrationBase.
Verifies that the generalized post-processing hook:
- Runs for non-skills format types (Markdown, TOML, YAML)
- Does NOT run for skills-format agents
- Default no-op returns content unchanged
- Exceptions propagate to caller
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from specify_cli.agents import CommandRegistrar
from specify_cli.integrations.base import IntegrationBase
@pytest.fixture
def registrar():
return CommandRegistrar()
@pytest.fixture
def ext_dir(tmp_path):
"""Create a mock extension with a simple command template."""
ext = tmp_path / "extension"
ext.mkdir()
cmd_dir = ext / "commands"
cmd_dir.mkdir()
return ext, cmd_dir
def _write_cmd(cmd_dir, name="review.md", body="Review the code.\n"):
cmd_file = cmd_dir / name
cmd_file.write_text(
f"---\ndescription: Test command\n---\n\n{body}",
encoding="utf-8",
)
return cmd_file
class TestDefaultNoOp:
def test_returns_content_unchanged(self):
base = IntegrationBase()
content = "Some command content\nwith multiple lines."
assert base.post_process_command_content(content) == content
def test_empty_string(self):
base = IntegrationBase()
assert base.post_process_command_content("") == ""
class TestMarkdownAgentPostProcess:
def test_opencode_post_process_applied(
self, tmp_path, registrar, ext_dir, monkeypatch
):
ext, cmd_dir = ext_dir
_write_cmd(cmd_dir)
from specify_cli.integrations import get_integration
opencode = get_integration("opencode")
marker = "<!-- POST_PROCESSED -->"
def _inject_marker(self, content):
return content + marker
monkeypatch.setattr(
opencode.__class__, "post_process_command_content", _inject_marker
)
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
registrar.register_commands(
"opencode", commands, "test-ext", ext, tmp_path
)
cmd_output = tmp_path / ".opencode" / "commands" / "speckit.test.review.md"
assert cmd_output.exists()
content = cmd_output.read_text(encoding="utf-8")
assert marker in content
class TestTomlAgentPostProcess:
def test_gemini_post_process_applied(
self, tmp_path, registrar, ext_dir, monkeypatch
):
ext, cmd_dir = ext_dir
_write_cmd(cmd_dir)
from specify_cli.integrations import get_integration
gemini = get_integration("gemini")
marker = "# POST_PROCESSED"
def _inject_marker(self, content):
return content + f"\n{marker}\n"
monkeypatch.setattr(
gemini.__class__, "post_process_command_content", _inject_marker
)
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
registrar.register_commands(
"gemini", commands, "test-ext", ext, tmp_path
)
cmd_output = tmp_path / ".gemini" / "commands" / "speckit.test.review.toml"
assert cmd_output.exists()
content = cmd_output.read_text(encoding="utf-8")
assert marker in content
class TestYamlAgentPostProcess:
def test_goose_post_process_applied(
self, tmp_path, registrar, ext_dir, monkeypatch
):
ext, cmd_dir = ext_dir
_write_cmd(cmd_dir)
from specify_cli.integrations import get_integration
goose = get_integration("goose")
marker = "# POST_PROCESSED"
def _inject_marker(self, content):
return content + f"\n{marker}\n"
monkeypatch.setattr(
goose.__class__, "post_process_command_content", _inject_marker
)
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
registrar.register_commands(
"goose", commands, "test-ext", ext, tmp_path
)
cmd_output = tmp_path / ".goose" / "recipes" / "speckit.test.review.yaml"
assert cmd_output.exists()
content = cmd_output.read_text(encoding="utf-8")
assert marker in content
class TestSkillsAgentExcluded:
def test_claude_post_process_not_called(
self, tmp_path, registrar, ext_dir, monkeypatch
):
ext, cmd_dir = ext_dir
_write_cmd(cmd_dir)
from specify_cli.integrations import get_integration
claude = get_integration("claude")
marker = "<!-- SHOULD_NOT_APPEAR -->"
def _inject_marker(self, content):
return content + marker
monkeypatch.setattr(
claude.__class__, "post_process_command_content", _inject_marker
)
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
registrar.register_commands(
"claude", commands, "test-ext", ext, tmp_path
)
skill_file = (
tmp_path / ".claude" / "skills" / "speckit-test-review" / "SKILL.md"
)
assert skill_file.exists()
content = skill_file.read_text(encoding="utf-8")
assert marker not in content
def test_skills_agent_method_never_called(
self, tmp_path, registrar, ext_dir
):
ext, cmd_dir = ext_dir
_write_cmd(cmd_dir)
from specify_cli.integrations import get_integration
claude = get_integration("claude")
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
with patch.object(
claude.__class__, "post_process_command_content", wraps=claude.post_process_command_content
) as mock_method:
registrar.register_commands(
"claude", commands, "test-ext", ext, tmp_path
)
mock_method.assert_not_called()
class TestExceptionPropagation:
def test_hook_exception_propagates(
self, tmp_path, registrar, ext_dir, monkeypatch
):
ext, cmd_dir = ext_dir
_write_cmd(cmd_dir)
from specify_cli.integrations import get_integration
opencode = get_integration("opencode")
def _raise(self, content):
raise RuntimeError("Hook failed")
monkeypatch.setattr(
opencode.__class__, "post_process_command_content", _raise
)
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
with pytest.raises(RuntimeError, match="Hook failed"):
registrar.register_commands(
"opencode", commands, "test-ext", ext, tmp_path
)
class TestRegressionPlainTemplate:
@pytest.mark.parametrize(
"agent,path_pattern",
[
("claude", ".claude/skills/speckit-test-plain/SKILL.md"),
("opencode", ".opencode/commands/speckit.test.plain.md"),
],
ids=["skills", "markdown"],
)
def test_plain_template_unchanged(
self, tmp_path, registrar, ext_dir, agent, path_pattern
):
ext, cmd_dir = ext_dir
body_text = "This is a plain command with no special content.\n"
_write_cmd(cmd_dir, name="plain.md", body=body_text)
commands = [{"name": "speckit.test.plain", "file": "commands/plain.md"}]
registrar.register_commands(
agent, commands, "test-ext", ext, tmp_path
)
output_file = tmp_path / path_pattern
assert output_file.exists(), f"Output file missing for {agent}"
content = output_file.read_text(encoding="utf-8")
assert body_text.strip() in content, f"Body text missing in {agent} output"

View File

@@ -4538,6 +4538,27 @@ class TestBundledPresetLocator:
assert "got https://" not in output
open_url.assert_not_called()
def test_preset_add_from_malformed_ipv6_url_exits_cleanly(self, project_dir):
"""A malformed IPv6 URL must produce a clean error, not a ValueError traceback."""
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://[::1/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_redirect_error_describes_disallowed_url(self, project_dir, monkeypatch, capsys):
"""Redirect rejection message covers hostless HTTPS, not only non-HTTPS URLs."""
import typer

View File

@@ -322,3 +322,87 @@ class TestWorkflowRunWithoutProject:
assert result.exit_code != 0
assert ".specify path exists but is not a directory" in result.output
class TestWorkflowRunJsonErrorStream:
"""Under --json, error text must go to stderr so stdout stays parseable."""
def _bad_workflow(self, tmp_path):
wf = tmp_path / "bad.yml"
wf.write_text(
yaml.dump(
{
"schema_version": "1.0",
"workflow": {
"id": "bad-wf",
"name": "Bad",
"version": "1.0.0",
"description": "fails validation",
},
# shell step missing required 'run' -> validation error
"steps": [{"id": "s", "type": "shell"}],
}
),
encoding="utf-8",
)
return wf
def test_run_json_validation_error_not_on_stdout(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
wf = self._bad_workflow(tmp_path)
runner = CliRunner()
old = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(
app, ["workflow", "run", str(wf), "--json"], catch_exceptions=False
)
finally:
os.chdir(old)
assert result.exit_code == 1
# stdout must carry only JSON (here: nothing) — never human error text.
assert "validation failed" not in result.stdout
assert "Error" not in result.stdout
# The message is routed to stderr instead.
assert "validation failed" in result.stderr
def test_run_json_invalid_input_not_on_stdout(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
# A valid single-shell workflow so we get past load/validate to
# _parse_input_values, which rejects the malformed --input.
wf = tmp_path / "ok.yml"
wf.write_text(
yaml.dump(
{
"schema_version": "1.0",
"workflow": {
"id": "ok-wf",
"name": "OK",
"version": "1.0.0",
"description": "x",
},
"steps": [{"id": "s", "type": "shell", "run": "echo hi"}],
}
),
encoding="utf-8",
)
runner = CliRunner()
old = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(
app,
["workflow", "run", str(wf), "--json", "--input", "no-equals"],
catch_exceptions=False,
)
finally:
os.chdir(old)
assert result.exit_code == 1
assert "Invalid input format" not in result.stdout
assert "Invalid input format" in result.stderr

View File

@@ -260,6 +260,60 @@ class TestExpressions:
ctx = StepContext(inputs={"text": "uses }} syntax"})
assert evaluate_expression("{{ inputs.text | contains('}}') }}", ctx) is True
def test_multi_expression_with_literal_close_brace_in_argument(self):
"""A multi-expression template with a literal ``}}`` inside a string
argument must interpolate, not raise. #3208/#3228 hardened the single-
expression fast path for literal braces but left the interpolation path
on ``_EXPR_PATTERN``, whose non-greedy body stops at the first ``}}`` --
so the block was captured truncated and the filter parser raised
ValueError."""
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(inputs={"name": "Bob", "missing": None})
# ``}}`` in the default fallback of the second block.
result = evaluate_expression(
"{{ inputs.name }}: {{ inputs.missing | default('}}') }}", ctx
)
assert result == "Bob: }}"
# ``}}`` in the first block, expression following it.
result = evaluate_expression(
"{{ inputs.missing | default('}}') }} / {{ inputs.name }}", ctx
)
assert result == "}} / Bob"
def test_multi_expression_with_literal_open_brace_in_argument(self):
"""A literal ``{{`` inside a string argument in a multi-expression
template must not confuse block detection either."""
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(inputs={"name": "Bob", "missing": None})
result = evaluate_expression(
"{{ inputs.name }} {{ inputs.missing | default('{{') }}", ctx
)
assert result == "Bob {{"
def test_multi_expression_unbalanced_quote_still_raises(self):
"""A malformed block (an unbalanced quote in a filter arg) must still
surface a ValueError, not be silently emitted verbatim.
The quote-aware scan never finds a block-closing ``}}`` when a quote is
left open, but a raw ``}}`` is still present in the tail. It must fall
back to that raw delimiter and evaluate — same as the old regex path —
so a typo fails loudly instead of being hidden (Copilot review on
#3307)."""
import pytest
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext(inputs={"name": "Bob", "missing": None})
with pytest.raises(ValueError):
evaluate_expression(
"{{ inputs.name }} {{ inputs.missing | default('oops }}", ctx
)
def test_comparison_equals(self):
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
@@ -288,6 +342,35 @@ class TestExpressions:
assert evaluate_expression("{{ steps.plan.output.task_count > 5 }}", ctx) is True
assert evaluate_expression("{{ steps.plan.output.task_count < 5 }}", ctx) is False
def test_ordering_comparison_of_non_numeric_strings(self):
"""`<`/`>`/`<=`/`>=` between non-numeric strings must compare
lexicographically, not silently return False.
`_safe_compare` used to coerce both operands to int/float unconditionally;
a non-numeric string (date, version tag, name) failed that coercion and
the whole comparison returned False. Ordinary strings should order the
way Python does; numeric strings must still compare as numbers."""
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
# ISO dates compare lexicographically (correct chronological order).
ctx = StepContext(inputs={"d": "2026-01-01"})
assert evaluate_expression("{{ inputs.d < '2026-02-01' }}", ctx) is True
assert evaluate_expression("{{ inputs.d > '2026-02-01' }}", ctx) is False
# Plain string ordering.
ctx = StepContext(inputs={"name": "beta"})
assert evaluate_expression("{{ inputs.name > 'alpha' }}", ctx) is True
# Two numeric strings still compare numerically, not lexically
# ("10" > "9" is True as numbers; as strings it would be False).
ctx = StepContext(inputs={"v": "10"})
assert evaluate_expression("{{ inputs.v > '9' }}", ctx) is True
# A number vs a non-numeric string is genuinely incomparable -> False.
ctx = StepContext(inputs={"n": 5})
assert evaluate_expression("{{ inputs.n > 'abc' }}", ctx) is False
def test_boolean_and(self):
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
@@ -1183,6 +1266,26 @@ class TestShellStep:
errors = step.validate({"id": "test"})
assert any("missing 'run'" in e for e in errors)
@pytest.mark.parametrize("bad_run", [None, ["echo", "hi"], 42])
def test_validate_rejects_non_string_run(self, bad_run):
"""A non-string 'run' must be rejected at validation.
execute() str()-coerces run and invokes it under shell=True, so a
null or list run would otherwise run the Python repr as a command.
"""
from specify_cli.workflows.steps.shell import ShellStep
step = ShellStep()
errors = step.validate({"id": "s", "run": bad_run})
assert any("'run' must be a string" in e for e in errors)
def test_validate_accepts_string_and_expression_run(self):
from specify_cli.workflows.steps.shell import ShellStep
step = ShellStep()
assert step.validate({"id": "s", "run": "echo hi"}) == []
assert step.validate({"id": "s", "run": "{{ steps.x.output }}"}) == []
def test_output_format_json_exposes_data(self, tmp_path):
from specify_cli.workflows.steps.shell import ShellStep
@@ -2099,6 +2202,27 @@ class TestFanInStep:
errors = step.validate({"id": "test", "wait_for": "not-a-list"})
assert any("non-empty list" in e for e in errors)
@pytest.mark.parametrize("bad_output", [["{{ fan_in.results }}"], "{{ x }}", 42])
def test_validate_rejects_non_mapping_output(self, bad_output):
"""A non-mapping 'output' must be rejected: execute() would otherwise
silently coerce it to {} and drop the declared aggregation keys."""
from specify_cli.workflows.steps.fan_in import FanInStep
step = FanInStep()
errors = step.validate(
{"id": "j", "wait_for": ["a"], "output": bad_output}
)
assert any("'output' must be a mapping" in e for e in errors)
def test_validate_accepts_mapping_or_absent_output(self):
from specify_cli.workflows.steps.fan_in import FanInStep
step = FanInStep()
assert step.validate(
{"id": "j", "wait_for": ["a"], "output": {"joined": "{{ x }}"}}
) == []
assert step.validate({"id": "j", "wait_for": ["a"]}) == []
class TestFanOutConcurrency:
"""Fan-out honors max_concurrency (WorkflowEngine._run_fan_out)."""
@@ -3977,6 +4101,48 @@ steps:
assert state.status == RunStatus.ABORTED
assert "should-not-run" not in state.step_results
def test_gate_reject_matches_case_insensitively(
self, project_dir, monkeypatch
):
"""A capitalised reject option (`options: [Approve, Reject]`) still
aborts the run. `validate` accepts a reject choice case-insensitively,
so the runtime reject check must agree — a case-sensitive comparison
would treat the echoed `Reject` as approval and silently run
downstream steps.
"""
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
from specify_cli.workflows.base import RunStatus
from specify_cli.workflows.steps.gate import GateStep
# `_prompt` echoes the option's original casing, so the operator
# picking "Reject" hands `execute` the capitalised string.
_force_gate_stdin(monkeypatch, tty=True)
monkeypatch.setattr(
GateStep, "_prompt", staticmethod(lambda _msg, _opts: "Reject")
)
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "gate-reject-case"
name: "Gate Reject Case"
version: "1.0.0"
steps:
- id: gate-step
type: gate
message: "Approve?"
options: [Approve, Reject]
on_reject: abort
- id: should-not-run
type: shell
run: "echo nope"
""")
engine = WorkflowEngine(project_dir)
state = engine.execute(definition)
assert state.status == RunStatus.ABORTED
assert "should-not-run" not in state.step_results
def test_validation_rejects_non_bool_continue_on_error(self):
"""`continue_on_error` must be a literal boolean; coerced
strings like `"true"` are rejected at validation time so
@@ -5427,6 +5593,23 @@ class TestWorkflowRemoveGuard:
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."""
from typer.testing import CliRunner
from specify_cli import app
(temp_dir / ".specify").mkdir(exist_ok=True)
monkeypatch.chdir(temp_dir)
result = CliRunner().invoke(
app,
["workflow", "add", "https://[::1/wf.yaml"],
catch_exceptions=True,
)
assert result.exit_code == 1
assert result.exception is None or isinstance(result.exception, SystemExit)
assert "Invalid URL" in result.output
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
def test_add_refuses_symlinked_specify(self, temp_dir, monkeypatch):
"""workflow add must refuse a symlinked .specify (writes could escape root)."""

View File

@@ -69,3 +69,30 @@ def test_http_fetch_rejects_non_https_final_url(monkeypatch):
fetcher = adapters.make_catalog_fetcher(allow_network=True)
with pytest.raises(BundlerError, match="must use HTTPS"):
fetcher(_source("https://example.com/c.json"))
@pytest.mark.parametrize(
"url",
[
"https://:8080", # port only, no host
"https://:0",
"https://user@", # userinfo only, no host
"https://user:pw@",
"https://:8080/catalog.json",
],
)
def test_validate_remote_url_rejects_host_less_urls(url):
"""A URL with a truthy netloc but no host (``https://:8080``,
``https://user@``) must be rejected.
``urlparse`` gives these a non-empty ``netloc`` but ``hostname is None``,
so a ``netloc`` check would wrongly accept them. This mirrors the fix in
``specify_cli.catalogs`` (#3210), which the docstring says this validator
mirrors."""
with pytest.raises(BundlerError, match="valid URL with a host"):
adapters._validate_remote_url("team", url)
def test_validate_remote_url_accepts_normal_https_url():
# Sanity: a real host with a port still passes.
adapters._validate_remote_url("team", "https://example.com:8080/c.json")

View File

@@ -222,3 +222,39 @@ def test_add_source_allows_local_path_with_colon(tmp_path: Path, monkeypatch):
# A relative path containing ':' but no '://' is still a local path.
source = cc.add_source(project, "weird:name.json", policy="install-allowed", priority=50)
assert source.url.endswith("weird:name.json") or "weird" in source.url
def test_add_source_rejects_plain_http_for_non_localhost(tmp_path: Path):
project = tmp_path / "proj"
(project / ".specify").mkdir(parents=True)
with pytest.raises(BundlerError, match="HTTPS"):
cc.add_source(project, "http://example.com/catalog.json", policy="install-allowed", priority=50)
def test_add_source_allows_http_for_localhost(tmp_path: Path):
project = tmp_path / "proj"
(project / ".specify").mkdir(parents=True)
source = cc.add_source(project, "http://localhost:8080/c.json", policy="install-allowed", priority=50)
assert source.url == "http://localhost:8080/c.json"
def test_add_source_rejects_host_less_remote_urls(tmp_path: Path):
project = tmp_path / "proj"
(project / ".specify").mkdir(parents=True)
for url in ("https://:8080", "https://user@"):
with pytest.raises(BundlerError, match="host"):
cc.add_source(project, url, policy="install-allowed", priority=50)
def test_add_source_wraps_invalid_ipv6_as_bundler_error(tmp_path: Path):
project = tmp_path / "proj"
(project / ".specify").mkdir(parents=True)
with pytest.raises(BundlerError, match="Invalid catalog url"):
cc.add_source(project, "https://[::1/c.json", policy="install-allowed", priority=50)
def test_remove_source_does_not_crash_on_invalid_ipv6(tmp_path: Path):
project = tmp_path / "proj"
(project / ".specify").mkdir(parents=True)
with pytest.raises(BundlerError, match="No project-scoped catalog source"):
cc.remove_source(project, "https://[::1/c.json")