Compare commits

..

41 Commits

Author SHA1 Message Date
github-actions[bot]
d3a3888c3a chore: bump version to 0.14.0 2026-07-23 13:57:26 +00:00
Manfred Riem
7cd97e47f0 docs: add spec-kit-copilot to community friends (#3675)
* docs: add spec-kit-copilot to community friends

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

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: c4ad8a5e-3ddb-44b2-b8ec-4f438c636b61

* docs: note first-party exception for spec-kit-copilot

Clarify the page disclaimer so it covers first-party GitHub projects, and
mark spec-kit-copilot as a first-party GitHub project.

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

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: c4ad8a5e-3ddb-44b2-b8ec-4f438c636b61

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: c4ad8a5e-3ddb-44b2-b8ec-4f438c636b61
2026-07-23 08:50:33 -05:00
Ali jawwad
93dbf6d575 fix(integrations): recompute invoke_separator from retained parsed_options (#3664)
with_integration_setting recomputed invoke_separator from the raw
parsed_options argument. When only script_type changes (parsed_options and
raw_options both None), the previously-stored parsed_options are retained on
the setting, but the separator was derived from the None argument — dropping
an options-dependent separator (e.g. Copilot --skills -> "-") back to the
default ".", desynchronizing invoke_separator from the stored options.

Derive the separator from current.get("parsed_options") — the options
actually stored after the update — so it stays consistent in every branch.

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 08:44:55 -05:00
Ali jawwad
4fc0a5b06e fix(workflows): preserve intra-overlay order for multiple insert_after edits (#3662)
_traverse_and_apply's insert_after loop iterated reversed(edits) over the
flat per-anchor edit list. The reversal is only meant to place a
higher-priority OVERLAY closer to the anchor (mirroring insert_before's
winner-closest behaviour), but reversing the flat list also flipped the
declared order of multiple insert_after edits authored within a SINGLE
overlay: [insert_after a->x, insert_after a->y] produced [a, y, x, b]
instead of [a, x, y, b]. insert_before (a forward loop) already preserves
order, so the two operations were asymmetric.

Group contiguous same-layer edits and reverse the GROUP order only, keeping
each overlay's own inserts in declared order. Cross-overlay priority is
unchanged (higher-priority overlay still lands closest to the anchor).

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 08:27:29 -05:00
Ali jawwad
0a7f288ae4 fix(bundler): reject falsy non-mapping requires/provides in manifest from_dict (#3661)
* fix(bundler): reject falsy non-mapping requires/provides in manifest from_dict

BundleManifest.from_dict used `data.get("requires") or {}` and
`data.get("provides") or {}`, so a FALSY non-mapping value ([], '', 0,
false) was coerced to {} BEFORE the isinstance guard — a malformed manifest
passed validation as one that requires/provides nothing. Only a truthy
non-mapping (e.g. "extensions") was rejected.

Handle None explicitly (default to {}) and reject every other non-mapping,
matching the sibling 'integration' guard added in #3629. Absent fields still
parse to the empty default.

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

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

* test(bundler): correct absent-optional-mapping regression assertion

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 08:23:51 -05:00
Ali jawwad
5e384bb9f5 fix(bundler): dump_yaml writes literal UTF-8 (allow_unicode=True) (#3660)
dump_yaml called yaml.safe_dump without allow_unicode=True, so non-ASCII
content was written as \xNN / \uXXXX escapes instead of literal UTF-8 — a
round-trip readability loss for bundle config. The centralized helper
_utils.dump_frontmatter and the extensions/presets config writers all pass
allow_unicode=True; align dump_yaml with them.

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 08:12:26 -05:00
Quratulain-bilal
e4cfa4c19c fix(integrations): declare kiro-cli multi-install safe (#3477)
* tidy kiro-cli multi-install-safe declaration + add docs row and test

kiro-cli is declared multi_install_safe (it uses a fully isolated .kiro/
root, .kiro/prompts command dir, a stable '.' separator, and a dedicated
manifest — #3471). main ended up with the flag assigned twice in the class
body; this drops the bare duplicate and keeps a single declaration with the
comment explaining why it's safe.

also adds the missing kiro-cli row to the multi-install-safe table in
docs/reference/integrations.md, and a test asserting the flag is set (the
registry contract tests already enforce the actual path isolation against
every other safe integration).

* address review: drop agent-file column and duplicate test

per maintainer guidance on #3477: the Isolation table listed each
integration's agent-context file (AGENTS.md, CLAUDE.md, etc.) alongside
its command dir, which contradicted the safety definition (Copilot flagged
kiro-cli/codex both mapping to AGENTS.md). those context files are owned by
the optional agent-context extension, not by multi_install_safe — the flag
governs only the command directory + manifest.

- renamed the column to 'Command directory' and removed the agent-file
  entries, so it lists only what each integration actually manages
- reworded the definition to note agent-context is a separate concern and
  is not multi-install safe
- removed the duplicate test_declared_multi_install_safe (the existing
  test_declares_multi_install_safe already asserts the same thing)

* address review: keep agent-root requirement; reframe agent-context targeting

- Restore 'static, unique agent root' alongside command directory in the
  multi-install-safe definition — base.py and test_registry.py
  (test_safe_integrations_have_distinct_agent_roots) enforce both.
- Reframe the agent-context note: multi_install_safe is an integration-level
  declaration about command/skill paths, so describe context-file targeting
  as independent of it rather than calling the extension 'not multi-install
  safe'. The extension can even sync multiple anchors via context_files.

* address review: hoist multi_install_safe to top of class

Move the multi_install_safe = True declaration to the top of
KiroCliIntegration (right after key) so it is visible at the exact spot
the diff touched. The flag was never actually removed — it was declared
once further down the class — but placing it at the top makes the opt-in
unmistakable in review and keeps the single declaration. Verified the
integration still resolves multi_install_safe is True; 24 kiro-cli tests
pass.
2026-07-23 07:41:07 -05:00
Quratulain-bilal
6e8623bbd7 fix(git-extension): trim trailing whitespace before stripping commit-message quotes (#3673)
The auto-commit bash and Python twins strip a leading/trailing quote from
the configured `message:` value with an end-of-string-anchored quote strip.
When the YAML value has trailing whitespace after the closing quote
(`message: "Done"  `), the close-quote strip is anchored to end-of-string,
so it never matches the quote (spaces follow it). The commit message then
keeps a dangling quote and trailing spaces (`Done"  `).

The PowerShell twin already .Trim()s before stripping, so it produced the
clean `Done`. This left the three script variants out of parity. Trim the
value before stripping quotes in the bash and Python twins so all three
agree.

Verified at the exact-code level: the old bash sed pipeline yields
`spec done"  ` and the new one `spec done`; the Python _strip_quotes matches.
Add a parity regression test with trailing whitespace after the closing
quote (runs under CI where Git bash is resolvable).
2026-07-23 07:17:58 -05:00
Ali jawwad
370551ea89 fix(bundler): order bundle members by canonical POSIX arcname (reproducible builds) (#3658)
_collect_files returned sorted(collected), i.e. pathlib.Path order, which is
platform-dependent: on Windows PurePath compares case-folded with backslash
separators, whereas the zip member NAMES are the canonical POSIX arcnames
(build_bundle: file_path.relative_to(bundle_dir).as_posix()). So the same
bundle built on Windows vs Linux/macOS produced archives whose members were
laid out in different order — not byte-for-byte identical across build hosts,
contradicting the packager's reproducible-build guarantee (fixed timestamps +
canonical modes).

Order by the same canonical POSIX-arcname key used to name members, so member
order is host-independent.

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 07:03:29 -05:00
Ali jawwad
38eb2fcc4b fix(integrations): Cline overrides post_process_command_content (correct hook name) (#3657)
ClineIntegration defined its command-content transform as
post_process_content, but the overridable base hook is
IntegrationBase.post_process_command_content, which
CommandRegistrar.register_commands() dispatches to for every non-skills
integration. Because the names differed, Cline's method never overrode the
base hook, so extension/preset command files registered for Cline silently
ran the base no-op and never received Cline's dot-to-hyphen hook-command
note (_inject_hook_command_note) — the note that tells the agent to replace
dots with hyphens when invoking hook commands. (Handoff references are
already hyphenated independently by the registrar's _hyphenate_body_refs,
so that transform was unaffected; renaming simply makes Cline's own copy
run too, harmlessly, since both are idempotent.)

Rename to post_process_command_content (matching the base hook and the
post_process_skill_content convention used by claude/copilot/agy/kimi/
droid/vibe) and update the single internal caller in setup(). Cline's own
setup() post-processing of core commands is unchanged. No test referenced
the old name.

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 16:26:24 -05:00
Ali jawwad
37041087dd docs(workflows): gate step docstring lists the 'retry' on_reject behaviour (#3656)
The GateStep docstring said on_reject "controls abort / skip behaviour",
omitting the third value. validate() accepts 'abort', 'skip', or 'retry',
and execute() has a dedicated retry branch (returns PAUSED so the next
resume re-runs the gate) distinct from abort (FAILED) and skip (COMPLETED).
Add 'retry' to the docstring so it matches the same file's validate() and
execute() authority.

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 15:50:58 -05:00
Pascal THUET
3a7a8758f7 fix: harden bounded reads and redirect validation (#3671)
Assisted-by: OpenAI Codex (model: GPT-5, autonomous)
2026-07-22 15:18:10 -05:00
Martin Chamberlin
c0f4cee25a fix(packaging): bundle scripts/python into the wheel core_pack (#3665) (#3670)
`specify init --script py` generated skills that invoke
`python3 .specify/scripts/python/<name>.py`, but the wheel's
force-include list only carried `scripts/bash` and `scripts/powershell`.
Installs from PyPI/Homebrew therefore shipped commands pointing at files
that were never packaged, leaving `--script py` non-functional while
`sh`/`ps` kept working.

Force-include `scripts/python` alongside the other two variants, and add
a contract test that asserts every script variant present in the repo is
bundled, so a future variant cannot be dropped the same way.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 14:50:19 -05:00
Manfred Riem
93fc533d79 fix: bundle scripts/python in wheel so --script py works (#3665) (#3668)
The wheel force-include mapped scripts/bash and scripts/powershell into
core_pack but omitted scripts/python. As a result, `specify init
--script py` laid down commands referencing
.specify/scripts/python/*.py while installing bash scripts, breaking
every command at its first setup step.

Add the scripts/python force-include mapping to mirror the other script
variants so the core Python scripts ship in the packaged wheel.

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


Copilot-Session: 1bc9c590-2c0c-4d88-bf3c-23f265cef82d

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-07-22 14:22:30 -05:00
Ali jawwad
3356161d88 docs(workflows): init step docstring lists the 'py' script type (#3655)
The InitStep `script` field docstring claimed only 'sh' or 'ps', but the
step's own VALID_SCRIPT_TYPES = tuple(SCRIPT_TYPE_CHOICES.keys()) is
('sh', 'ps', 'py') and validate() accepts all three (its error message is
built from VALID_SCRIPT_TYPES). Update the docstring to list 'py' too, so
it no longer contradicts the same class's validate() authority.

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 13:54:53 -05:00
Ali jawwad
9fb467f8de fix(integrations): declare LingmaIntegration multi_install_safe (#3654)
LingmaIntegration writes only to its isolated, static root .lingma/skills,
disjoint from every other integration, yet never declared
multi_install_safe — inheriting the IntegrationBase default False and
leaving `specify integration status` in a permanent unsafe-multi-install
ERROR state when lingma is co-installed alongside another agent.

Add `multi_install_safe = True`, mirroring the structurally-identical
trae/zcode SkillsIntegrations and the kiro-cli #3471 fix. The parametrized
registry isolation contracts auto-include lingma and pass.

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 13:37:37 -05:00
Ben Buttigieg
8c816fac40 fix: guard constitution command against feature execution (#3646)
* fix: guard constitution command scope

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

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

Copilot-Session: be3f0d7d-2774-4ba2-b741-efbb4870148a

* fix: render deferred command per integration

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

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

Copilot-Session: be3f0d7d-2774-4ba2-b741-efbb4870148a

* fix: defer all lean non-governance intents

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

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

Copilot-Session: be3f0d7d-2774-4ba2-b741-efbb4870148a
2026-07-22 18:27:37 +01:00
Ben Buttigieg
fb7dc0c4d6 Fix duplicate step numbering in specify command (#3647)
* fix: correct specify step numbering

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
2026-07-22 18:06:59 +01:00
Manfred Riem
a5560fcf13 docs(scripts): document the 'py' script type and sh/ps migration plan (#3284) (#3653)
* docs(scripts): document the 'py' script type and sh/ps migration plan (#3284)

Bring remaining docs up to date with the Python (`py`) workflow-script
variant introduced in #3277, and record the retention/deprecation plan
for the shell variants.

- AGENTS.md: document the `scripts:` frontmatter (sh/ps/py), clarify the
  `{SCRIPT}` placeholder resolution, and add a "Script Types and
  Migration" section (why py is recommended, defaults, phased sh/ps
  deprecation path). Note the Python agent-context variant.
- docs/quickstart.md, docs/local-development.md: mention the `py` variant
  and `--script sh|ps|py`.
- docs/reference/integrations.md: add `py` to the `--script` rows for
  install/switch/upgrade.
- .devcontainer/devcontainer.json: auto-approve `.specify/scripts/python/`.

Closes #3284.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 298d6ec2-a330-49bc-9394-fe2b77f25ff3

* docs(scripts): address review — accurate paths, prompt behavior, scoped py claims

Addresses the review on PR #3653:

- AGENTS.md: fix the agent-context Python path to its real
  `extensions/agent-context/scripts/python/` location.
- AGENTS.md: qualify that only templates that invoke a helper script
  carry `scripts:` frontmatter (constitution/specify do not).
- AGENTS.md: narrow the availability claim — `py` covers the core
  command templates; the bundled extensions ship Python scripts on disk
  but their command templates still invoke shell variants, so `--script
  py` does not yet route extension commands to Python.
- AGENTS.md / quickstart / local-development: describe the interactive
  prompt vs. non-interactive OS default instead of "auto-selects".
- local-development: add `--script py` to the wrong-script-type fix.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 298d6ec2-a330-49bc-9394-fe2b77f25ff3

* docs(scripts): trim deprecation timeline, correct parity/scope claims (review)

Addresses the second review on PR #3653:

- Remove the speculative four-phase deprecation timeline from AGENTS.md.
  A forward-looking removal schedule is roadmap content, not contributor
  guidance, and its phases lacked an actionable adoption signal. Replace
  it with the concrete contributor parity rule plus a one-line
  current-posture note pointing removal work to the #3277 epic.
- Stop stating dual-maintenance as already eliminated: reframe "single
  source of truth" as the intended direction, noting all three variants
  are still maintained in parallel today.
- Correct the parity-coverage claim: Python ports have output-parity
  tests where the contract is stdout-based and unit tests elsewhere,
  rather than every file being compared to every shell counterpart.
- Scope the `scripts:` frontmatter rule to core command templates and
  note the agent-context/git extension templates don't use it yet.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 298d6ec2-a330-49bc-9394-fe2b77f25ff3

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-07-22 11:46:11 -05:00
Pascal THUET
5601830ba3 harden: bound HTTP reads and enforce strict redirects (#3140)
* harden: bound HTTP reads and enforce strict redirects

Add a shared _download_security module (read_response_limited,
is_https_or_localhost_http, size constants) and route the GitHub release
and Azure DevOps token network reads through bounded reads so an oversized
response can't exhaust memory.

Add a strict_redirects mode to authentication.open_url: the redirect handler
now rejects any redirect whose target isn't HTTPS (or HTTP to localhost),
composing with the existing per-hop redirect_validator and auth-stripping.
The Azure DevOps token POST is routed through that handler so a 307/308
cannot forward the client_secret body to a non-HTTPS host.

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

* test: align HTTP fakes with bounded reads

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

* fix: tolerate invalid token response encoding

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

* test: align GHES fakes with bounded reads

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

* test: reuse shared upgrade HTTP response helper

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

* fix: include rejected redirect target in error

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

* fix: enforce strict redirects by default

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

* fix: close redirect credential and SSRF gaps

Assisted-by: Codex (model: GPT-5, autonomous)
2026-07-22 11:08:32 -05:00
Manfred Riem
0f6ea64a03 chore: release 0.13.4, begin 0.13.5.dev0 development (#3649)
* chore: bump version to 0.13.4

* chore: begin 0.13.5.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-22 10:01:05 -05:00
Manfred Riem
bb5a2c5424 docs(concepts): document the spec-of-specs feature breakdown approach (#3648)
* docs(concepts): document the spec-of-specs feature breakdown approach (#3423)

Add a dedicated "Spec of Specs" concept page describing how to decompose a
large feature into a roadmap of smaller, independently-specified sub-features
using the existing Spec Kit flow. Covers the roadmap pass, the roadmap
artifact template, specifying each sub-feature, bidirectional sub-spec/roadmap
linking, keeping them in sync, a worked example, and optional automation.

Link the new page from the "Handling Complex Features" decomposition section
and add it to the docs table of contents.

Closes #3423

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f236bc38-a7c6-4063-a79c-6ba81aa685b5

* Potential fix for pull request finding

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

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-22 09:30:17 -05:00
Ali jawwad
8db722842f fix(scripts): git-ext PowerShell emits the '# To persist' SPECIFY_FEATURE hint (parity) (#3632)
* fix(scripts): git-ext PowerShell emits the '# To persist' SPECIFY_FEATURE hint (parity)

The Git extension's create-new-feature-branch.ps1 printed a non-JSON hint
'SPECIFY_FEATURE environment variable set to: <name>', diverging from every
twin: the bash (create-new-feature-branch.sh) and python
(create_new_feature_branch.py) siblings of the same extension, and the core
create-new-feature.ps1, all emit '# To persist in your shell:
$env:SPECIFY_FEATURE = '<name>''. The old wording is also misleading —
$env:SPECIFY_FEATURE is set only in this child process and never reaches the
agent's shell, so the actionable output is the persist hint. Mirror the core PS
twin's $featureAssignment construction and message.

Test (pwsh CI): the non-JSON output uses the '# To persist in your shell:' form
(fails before: old wording). Verified end-to-end via powershell.exe.

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

* docs(test): fix doubled apostrophe in persist-hint test docstring

Address review: the docstring rendered the documented output form as
'<name>'' (two trailing apostrophes) instead of '<name>'. Docstring-only;
the assertions were already correct.

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

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 09:00:23 -05:00
Ali jawwad
a6743ab5e0 fix(integrations): validate cached catalog shape before returning it (#3627)
* fix(integrations): validate cached catalog shape before returning it

The catalog cache-read branch returned json.loads(cache_file) directly, skipping
the shape validation the fresh-fetch branch enforces (dict root + 'integrations'
mapping). A poisoned or older-format cache (e.g. {"integrations": []}) was
therefore returned as-is and later crashed with 'AttributeError: list object has
no attribute items' when the caller iterated integrations. Validate the cached
object the same way; the raised ValueError is already caught by the surrounding
handler, which drops the corrupt cache and refetches from source.

Test: a fresh-but-mis-shaped cache is dropped and the valid source refetched
(fails before: AttributeError).

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

* fix(integrations): share one catalog-shape validator across cache and fetch

Address review: the cache-read path checked only that the payload was a
dict with a dict 'integrations', while the fresh-fetch path also required
'schema_version'. That asymmetry let an older/poisoned cache such as
{"integrations": {}} (no schema_version) bypass the format contract
instead of being dropped and refetched.

Introduce a shared `_catalog_shape_error()` helper and use it in both
paths so they enforce the same contract (dict + schema_version + dict
integrations). The fresh path still raises IntegrationCatalogError with
the "Invalid catalog format from <url>" prefix; the cache path still
raises ValueError (caught to drop+refetch). Add a test for the
missing-schema_version cache case.

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

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

* test(integrations): unit-test the shared catalog-shape validator directly

Replace the integration-level missing-schema_version cache test (which
was masked by multi-source merging — a sibling catalog source still
supplied the entry, so it passed regardless of the fix) with a direct
unit test of _catalog_shape_error. This deterministically proves both
paths now reject a payload missing schema_version, a non-dict
integrations, or a non-dict payload, and accept a well-formed one.

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

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 08:57:39 -05:00
Ali jawwad
3b9deeca69 fix(bundler): reject non-list 'catalogs' in bundle-catalogs.yml with a clean error (#3623)
* fix(bundler): reject non-list 'catalogs' in bundle-catalogs.yml with a clean error

_merge_config guarded only 'if not catalogs: return', so a non-empty scalar
(catalogs: 5) passed through and raised a raw 'TypeError: int object is not
iterable' from the loop below — escaping the module's BundlerError error
contract. The sibling reader of the same file (commands_impl/catalog_config.py,
used by 'bundle catalog list') already raises an actionable BundlerError for the
identical mis-shape. Add the same isinstance(list) guard so both readers of
bundle-catalogs.yml agree.

Test: 'catalogs: 5' raises BundlerError('...must be a list...') (fails before:
raw TypeError).

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

* fix(bundler): reject falsy non-list 'catalogs' too (is None, not falsy)

Address review: the isinstance guard sat after `if not catalogs: return`,
so falsy non-list values (`catalogs: false`, `0`, `''`, `{}`) hit the
early return and were silently accepted instead of raising the promised
BundlerError. Only an absent/None value means "nothing to merge".

Change the early return to `if catalogs is None`, mirroring the sibling
reader (commands_impl/catalog_config._read). An empty list stays valid
(the merge loop is a no-op). Add parametrized tests for the falsy
non-list cases and for the absent/empty-list no-op.

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

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 08:49:20 -05:00
Noor ul ain
bd90f766fb fix(bundler): guard lazy .hostname ValueError in catalog add_source (#3644)
A bracketed-but-invalid IPv6 authority (e.g. "https://[not-an-ip]/c.json")
parses cleanly under urlparse() on Python < 3.14 and only raises ValueError
lazily on the first .hostname access. add_source read parsed.hostname
outside the try/except ValueError guard, so on the interpreters spec-kit
supports (>=3.11) that raw ValueError leaked past the CLI's
`except BundlerError`, surfacing an uncaught traceback instead of the clean
"Invalid catalog url" domain error. (The raise moved eager into urlparse()
only in 3.14.)

Read parsed.hostname inside the try and reuse the value for both the
HTTPS/localhost check and the require-host check. This also protects the
later _derive_id() call on the same URL.

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 08:21:02 -05:00
github-actions[bot]
b91e30a113 Add Intake Authoring Governance preset to community catalog (#3643)
Add intake-authoring-governance preset submitted by @hindermath to:
- presets/catalog.community.json (alphabetical order)
- docs/community/presets.md community presets table

Closes #3621

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-22 08:12:53 -05:00
Nurfitra Pujo Santiko
470ac5b6e6 feat: add Factory Droid CLI integration (#822) (#3587)
Adds a skills-based integration for the Factory Droid CLI alongside the
existing Claude/Codex skills agents. The integration scaffolds
`.factory/skills/speckit-*` directories and documents the install step
in the devcontainer post-create script via the official npm
distribution (`npm install -g droid`), which matches the layout of every
other CLI install block above and avoids executing an unverified remote
shell installer. The integration is also added to the user-facing
supported-agent table in `docs/reference/integrations.md` so the new key
is discoverable from the published documentation, as required by the
"Updating this documentation" guideline in AGENTS.md.

Operator-supplied extra args via `SPECKIT_INTEGRATION_DROID_EXTRA_ARGS`
are appended after the canonical Spec Kit flags so the canonical flags
are always present in argv. The Factory Droid CLI parser uses
last-wins duplicate-flag semantics (verified empirically against
droid 0.175.0), so a later operator-supplied value may override the
canonical one — this is a deliberate inversion of the cursor-agent /
opencode / codex ordering.

Includes:
- `src/specify_cli/integrations/droid/__init__.py` (subpackage)
- `tests/integrations/test_integration_droid.py` (46 tests, including
  regression coverage for the no-trailing-newline frontmatter fusion
  bug, idempotent skill injection, and env-var path resolution)
- `integrations/catalog.json` entry + `updated_at` bump
- Alphabetical registration in `src/specify_cli/integrations/__init__.py`
  and `tests/integrations/test_registry.py`
- Devcontainer Droid install block via the npm distribution
  (`npm install -g droid`), replacing the earlier curl-based installer
- User-facing supported-agent table row in
  `docs/reference/integrations.md` (key `droid`, `.factory/skills/`
  layout, `/speckit-<command>` invocation)
- `AGENT_CONFIG` entry and matching alphabetical entries in
  `tests/test_agent_config_consistency.py` (`ISSUE_TEMPLATE_AGENT_KEYS`)
  and the three issue-template dropdowns (`agent_request.yml`,
  `bug_report.yml`, `feature_request.yml`) so
  `test_issue_template_agent_lists_match_runtime_integrations` keeps
  the runtime/template surfaces synchronized

Closes #822

Assisted-by: Droid (oracle-reviewer)

Assisted-by: Droid (model: MiniMax M3, autonomous)

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-07-22 07:49:48 -05:00
Ali jawwad
11ef1b35e2 docs(installation): document the 'py' (Python) script type (#3640)
* docs(installation): document the 'py' (Python) script type

The installation guide's "Specify Script Type" section only showed
`--script sh` and `--script ps`, and the installed-variant list only
mentioned `.specify/scripts/bash/` and `.specify/scripts/powershell/`.
The CLI has a third script type, `py`: `SCRIPT_TYPE_CHOICES` in
`_agent_config.py` includes `"py": "Python"`, and `shared_infra.py`
installs a `python/` variant directory (plus the platform shell fallback)
when `--script py` is chosen.

Add the missing `--script py` force example and a `.specify/scripts/python/`
bullet so the docs match the code. Docs-only.

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

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

* docs(installation): frame Python as a first-class third script type

Address review feedback: update the section heading and intro so Python
is presented as a first-class script type alongside Shell and PowerShell,
matching the added --script py example and python/ variant directory.

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

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 07:47:52 -05:00
Ali jawwad
5674fd03a9 fix(init): show hyphenated /speckit-<name> in Next Steps for Forge projects (#3642)
The post-init "Next Steps" panel renders recommended slash commands via
the nested `_display_cmd()`. It special-cased dollar-skills agents, kimi,
slash-skills agents, and cline, but not Forge. For a Forge project
`_display_cmd` fell through to `return f"/speckit.{name}"`, printing
`/speckit.constitution`, `/speckit.specify`, etc.

Forge only registers the hyphenated form (`/speckit-<name>`, per
`format_forge_command_name` / `ForgeIntegration.build_command_invocation`,
and the generated command-file tests already assert this), so the panel
told Forge users to run commands that don't exist under the dotted name.

Add `forge_skill_mode` alongside `cline_skill_mode` and include it in the
hyphenated-slash condition, mirroring how cline (also a non-skills
markdown agent with hyphenated commands) is handled. Other agents
unaffected.

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 07:46:45 -05:00
Ali jawwad
735fe0c5da fix(extensions): render hyphenated hook invocations for Forge projects (#3641)
Forge is a hyphenated slash-command agent: it registers its commands as
`/speckit-<name>` (see `format_forge_command_name` and
`ForgeIntegration.build_command_invocation`), exactly like Cline.

`HookExecutor._render_hook_invocation` special-cases dollar-skills agents,
kimi, cline, and slash-skills agents, but had no Forge branch. Forge
matches none of those, so it fell through to `return f"/{command_id}"`
and rendered the DOTTED form — `/speckit.plan`, `/speckit.git.commit` —
which Forge does not recognize as a registered command.

Add a Forge branch mirroring the adjacent Cline branch, using
`format_forge_command_name` (idempotent, same contract as the Cline
formatter). Non-Forge agents are unaffected.

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 07:45:51 -05:00
Ali jawwad
0add7131c9 fix(workflows): workflow add detects local YAML files case-insensitively (#3633)
`workflow add` gated the local-file branches on a case-SENSITIVE
`.suffix in (".yml", ".yaml")` (the `--dev` branch and the plain
local-path branch), while every other YAML-file detector in the CLI
normalizes case: `workflow run` uses `source_path.suffix.lower()` and
`WorkflowEngine.load_workflow` uses `path.suffix.lower()`.

The result was an add/run inconsistency: `specify workflow run Sample.YAML`
loads the file, but `specify workflow add Sample.YAML` does not recognize
it as a local workflow — the `--dev` branch rejects it with "--dev source
must be a workflow YAML file ..." and the plain path falls through to a
catalog lookup that fails with "not found in catalog".

Add `.lower()` to both suffix reads so `workflow add` matches its siblings.
The lowercase happy path is unchanged.

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 07:42:06 -05:00
Ali jawwad
cef00a1cb3 fix(workflows): list-literal expression ignores trailing/empty commas (#3631)
A workflow list-literal expression with a trailing (or leading/double) comma —
'{{ [1, 2,] }}' — evaluated to [1, 2, None]: _split_top_level_commas returns a
trailing empty segment, which _evaluate_simple_expression resolves as an empty
dot-path to None. That silently widens membership tests and renders a stray
None in joins. Python and Jinja2 both tolerate trailing commas.

Drop whitespace-empty segments from the comprehension. An intentional
empty-string element ('') survives because its segment strips to "''" (truthy),
so ['', 'a'] is preserved. Completes the quoted-comma handling from #3134.

Test: [1, 2,] and [1,, 2] -> [1, 2]; ['', 'a'] -> ['', 'a'] (fails before:
trailing None).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 07:40:13 -05:00
Ali jawwad
03f9013a7b fix(workflows): StepRegistry.add tolerates a corrupted non-dict existing entry (#3630)
StepRegistry.add read existing = self.data['steps'].get(step_id, {}) then called
existing.get('installed_at', ...). A corrupted-but-parseable registry holding a
non-dict entry (e.g. {'steps': {'foo': 'corrupted'}}) — which _load() accepts,
since it validates only the top-level dict and that 'steps' is a dict — made
add() raise AttributeError. WorkflowRegistry.add was hardened for exactly this
(#3419); mirror its isinstance guard so a non-dict existing entry is treated as
absent.

Test copies the WorkflowRegistry sibling test for StepRegistry (fails before:
AttributeError on existing.get()).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 07:39:27 -05:00
Ali jawwad
cbfb9f01f7 fix(bundler): reject non-mapping 'integration' in a bundle manifest (#3629)
BundleManifest.from_dict guarded 'requires' and 'provides' with
"must be a mapping when present", but a present-but-non-mapping 'integration'
(e.g. a bare string "copilot") silently failed the isinstance(dict) check and
was dropped — leaving the bundle wrongly integration-agnostic (is_agnostic()
True) instead of surfacing the authoring mistake. Add the same guard so
'integration' is consistent with its sibling mapping fields.

Test: integration='copilot' now raises BundlerError (fails before: silently
dropped, no raise).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 07:38:36 -05:00
Ali jawwad
aee9df00d4 fix(workflows): command/prompt steps fail cleanly on a non-string integration (#3626)
_try_dispatch guarded only 'if not integration_key', then called
get_integration(integration_key). A non-string integration (a list/dict, or an
expression like integration: "{{ steps.pick.output.agents }}" that resolves to a
list) reached the registry dict lookup and raised 'TypeError: unhashable type:
list', aborting the entire workflow run. Widen the guard to also require a str,
so a non-string integration is treated as not-dispatchable and execute() falls
through to its existing FAILED StepResult (unconfigured integration=None still
returns None as before). Applied to both command and prompt steps.

Tests: a list integration now yields a FAILED result (fail before: TypeError).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 07:35:39 -05:00
Ali jawwad
4f4d19ba93 docs(core): document the 'py' (Python) --script type in the init option table (#3625)
'specify init --script' accepts sh, ps, or py (init.py + SCRIPT_TYPE_CHOICES in
_agent_config.py), and 'specify init --script py' scaffolds Python scripts. The
core.md option reference listed only 'sh|ps', so a user following the canonical
docs never learns about --script py. Update the option row to sh|ps|py.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 07:34:43 -05:00
Ali jawwad
9ef477167d fix(workflows): gate prompt uses isdecimal() so a superscript digit doesn't crash (#3624)
The interactive gate prompt guarded numeric choices with raw.isdigit(), but
str.isdigit() returns True for characters int() rejects — superscripts/subscripts
like '²'. So typing '²' passed the guard and int('²') raised an uncaught
ValueError, crashing the prompt loop. Use raw.isdecimal(), which is exactly the
decimal-digit set int() accepts (Numeric_Type=Decimal), so such input is treated
as an invalid choice and re-prompted. No behavior change for valid input.

Test: input '²' then '1' returns the first option (fails before: ValueError).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 07:29:50 -05:00
Ali jawwad
d39f8fd5e5 fix(integrations): Cline dispatches hyphenated /speckit-<cmd> invocations (#3622)
Cline installs its slash-commands with hyphenated names (speckit-plan,
speckit-git-commit) via format_cline_command_name + the hyphenated
command_filename, but ClineIntegration inherited MarkdownIntegration's
build_command_invocation, which builds the dotted /speckit.<cmd> — a name Cline
never registered.

Add a build_command_invocation override reusing format_cline_command_name,
producing /speckit-<name>, mirroring the ForgeIntegration fix. Cline was the only
remaining markdown integration with invoke_separator='-' + hyphenated
command_filename that lacked the override.

Tests assert Cline core + extension invocations are hyphenated, incl. args
(fail before: dotted /speckit.plan / /speckit.git.commit).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 07:26:28 -05:00
Pascal THUET
914d7b887f docs(upgrade): document integration upgrade / extension update as the project-files upgrade path (#3326)
* docs: document manifest-aware project upgrades

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

* docs: clarify project upgrade commands

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

* docs: correct Claude skills path

Assisted-by: Codex (model: GPT-5, autonomous)
2026-07-22 07:25:49 -05:00
Manfred Riem
29877825ef chore: release 0.13.3, begin 0.13.4.dev0 development (#3645)
* chore: bump version to 0.13.3

* chore: begin 0.13.4.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-22 07:15:56 -05:00
87 changed files with 2885 additions and 390 deletions

View File

@@ -65,7 +65,8 @@
},
"chat.tools.terminal.autoApprove": {
".specify/scripts/bash/": true,
".specify/scripts/powershell/": true
".specify/scripts/powershell/": true,
".specify/scripts/python/": true
}
}
}

View File

@@ -97,6 +97,17 @@ echo -e "\n🤖 Installing CodeBuddy CLI..."
run_command "npm install -g @tencent-ai/codebuddy-code@latest"
echo "✅ Done"
echo -e "\n🤖 Installing Factory Droid CLI..."
run_command "npm install -g droid@latest"
if ! command -v droid >/dev/null 2>&1; then
echo -e "\033[0;31m[ERROR] Droid CLI installation did not create 'droid' in PATH.\033[0m" >&2
exit 1
fi
run_command "droid --version > /dev/null"
echo "✅ Done"
# Installing UV (Python package manager)
echo -e "\n🐍 Installing UV - Python Package Manager..."
run_command "pipx install uv"

View File

@@ -8,7 +8,7 @@ body:
value: |
Thanks for requesting a new agent! Before submitting, please check if the agent is already supported.
**Currently supported agents**: Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Grok Build, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed
**Currently supported agents**: Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Factory Droid, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Grok Build, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed
- type: input
id: agent-name

View File

@@ -71,6 +71,7 @@ body:
- Codex CLI
- Cursor
- Devin for Terminal
- Factory Droid
- Firebender
- Forge
- Gemini CLI

View File

@@ -65,6 +65,7 @@ body:
- Codex CLI
- Cursor
- Devin for Terminal
- Factory Droid
- Firebender
- Forge
- Gemini CLI

View File

@@ -187,7 +187,7 @@ context_markers:
end: "<!-- SPECKIT END -->"
```
- The Specify CLI does **not** write this config. When `context_file` is empty, the extension's bundled scripts self-seed it by looking up the active integration's key in the extension's own `agent-context-defaults.json` map (`extensions/agent-context/scripts/bash/update-agent-context.sh` and `.ps1`). The CLI registry is never consulted — all agent→context-file knowledge lives inside the extension.
- The Specify CLI does **not** write this config. When `context_file` is empty, the extension's bundled scripts self-seed it by looking up the active integration's key in the extension's own `agent-context-defaults.json` map (`extensions/agent-context/scripts/bash/update-agent-context.sh`, `.ps1`, and `extensions/agent-context/scripts/python/update_agent_context.py`). The CLI registry is never consulted — all agent→context-file knowledge lives inside the extension.
- `context_markers.{start,end}` are read solely by the extension's scripts; they default to the Spec Kit markers shown above and can be customized by editing `agent-context-config.yml` directly.
Existing projects created by older Spec Kit versions keep working: any previously written managed section or extension config is left intact and is only ever updated by the extension when run.
@@ -268,6 +268,25 @@ echo "✅ Done"
## Command File Formats
### Script References (`scripts:` frontmatter)
Core command templates (`templates/commands/*.md`) that invoke a helper script declare it in a `scripts:` frontmatter block with one line per supported script type. The `{SCRIPT}` placeholder in the command body is replaced at install time with the entry matching the project's selected script type (`--script sh|ps|py`):
```yaml
scripts:
sh: scripts/bash/setup-plan.sh --json
ps: scripts/powershell/setup-plan.ps1 -Json
py: scripts/python/setup_plan.py --json
```
| Key | Script type | Location |
| ---- | ---------------------- | -------------------------- |
| `sh` | POSIX shell (bash/zsh) | `scripts/bash/*.sh` |
| `ps` | PowerShell | `scripts/powershell/*.ps1` |
| `py` | Python | `scripts/python/*.py` |
All three entries must be present and behaviorally equivalent — agents parse the same stdout contract (`FEATURE_DIR:…`, `AVAILABLE_DOCS:…`, `--json` shapes) regardless of which one runs. (The bundled `agent-context` and `git` extension command templates also invoke helpers but do not yet use `scripts:` frontmatter — see [Script Types and Migration](#script-types-and-migration).)
### Markdown Format
**Standard format:**
@@ -328,9 +347,29 @@ Different agents use different argument placeholders. The placeholder used in co
- **TOML-based**: `{{args}}` (e.g., Gemini)
- **YAML-based**: `{{args}}` (e.g., Goose)
- **Custom**: some agents override the default (e.g., Forge uses `{{parameters}}`)
- **Script placeholders**: `{SCRIPT}` (replaced with actual script path)
- **Script placeholders**: `{SCRIPT}` (replaced with the resolved command from the template's `scripts:` frontmatter, per the project's `--script sh|ps|py` selection)
- **Agent placeholders**: `__AGENT__` (replaced with agent name)
## Script Types and Migration
Spec Kit ships every core workflow script in three interchangeable variants — POSIX shell (`sh`), PowerShell (`ps`), and Python (`py`) — selected per project with `specify init --script sh|ps|py`. Each core command template that invokes a helper script carries all three in its `scripts:` frontmatter (templates that don't call a script, e.g. `constitution`/`specify`, have no `scripts:` block); see [Script References](#script-references-scripts-frontmatter).
### Why Python is recommended
- **No extra runtime.** The `specify` CLI is already Python, so the interpreter is guaranteed present — `py` adds no new dependency.
- **Path toward a single source of truth.** The shell variants require paired `.sh` + `.ps1` maintenance and diverge on JSON handling (`jq` vs manual parsing). The Python variant avoids `jq` and is intended to eventually replace that dual-maintenance — but that consolidation has not happened yet: all three variants are still maintained in parallel (see the parity rule below).
- **Parity-tested.** The Python ports are covered by tests — output-parity tests against the shell scripts where the contract is stdout-based, and direct unit tests elsewhere — so the stdout contract agents rely on stays stable.
### Defaults and availability
- `py` is available today for the core command templates (via their `scripts:` frontmatter). The bundled extensions (`agent-context`, `git`) ship Python script variants on disk, but their command templates still hard-code the Bash/PowerShell invocations, so `--script py` does not yet route those extension commands to Python — wiring `py` into the extension command templates is tracked separately.
- Selection is per project: interactive `specify init` prompts for the script type, while non-interactive runs default to a shell variant by OS (`sh` on Linux/macOS, `ps` on Windows). `py` is chosen at the prompt or via `--script py`.
- `sh` and `ps` remain fully supported. Nothing is removed, and `py` is not yet the default.
### Parity rule for contributors
All three script types are first-class: any change to a workflow script must update `sh`, `ps`, and `py` together and keep their tests (parity and unit) green. Making `py` the default and eventually retiring `sh`/`ps` is future work gated on adoption, tracked under the script-unification epic ([#3277](https://github.com/github/spec-kit/issues/3277)) — not something to act on from this doc.
## Special Processing Requirements
Some agents require custom processing beyond the standard template transformations:

View File

@@ -2,6 +2,56 @@
<!-- insert new changelog below this comment -->
## [0.14.0] - 2026-07-23
### Changed
- docs: add spec-kit-copilot to community friends (#3675)
- fix(integrations): recompute invoke_separator from retained parsed_options (#3664)
- fix(workflows): preserve intra-overlay order for multiple insert_after edits (#3662)
- fix(bundler): reject falsy non-mapping requires/provides in manifest from_dict (#3661)
- fix(bundler): dump_yaml writes literal UTF-8 (allow_unicode=True) (#3660)
- fix(integrations): declare kiro-cli multi-install safe (#3477)
- fix(git-extension): trim trailing whitespace before stripping commit-message quotes (#3673)
- fix(bundler): order bundle members by canonical POSIX arcname (reproducible builds) (#3658)
- fix(integrations): Cline overrides post_process_command_content (correct hook name) (#3657)
- docs(workflows): gate step docstring lists the 'retry' on_reject behaviour (#3656)
- fix: harden bounded reads and redirect validation (#3671)
- fix(packaging): bundle scripts/python into the wheel core_pack (#3665) (#3670)
- fix: bundle scripts/python in wheel so --script py works (#3665) (#3668)
- docs(workflows): init step docstring lists the 'py' script type (#3655)
- fix(integrations): declare LingmaIntegration multi_install_safe (#3654)
- fix: guard constitution command against feature execution (#3646)
- Fix duplicate step numbering in specify command (#3647)
- docs(scripts): document the 'py' script type and sh/ps migration plan (#3284) (#3653)
- harden: bound HTTP reads and enforce strict redirects (#3140)
- chore: release 0.13.4, begin 0.13.5.dev0 development (#3649)
## [0.13.4] - 2026-07-22
### Changed
- docs(concepts): document the spec-of-specs feature breakdown approach (#3648)
- fix(scripts): git-ext PowerShell emits the '# To persist' SPECIFY_FEATURE hint (parity) (#3632)
- fix(integrations): validate cached catalog shape before returning it (#3627)
- fix(bundler): reject non-list 'catalogs' in bundle-catalogs.yml with a clean error (#3623)
- fix(bundler): guard lazy .hostname ValueError in catalog add_source (#3644)
- Add Intake Authoring Governance preset to community catalog (#3643)
- feat: add Factory Droid CLI integration (#822) (#3587)
- docs(installation): document the 'py' (Python) script type (#3640)
- fix(init): show hyphenated /speckit-<name> in Next Steps for Forge projects (#3642)
- fix(extensions): render hyphenated hook invocations for Forge projects (#3641)
- fix(workflows): workflow add detects local YAML files case-insensitively (#3633)
- fix(workflows): list-literal expression ignores trailing/empty commas (#3631)
- fix(workflows): StepRegistry.add tolerates a corrupted non-dict existing entry (#3630)
- fix(bundler): reject non-mapping 'integration' in a bundle manifest (#3629)
- fix(workflows): command/prompt steps fail cleanly on a non-string integration (#3626)
- docs(core): document the 'py' (Python) --script type in the init option table (#3625)
- fix(workflows): gate prompt uses isdecimal() so a superscript digit doesn't crash (#3624)
- fix(integrations): Cline dispatches hyphenated /speckit-<cmd> invocations (#3622)
- docs(upgrade): document integration upgrade / extension update as the project-files upgrade path (#3326)
- chore: release 0.13.3, begin 0.13.4.dev0 development (#3645)
## [0.13.3] - 2026-07-22
### Changed

View File

@@ -1,7 +1,7 @@
# Community Friends
> [!NOTE]
> Community projects listed here are independently created and maintained by their respective authors. They are **not reviewed, nor endorsed, nor supported by GitHub**. Review their source code before installation and use at your own discretion.
> Community projects listed here are independently created and maintained by their respective authors. Unless explicitly marked as a **first-party GitHub project**, they are **not reviewed, nor endorsed, nor supported by GitHub**. Review their source code before installation and use at your own discretion.
Community projects that extend, visualize, or build on Spec Kit:
@@ -16,3 +16,5 @@ Community projects that extend, visualize, or build on Spec Kit:
- **[cc-spec-kit](https://github.com/speckit-community/cc-spec-kit)** — Community-maintained plugin for Claude Code and GitHub Copilot CLI that installs Spec Kit skills via the plugin marketplace.
- **[spectatui](https://github.com/tinesoft/spectatui)** — A terminal UI (TUI) dashboard for Spec Kit that lets you track features, manage specifications, integrations, presets, workflows, and extensions, and monitor AI agent workflows. Attach to existing AI sessions or launch new ones from your terminal. Keyboard and mouse support. Light/dark theme support. Customizable and performance-oriented. Requires the `specify` CLI in your PATH.
- **[spec-kit-copilot](https://github.com/github/spec-kit-copilot)** — _First-party GitHub project._ A GitHub Copilot **skills plugin** that exposes the Spec Kit `specify` CLI to the Copilot agent in both the Copilot CLI and the GitHub Copilot app. It provides a focused skill per `specify` command group — setup, init, check, extensions, presets, bundles, workflows, workflow steps, and self-upgrade — so you can navigate and drive the entire Spec Kit ecosystem through natural language, letting Copilot decide when and how to run the right `specify` commands on your behalf.

View File

@@ -19,6 +19,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose principles. Supports interactive elements like brainstorming, interview, roleplay, and extras like statistics, cover builder, illustration builder, and bio command. Export with templates for KDP, D2D, etc. | 26 templates, 34 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
| Game Narrative Writing | Preset for game narrative design and interactive storytelling. It adapts the Spec-Driven Development workflow for game narratives: features become story mechanics, specs become narrative briefs, plans become story maps, and tasks become dialogue and scene-writing tasks. Supports branching narratives, player agency systems, state machines, and interactive dialogue trees. | 37 templates, 34 commands, 5 scripts | — | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-writing) |
| Intake Authoring Governance | Creates traceable Spec Kit intake files and receipts from ordered text sources while preserving clarification, update, and delivery-authority boundaries. | 7 templates, 2 commands, 2 scripts | — | [spec-kit-preset-intake-authoring-governance](https://github.com/hindermath/spec-kit-preset-intake-authoring-governance) |
| Intake Review Governance | Adds hash-bound review, repair, and status gates for single, series, and campaign intake files before interactive, autonomous, or parallel Spec Kit execution. | 8 templates, 3 commands, 2 scripts | — | [spec-kit-preset-intake-review-governance](https://github.com/hindermath/spec-kit-preset-intake-review-governance) |
| iSAQB Architecture Governance | Adds general iSAQB/CPSA-F and arc42 software-architecture governance, including audit-ready Spec Kit run evidence for architecture goals, views, quality scenarios, ADRs, risks, and technical debt. | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
| Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) |

View File

@@ -63,10 +63,14 @@ independently specified sub-features. Each sub-feature gets its own
`spec.md`, `plan.md`, and `tasks.md`, and runs through its own
specify/plan/tasks/implement cycle.
This is the "spec of specs" approach: the first iteration breaks a massive
feature into smaller, self-contained specs that can each be implemented without
overwhelming the model. It adds the most overhead, so reserve it for features
that are too large to handle any other way.
This is the "spec of specs" approach: a first pass breaks a massive feature into
smaller, self-contained specs that can each be implemented without overwhelming the
model. It adds the most overhead, so reserve it for features that are too large to
handle any other way.
See [Spec of Specs](spec-of-specs.md) for the full procedure — how to run the
roadmap pass, structure the roadmap artifact, link sub-specs back to it, and a worked
example.
## Which Approach to Choose

View File

@@ -0,0 +1,171 @@
# Spec of Specs
When a feature is too large to run through a single
`/speckit.specify``/speckit.plan``/speckit.tasks``/speckit.implement`
cycle without the model losing track mid-implementation, you can break it into a
**roadmap** of smaller, independently-specified sub-features. This is the "spec of
specs" approach: one up-front pass decomposes a massive feature into self-contained
specs, and each of those runs through its own specify/plan/tasks/implement cycle.
> **When to reach for this.** Decomposition adds the most overhead of any strategy
> in [Handling Complex Features](complex-features.md). Use it **only when the lighter
> options there are insufficient** — first try limiting how many tasks run per
> `/speckit.implement` invocation, then sub-agent delegation, then a combination.
> Reach for a spec of specs only when even a single phase is too large to handle in
> one run.
The rest of this page describes *how* to do it with the tools you already have. No
new commands or extensions are required.
## The roadmap pass
Before writing any sub-spec, do a single decomposition pass to produce a roadmap.
Treat this as a lightweight planning conversation with your agent, not a full spec:
1. **State the whole feature.** Describe the large feature (the "epic") in a
sentence or two so the agent has the full picture up front.
2. **Identify independent slices.** Ask the agent to propose a small set of
sub-features that each deliver a coherent piece of the epic and can be specified
on their own. Aim for slices that are independently testable — implementing just
one should leave you with something demonstrable.
3. **Draw the boundaries.** For each slice, write one line of intent and an explicit
scope boundary (what is in, what is deferred to a sibling slice). Sharp
boundaries are what keep each sub-spec small enough to fit in context.
4. **Order by dependency.** Note which slices depend on others and sequence them so
prerequisites come first. Slices with no dependency on each other can be built in
any order. To build independent slices in parallel, use separate worktrees so each
run has isolated active-feature state.
5. **Record the result as a roadmap.** Capture the slices in a durable roadmap file
(below) so every later sub-spec can point back to it.
The roadmap is deliberately shallow: it names and orders the sub-features but does
**not** design them. The design happens when each slice runs through its own
`/speckit.specify`.
## The roadmap artifact
The roadmap is an ordinary Markdown file you author and keep under version control —
there is no special tooling behind it. Put it where the sub-specs can find it:
- For a feature-scoped epic: `specs/<epic-slug>/roadmap.md`.
- For a larger, cross-cutting epic: a top-level `ROADMAP.md`.
Each roadmap entry carries a stable id (used later for linking), a name, its intent,
its scope boundary, its dependencies, a status, and — once the sub-spec exists — a
link to it. A minimal template:
```markdown
# Roadmap: <epic name>
<One or two sentences: what the epic is and why it is being decomposed.>
**Status legend**: planned · in-progress · done
| ID | Sub-feature | Intent | Scope boundary | Depends on | Status | Sub-spec |
|----|-------------|--------|----------------|-----------|--------|----------|
| R1 | <name> | <one line> | <in / deferred> | — | planned | — |
| R2 | <name> | <one line> | <in / deferred> | R1 | planned | — |
| R3 | <name> | <one line> | <in / deferred> | R1 | planned | — |
```
Keep the `ID` column immutable once a sub-spec references it — it is the anchor for
traceability. Fill in the `Sub-spec` column with the path to each sub-feature's spec
directory as you create it, and update `Status` as work progresses.
## Specifying each sub-feature
With the roadmap in hand, work through the entries one at a time using the normal
Spec Kit flow — nothing new to learn:
1. Pick the next roadmap entry whose dependencies are already `done` (or have none).
2. Run `/speckit.specify` for just that slice, describing only its intent and scope
from the roadmap entry. Because the slice is bounded, its spec, plan, and tasks
stay well within the context window.
3. Run `/speckit.plan`, `/speckit.tasks`, and `/speckit.implement` for that slice as
usual.
4. Mark the roadmap entry `done` and move to the next one.
Each slice is a complete, independent Spec Kit feature with its own
`spec.md`/`plan.md`/`tasks.md`. The roadmap is what ties them together.
## Linking sub-specs to the roadmap
To keep scope and intent from drifting across separate runs, every sub-spec
references its roadmap entry, and the roadmap links back — a simple, greppable,
bidirectional convention:
- **Sub-spec → roadmap.** In the sub-feature's `spec.md`, name the parent roadmap
and entry id in the `Input` / summary line, for example:
```markdown
**Input**: Parent roadmap: `specs/<epic>/roadmap.md` → entry **R3**. <feature description>
```
- **Roadmap → sub-spec.** In the roadmap table, set the entry's `Sub-spec` column to
the sub-feature's directory, e.g. `specs/<epic>-part-3/`.
Because both directions are plain text, you can trace any sub-spec back to its place
in the epic (and find its siblings) with a quick search — no tooling, no metadata
schema.
## Keeping the roadmap and sub-specs in sync
The roadmap is a living document. As you learn more, keep it and the sub-specs
aligned:
- **Roadmap first, then reconcile.** When scope shifts, update the roadmap entry
first, then update any sub-specs it affects. The roadmap is the source of truth for
how the epic is divided.
- **Respect dependencies and ordering.** If a slice depends on another, build the
prerequisite first and cross-reference the dependent sub-spec so the relationship
is visible from both sides.
- **Recurse when a slice is still too big.** If a sub-feature turns out to be too
large to specify in one cycle, give it its own roadmap and decompose it further —
the same approach applies one level down. Recursion adds overhead, so only go as
deep as the context problem actually requires.
## Worked example
Suppose the epic is **"Add a self-service billing portal"** — far too large for a
single cycle. The roadmap pass breaks it into three independently-specifiable
slices.
`specs/billing-portal/roadmap.md`:
```markdown
# Roadmap: Self-service billing portal
Let customers view invoices, manage payment methods, and change plans without
contacting support. Too large for one cycle, so it is split into independent slices.
**Status legend**: planned · in-progress · done
| ID | Sub-feature | Intent | Scope boundary | Depends on | Status | Sub-spec |
|----|--------------------|------------------------------------------|---------------------------------------------|-----------|---------|----------|
| R1 | Invoice history | Customers view and download past invoices | Read-only; no payment actions | — | done | specs/billing-invoices/ |
| R2 | Payment methods | Add, remove, and set a default card | No plan changes; assumes invoices exist | R1 | in-progress | specs/billing-payment-methods/ |
| R3 | Plan changes | Upgrade/downgrade the subscription plan | Uses R2's default payment method | R1, R2 | planned | — |
```
Each slice is then specified on its own. For example, the **R2** sub-feature's
`spec.md` opens with a back-reference:
```markdown
# Feature Specification: Billing — payment methods
**Input**: Parent roadmap: `specs/billing-portal/roadmap.md` → entry **R2**.
Let customers add, remove, and set a default payment method in the billing portal.
```
From here a reader can trace **R2** back to the roadmap, see that it depends on
**R1** (invoice history, already `done`), and see that **R3** (plan changes) is
waiting on it. Building R1, then R2, then R3 keeps every run small while the roadmap
preserves the shape of the whole epic.
## For automation (optional)
If you would rather automate roadmap capture and consistency checks than maintain
the file by hand, the community-maintained
[Spec Roadmap extension](https://github.com/srobroek/speckit-roadmap) explores that
direction. It is a third-party extension and is not required — the manual convention
above is enough on its own.

View File

@@ -77,9 +77,9 @@ specify init <project_name> --integration pi
specify init <project_name> --integration omp
```
### Specify Script Type (Shell vs PowerShell)
### Specify Script Type (Shell, PowerShell, or Python)
All automation scripts now have both Bash (`.sh`) and PowerShell (`.ps1`) variants.
Automation scripts are available as Bash (`.sh`), PowerShell (`.ps1`), and Python (`.py`) variants.
Auto behavior:
@@ -92,6 +92,7 @@ Force a specific script type:
```bash
specify init <project_name> --script sh
specify init <project_name> --script ps
specify init <project_name> --script py
```
### Ignore Agent Tools Check
@@ -131,6 +132,7 @@ Scripts are installed into a variant subdirectory matching the chosen script typ
- `.specify/scripts/bash/` — contains `.sh` scripts (default on Linux/macOS)
- `.specify/scripts/powershell/` — contains `.ps1` scripts (default on Windows)
- `.specify/scripts/python/` — contains `.py` scripts (chosen with `--script py`; also installs the platform shell fallback)
## Troubleshooting

View File

@@ -2,7 +2,7 @@
This guide shows how to iterate on the `specify` CLI locally without publishing a release or committing to `main` first.
> Scripts now have both Bash (`.sh`) and PowerShell (`.ps1`) variants. The CLI auto-selects based on OS unless you pass `--script sh|ps`.
> Scripts are available as Bash (`.sh`), PowerShell (`.ps1`), and Python (`.py`) variants. Interactive `specify init` prompts you to choose one; non-interactive runs default to a shell variant for your OS. Pass `--script sh|ps|py` to select explicitly.
## 1. Clone and Switch Branches
@@ -189,7 +189,7 @@ rm -rf .venv dist build *.egg-info
| `ModuleNotFoundError: typer` | Run `uv pip install -e .` |
| Scripts not executable (Linux) | Re-run init or `chmod +x scripts/*.sh` |
| Git commands unavailable | Install the git extension with `specify extension add git` |
| Wrong script type downloaded | Pass `--script sh` or `--script ps` explicitly |
| Wrong script type downloaded | Pass `--script sh`, `--script ps`, or `--script py` explicitly |
| TLS errors on corporate network | Configure your environment's certificate store or proxy. The `--skip-tls` flag is deprecated and has no effect. |
## 14. Next Steps

View File

@@ -3,7 +3,7 @@
This guide will help you get started with Spec-Driven Development using Spec Kit. Throughout, we illustrate each step with a running example: **Taskify**, a small team productivity platform.
> [!NOTE]
> Automation scripts are provided as both Bash (`.sh`) and PowerShell (`.ps1`) variants. The `specify` CLI auto-selects based on your OS unless you pass `--script sh|ps`.
> Automation scripts are provided as Bash (`.sh`), PowerShell (`.ps1`), and Python (`.py`) variants. Interactive `specify init` prompts you to choose one; non-interactive runs default to a shell variant for your OS. Pass `--script sh|ps|py` to select explicitly.
> [!NOTE]
> Commands are shown here in `/speckit.*` form, but the exact invocation depends on your agent. Some skills-based agents use `$speckit-*` (e.g. Codex, ZCode) or `/skill:speckit-*` (e.g. Kimi). Use whichever form your agent exposes — the steps are otherwise identical.

View File

@@ -12,7 +12,7 @@ specify init [<project_name>]
| ------------------------ | ------------------------------------------------------------------------ |
| `--integration <key>` | AI coding agent integration to use (e.g. `copilot`, `claude`, `gemini`). See the [Integrations reference](integrations.md) for all available keys |
| `--integration-options` | Options for the integration (e.g. `--integration-options="--commands-dir .myagent/cmds"`) |
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
| `--script sh\|ps\|py` | Script type: `sh` (bash/zsh), `ps` (PowerShell), or `py` (Python) |
| `--here` | Initialize in the current directory instead of creating a new one |
| `--force` | Force merge/overwrite when initializing in an existing directory |
| `--ignore-agent-tools` | Skip checks for AI coding agent CLI tools |

View File

@@ -15,6 +15,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Codex CLI](https://github.com/openai/codex) | `codex` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `$speckit-<command>` |
| [Cursor](https://cursor.sh/) | `cursor-agent` | |
| [Devin for Terminal](https://cli.devin.ai/docs) | `devin` | Skills-based integration; installs skills into `.devin/skills/` and invokes them as `/speckit-<command>` |
| [Factory Droid](https://docs.factory.ai/cli/getting-started/overview) | `droid` | Skills-based integration; installs skills into `.factory/skills/` and invokes them as `/speckit-<command>` |
| [Firebender](https://firebender.com/) | `firebender` | IDE-based agent for Android Studio / IntelliJ |
| [Forge](https://forgecode.dev/) | `forge` | |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | |
@@ -85,7 +86,7 @@ specify integration install <key>
| Option | Description |
| ------------------------ | ------------------------------------------------------------------------ |
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
| `--script sh\|ps\|py` | Script type: `sh` (bash/zsh), `ps` (PowerShell), or `py` (Python) |
| `--force` | Opt in to installing alongside integrations that are not declared multi-install safe |
| `--integration-options` | Integration-specific options (e.g. `--integration-options="--commands-dir .myagent/cmds"`) |
@@ -121,7 +122,7 @@ specify integration switch <key>
| Option | Description |
| ------------------------ | ------------------------------------------------------------------------ |
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
| `--script sh\|ps\|py` | Script type: `sh` (bash/zsh), `ps` (PowerShell), or `py` (Python) |
| `--force` | Force removal of modified files during uninstall; when the target is already installed, overwrite managed shared templates while changing the default |
| `--refresh-shared-infra` | Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved) |
| `--integration-options` | Options for the target integration when it is not already installed |
@@ -149,7 +150,7 @@ specify integration upgrade [<key>]
| Option | Description |
| ------------------------ | ------------------------------------------------------------------------ |
| `--force` | Overwrite files even if they have been modified |
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
| `--script sh\|ps\|py` | Script type: `sh` (bash/zsh), `ps` (PowerShell), or `py` (Python) |
| `--integration-options` | Options for the integration |
Reinstalls an installed integration with updated templates and commands (e.g., after upgrading Spec Kit). Defaults to the default integration; if a key is provided, it must be one of the installed integrations. Detects locally modified files and blocks the upgrade unless `--force` is used. Stale files from the previous install that are no longer needed are removed automatically. Shared templates stay aligned with the default integration even when upgrading a non-default integration.
@@ -256,31 +257,30 @@ Spec Kit tracks one default integration in `.specify/integration.json` with `def
An integration is multi-install safe when it uses a static, unique agent root and command directory, stable command invocation settings, and a separate install manifest whose managed files do not overlap another safe integration. Registry tests enforce those path and manifest invariants. Shared Spec Kit templates remain aligned to the single default integration.
The Isolation column below lists paths Spec Kit manages for that integration (skills/commands roots and any integration-owned rule files). It is not a full inventory of every file an agent may read.
**Agent-context defaults are separate.** The optional agent-context extension maps each integration to a default context file in `extensions/agent-context/agent-context-defaults.json`. Those defaults are independent of multi-install safety: several agents may share a root file such as `AGENTS.md` when the extension is enabled. Multi-install safety does not require a unique context file per safe integration.
The Command directory column below lists the directory each integration installs its commands or skills into. Context-file targeting is a separate concern from integration multi-install safety: `multi_install_safe` is an integration declaration about command/skill paths, whereas the optional agent-context extension manages a per-agent context file (for example `AGENTS.md` or `CLAUDE.md`) and can even synchronize several anchors at once via its `context_files` setting. Multiple agents mapping to the same context file is expected there and does not affect whether an integration is multi-install safe; see the agent-context extension for details.
The currently declared multi-install safe integrations are:
| Key | Isolation |
| --- | --------- |
| `auggie` | `.augment/commands`, `.augment/rules/specify-rules.md` |
| `claude` | `.claude/skills`, `CLAUDE.md` |
| `cline` | `.clinerules/workflows`, `.clinerules/specify-rules.md` |
| `codebuddy` | `.codebuddy/commands`, `CODEBUDDY.md` |
| `codex` | `.agents/skills`, `AGENTS.md` |
| `cursor-agent` | `.cursor/skills`, `.cursor/rules/specify-rules.mdc` |
| `firebender` | `.firebender/commands`, `.firebender/rules/specify-rules.mdc` |
| `gemini` | `.gemini/commands`, `GEMINI.md` |
| Key | Command directory |
| --- | ----------------- |
| `auggie` | `.augment/commands` |
| `claude` | `.claude/skills` |
| `cline` | `.clinerules/workflows` |
| `codebuddy` | `.codebuddy/commands` |
| `codex` | `.agents/skills` |
| `cursor-agent` | `.cursor/skills` |
| `firebender` | `.firebender/commands` |
| `gemini` | `.gemini/commands` |
| `grok` | `.grok/skills` |
| `junie` | `.junie/commands`, `.junie/AGENTS.md` |
| `kilocode` | `.kilocode/workflows`, `.kilocode/rules/specify-rules.md` |
| `qodercli` | `.qoder/commands`, `QODER.md` |
| `qwen` | `.qwen/commands`, `QWEN.md` |
| `shai` | `.shai/commands`, `SHAI.md` |
| `tabnine` | `.tabnine/agent/commands`, `TABNINE.md` |
| `trae` | `.trae/skills`, `.trae/rules/project_rules.md` |
| `zcode` | `.zcode/skills`, `ZCODE.md` |
| `junie` | `.junie/commands` |
| `kilocode` | `.kilocode/workflows` |
| `kiro-cli` | `.kiro/prompts` |
| `qodercli` | `.qoder/commands` |
| `qwen` | `.qwen/commands` |
| `shai` | `.shai/commands` |
| `tabnine` | `.tabnine/agent/commands` |
| `trae` | `.trae/skills` |
| `zcode` | `.zcode/skills` |
Integrations that share a command directory with another integration, require dynamic install paths such as `--commands-dir`, or merge shared tool settings are not declared safe by default. They can still be installed alongside another integration with `--force`.

View File

@@ -55,6 +55,8 @@
href: concepts/spec-persistence.md
- name: Handling Complex Features
href: concepts/complex-features.md
- name: Spec of Specs
href: concepts/spec-of-specs.md
# Development workflows
- name: Development

View File

@@ -12,7 +12,7 @@
| **CLI Tool — pin a version** | `specify self upgrade --tag vX.Y.Z[suffix]` | Upgrade to a specific release tag instead of the latest stable. Suffixes are limited to dev, alpha/beta/rc, and/or build metadata forms. |
| **CLI Tool — manual fallback** | `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z` | When `specify self upgrade` isn't available (older installs) or when you want explicit control. |
| **CLI Tool — manual fallback (pipx)** | `pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z` | Same as above, for pipx installs. |
| **Project Files** | `specify init --here --force --integration <your-agent>` | Update slash commands, templates, and scripts in your project |
| **Project Files** | Run `specify integration upgrade <key>`, then `specify extension update` | Refresh installed integration files and extensions in your project |
| **Both** | Run CLI upgrade, then project update | Recommended for major version updates |
---
@@ -89,91 +89,94 @@ specify self check
## Part 2: Updating Project Files
When Spec Kit releases new features (like new slash commands or updated templates), you need to refresh your project's Spec Kit files.
When Spec Kit releases new features (like new slash commands, updated templates, or extension changes), you need to refresh the Spec Kit files that were installed into your project.
### What gets updated?
Running `specify init --here --force` will update:
For existing Spec Kit projects, use the manifest-aware upgrade path first:
-**Slash command files** (`.claude/commands/`, `.github/prompts/`, etc.)
-**Script files** (`.specify/scripts/`) — **only with `--force`**; without it, only missing files are added
-**Template files** (`.specify/templates/`) — **only with `--force`**; without it, only missing files are added
-**Shared memory files** (`.specify/memory/`) - **⚠️ See warnings below**
-**Integration command/skill files** (`.claude/skills/`, `.github/prompts/`, `.agents/skills/`, etc.)
-**Managed shared scripts and templates** (`.specify/scripts/`, `.specify/templates/`) when they are unchanged from the previous managed copy
-**Installed extensions** when you run `specify extension update`
The integration upgrade command uses the install manifest to detect local edits. If a managed integration file was modified after install, the command stops and asks you to inspect the change or rerun with `--force`.
### What stays safe?
These files are **never touched** by the upgrade—the template packages don't even contain them:
These files are **never touched** by the manifest-aware integration/extension upgrade path:
-**Your specifications** (`specs/001-my-feature/spec.md`, etc.) - **CONFIRMED SAFE**
-**Your implementation plans** (`specs/001-my-feature/plan.md`, `tasks.md`, etc.) - **CONFIRMED SAFE**
-**Your constitution** (`.specify/memory/constitution.md`) when using `specify integration upgrade`
-**Your source code** - **CONFIRMED SAFE**
-**Your git history** - **CONFIRMED SAFE**
The `specs/` directory is completely excluded from template packages and will never be modified during upgrades.
### Update command
### 1. Check installed integrations
Run this inside your project directory:
```bash
specify integration status
```
This reports the default integration, all installed integrations, and any modified or missing managed files. You can also inspect `.specify/integration.json`; installed integrations are listed under `installed_integrations`.
### 2. Upgrade each installed integration
Run this inside your project directory:
```bash
specify integration upgrade <key>
```
Replace `<key>` with an installed integration key such as `copilot`, `claude`, or `codex`. In projects with multiple installed integrations, run the command once per installed key.
**Example:**
```bash
specify integration upgrade claude
specify integration upgrade codex
```
See the [integration reference](reference/integrations.md#upgrade-an-integration) for options such as `--script`, `--integration-options`, and `--force`.
### 3. Update installed extensions
Run:
```bash
specify extension update
```
With no extension argument, this updates all installed extensions. Use `specify extension update <extension-id-or-name>` to update only one extension. See the [extensions reference](reference/extensions.md#update-extensions) for details.
### Fallback: re-run init
If a project predates manifests, has missing integration metadata, or needs a broader recovery, you can still re-run init:
```bash
specify init --here --force --integration <your-agent>
```
Replace `<your-agent>` with your AI coding agent. Refer to this list of [Supported AI Coding Agent Integrations](reference/integrations.md)
**Example:**
```bash
specify init --here --force --integration copilot
```
### Understanding the `--force` flag
Without `--force`, the CLI warns you and asks for confirmation:
```text
Warning: Current directory is not empty (25 items)
Template files will be merged with existing content and may overwrite existing files
Proceed? [y/N]
```
With `--force`, it skips the confirmation and proceeds immediately. It also **overwrites shared infrastructure files** (`.specify/scripts/` and `.specify/templates/`) with the latest versions from the installed Spec Kit release.
Without `--force`, shared infrastructure files that already exist are skipped — the CLI will print a warning listing the skipped files so you know which ones were not updated.
**Important: Your `specs/` directory is always safe.** The `--force` flag only affects template files (commands, scripts, templates, memory). Your feature specifications, plans, and tasks in `specs/` are never included in upgrade packages and cannot be overwritten.
---
Use this as an escape hatch rather than the default project-file upgrade path. It refreshes the selected integration and shared project scaffolding, but it does not use the same per-integration manifest checks before overwriting files.
## ⚠️ Important Warnings
### 1. Constitution file will be overwritten
### 1. Constitution file and memory customizations
**Known issue:** `specify init --here --force` currently overwrites `.specify/memory/constitution.md` with the default template, erasing any customizations you made.
`specify integration upgrade <key>` does not update `.specify/memory/constitution.md`.
**Workaround:**
The fallback `specify init --here --force --integration <your-agent>` path also preserves an existing `.specify/memory/constitution.md`; if the file is missing, init creates it from the current constitution template. You do not need a constitution backup/restore step for the manifest-aware upgrade path.
```bash
# 1. Back up your constitution before upgrading
cp .specify/memory/constitution.md .specify/memory/constitution-backup.md
As with any broad fallback refresh, commit or back up local customizations before using `init --here --force` so you can review the resulting diff.
# 2. Run the upgrade
specify init --here --force --integration copilot
### 2. Custom integration, script, or template modifications
# 3. Restore your customized constitution
mv .specify/memory/constitution-backup.md .specify/memory/constitution.md
```
`specify integration upgrade <key>` blocks when manifest-tracked integration files were modified locally, unless you pass `--force`.
Or use git to restore it:
```bash
# After upgrade, restore from git history
git restore .specify/memory/constitution.md
```
### 2. Custom script or template modifications
If you customized files in `.specify/scripts/` or `.specify/templates/`, the `--force` flag will overwrite them. Back them up first:
Shared scripts and templates are refreshed when they still match the previously recorded managed copy. Local customizations are preserved unless you explicitly use a force/refresh option that overwrites them. If you customized files in `.specify/scripts/` or `.specify/templates/`, commit or back them up first:
```bash
# Back up custom templates and scripts
@@ -215,29 +218,29 @@ Restart your IDE to refresh the command list.
# Upgrade CLI (auto-detects uv tool vs pipx install)
specify self upgrade
# Update project files to get new commands
specify init --here --force --integration copilot
# Inspect installed integrations
specify integration status
# Restore your constitution if customized
git restore .specify/memory/constitution.md
# Update project files to get new commands
specify integration upgrade <key>
specify extension update
```
### Scenario 2: "I customized templates and constitution"
```bash
# 1. Back up customizations
cp .specify/memory/constitution.md /tmp/constitution-backup.md
# 1. Commit or back up customizations
git status
cp -r .specify/templates /tmp/templates-backup
# 2. Upgrade CLI
specify self upgrade
# 3. Update project
specify init --here --force --integration copilot
# 3. Use the manifest-aware project update first
specify integration upgrade <key>
specify extension update
# 4. Restore customizations
mv /tmp/constitution-backup.md .specify/memory/constitution.md
# Manually merge template changes if needed
# 4. If the upgrade reports modified managed files, inspect the diff before using --force
```
### Scenario 3: "I see duplicate slash commands in my IDE"
@@ -262,14 +265,14 @@ rm speckit.old-command-name.md
The git extension is now opt-in, so upgrades do not install it unless you add it explicitly.
```bash
# Manually back up files you customized
cp .specify/memory/constitution.md .specify/memory/constitution.backup.md
# Upgrade CLI
specify self upgrade
# Run upgrade
specify init --here --force --integration copilot
# Refresh integration files and installed extensions
specify integration upgrade <key>
specify extension update
# Restore customizations
mv .specify/memory/constitution.backup.md .specify/memory/constitution.md
# The git extension is not added unless you run `specify extension add git`
```
If you later decide you want the git extension's commands and hooks, install it explicitly:
@@ -315,19 +318,21 @@ Alternatively, run the `/speckit.specify` command which creates `.specify/featur
- Codex requires `CODEX_HOME` environment variable
- Some agents need workspace restart or cache clearing
### "I lost my constitution customizations"
### "Will init overwrite my constitution customizations?"
**Fix:** Restore from git or backup:
Current `specify init --here --force` preserves an existing `.specify/memory/constitution.md`; it creates the file from the template only when it is missing.
If you previously lost constitution changes through an older workflow or manual replacement, restore from git or backup:
```bash
# If you committed before upgrading
# If you committed the customized constitution
git restore .specify/memory/constitution.md
# If you backed up manually
cp /tmp/constitution-backup.md .specify/memory/constitution.md
```
**Prevention:** Always commit or back up `constitution.md` before upgrading.
**Prevention:** Use `specify integration upgrade <key>` for routine project-file updates. If you need the fallback `specify init --here --force` path, commit first so you can review the full diff afterward.
### "Warning: Current directory is not empty"
@@ -354,7 +359,7 @@ Only Spec Kit infrastructure files:
- Agent command files (`.claude/commands/`, `.github/prompts/`, etc.)
- Scripts in `.specify/scripts/`
- Templates in `.specify/templates/`
- Memory files in `.specify/memory/` (including constitution)
- Missing memory files such as `.specify/memory/constitution.md` may be created from templates; an existing constitution is preserved
**What stays untouched:**
@@ -365,7 +370,7 @@ Only Spec Kit infrastructure files:
**How to respond:**
- **Type `y` and press Enter** - Proceed with the merge (recommended if upgrading)
- **Type `y` and press Enter** - Proceed with the merge when using the fallback init path
- **Type `n` and press Enter** - Cancel the operation
- **Use `--force` flag** - Skip this confirmation entirely:
@@ -375,11 +380,11 @@ Only Spec Kit infrastructure files:
**When you see this warning:**
- ✅ **Expected** when upgrading an existing Spec Kit project
- ✅ **Expected** when using the fallback init path in an existing Spec Kit project
- ✅ **Expected** when adding Spec Kit to an existing codebase
- ⚠️ **Unexpected** if you thought you were creating a new project in an empty directory
**Prevention tip:** Before upgrading, commit or back up your `.specify/memory/constitution.md` if you customized it.
**Prevention tip:** Before using the fallback init path, commit your current work so any refreshed files are easy to review or restore.
### "CLI upgrade doesn't seem to work"
@@ -418,14 +423,15 @@ uv tool install specify-cli --from git+https://github.com/github/spec-kit.git
### "Do I need to run specify every time I open my project?"
**Short answer:** No, you only run `specify init` once per project (or when upgrading).
**Short answer:** No, you only run `specify init` once per project, or later as a fallback recovery path.
**Explanation:**
The `specify` CLI tool is used for:
- **Initial setup:** `specify init` to bootstrap Spec Kit in your project
- **Upgrades:** `specify init --here --force` to update templates and commands
- **Routine project-file upgrades:** `specify integration upgrade <key>` and `specify extension update`
- **Fallback recovery:** `specify init --here --force` when integration metadata is missing or the manifest-aware path cannot be used
- **Diagnostics:** `specify check` to verify tool installation
Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/speckit.plan`, etc.) are **permanently installed** in your project's agent folder (`.claude/`, `.github/prompts/`, `.pi/prompts/`, `.omp/commands/`, etc.). Your AI coding agent reads these command files directly—no need to run `specify` again.

View File

@@ -94,7 +94,12 @@ if [ -f "$_config_file" ]; then
[ "$_val" = "false" ] && _enabled=false
fi
if echo "$_line" | grep -Eq '[[:space:]]+message:'; then
_commit_msg=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//')
# Trim trailing whitespace before stripping the closing quote:
# a value like `message: "Done" ` (trailing spaces after the
# quote) would otherwise leave the quote dangling (`Done" `),
# since the closing-quote strip is anchored to end-of-string.
# The PowerShell twin .Trim()s first; match it for parity.
_commit_msg=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | sed 's/[[:space:]]*$//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//')
fi
fi
fi

View File

@@ -565,6 +565,12 @@ if (-not $DryRun) {
$env:SPECIFY_FEATURE = $branchName
}
# Build the PowerShell-idiomatic persist hint, mirroring the core
# create-new-feature.ps1 twin (and the bash/python twins of this script), which
# all emit "# To persist in your shell: ...".
$quotedBranchName = "'" + $branchName.Replace("'", "''") + "'"
$featureAssignment = '$env:SPECIFY_FEATURE = ' + $quotedBranchName
if ($Json) {
$obj = [PSCustomObject]@{
BRANCH_NAME = $branchName
@@ -581,6 +587,6 @@ if ($Json) {
Write-Output "BRANCH_NAME: $branchName"
Write-Output "FEATURE_NUM: $featureNum"
if (-not $DryRun) {
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
Write-Output "# To persist in your shell: $featureAssignment"
}
}

View File

@@ -33,7 +33,15 @@ def _value_after_colon(line: str) -> str:
def _strip_quotes(value: str) -> str:
"""Strip one leading quote and all trailing quotes, mirroring the bash sed."""
"""Strip surrounding whitespace, then one leading quote and all trailing quotes.
Trimming first matters when the YAML value has trailing whitespace after a
closing quote (``message: "Done" ``): stripping quotes anchored to the end
of string would leave the closing quote dangling (``Done" ``) because the
quote is no longer at the end. The PowerShell twin ``.Trim()``s before
stripping, so trim here too to keep all three script variants in parity.
"""
value = value.strip()
value = re.sub(r"^[\"']", "", value)
return re.sub(r"[\"']*$", "", value)

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-15T00:00:00Z",
"updated_at": "2026-07-17T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json",
"integrations": {
"claude": {
@@ -48,6 +48,15 @@
"repository": "https://github.com/github/spec-kit",
"tags": ["ide"]
},
"droid": {
"id": "droid",
"name": "Factory Droid",
"version": "1.0.0",
"description": "Factory Droid CLI skills-based integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["cli", "skills", "factory"]
},
"amp": {
"id": "amp",
"name": "Amp",

View File

@@ -364,6 +364,35 @@
"created_at": "2026-05-05T08:00:00Z",
"updated_at": "2026-06-22T00:00:00Z"
},
"intake-authoring-governance": {
"name": "Intake Authoring Governance",
"id": "intake-authoring-governance",
"version": "0.1.0",
"description": "Creates traceable Spec Kit intake files and receipts from ordered text sources while preserving clarification, update, and delivery-authority boundaries.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/archive/refs/tags/v0.1.0.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/blob/v0.1.0/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.3"
},
"provides": {
"templates": 7,
"commands": 2,
"scripts": 2
},
"tags": [
"intake",
"authoring",
"governance",
"traceability",
"clarification"
],
"created_at": "2026-07-22T00:00:00Z",
"updated_at": "2026-07-22T00:00:00Z"
},
"intake-review-governance": {
"name": "Intake Review Governance",
"id": "intake-review-governance",

View File

@@ -8,6 +8,24 @@ description: Create or update the project constitution.
$ARGUMENTS
```
## Scope Guard
This command's own work is limited to creating or updating the project constitution and
propagating constitution-driven changes to dependent Spec Kit artifacts.
- Classify every part of the user input as constitution content or a separate non-governance
intent. Feature implementation, code generation, refactoring, build, and deployment requests
are examples of non-governance intents.
- You **MUST NOT** execute any non-governance intent. Defer each one to `Next Actions`.
- You **MUST NOT** create, modify, or delete application source files or other artifacts
unrelated to the constitution workflow.
- If an instruction could be either constitution content or a non-governance intent, ask for
clarification before making changes.
- After updating the constitution, list each deferred intent in a `Next Actions` section with an
appropriate follow-up Spec Kit command, such as `__SPECKIT_COMMAND_SPECIFY__`, but do not
invoke it.
- Omit `Next Actions` when there are no non-governance intents.
## Outline
1. Create or update the project constitution and store it in `.specify/memory/constitution.md`.

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.13.3"
version = "0.14.0"
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"
@@ -39,6 +39,7 @@ packages = ["src/specify_cli"]
"templates/commands" = "specify_cli/core_pack/commands"
"scripts/bash" = "specify_cli/core_pack/scripts/bash"
"scripts/powershell" = "specify_cli/core_pack/scripts/powershell"
"scripts/python" = "specify_cli/core_pack/scripts/python"
# Bundled extensions (installable via `specify extension add <name>`)
"extensions/git" = "specify_cli/core_pack/extensions/git"
"extensions/agent-context" = "specify_cli/core_pack/extensions/agent-context"

View File

@@ -0,0 +1,218 @@
"""Helpers for bounded HTTP downloads."""
from __future__ import annotations
import io
import socket
from ipaddress import IPv4Address, IPv6Address, ip_address
from typing import NoReturn, TypeVar
from urllib.parse import ParseResult, urlparse
ErrorT = TypeVar("ErrorT", bound=Exception)
MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024
READ_CHUNK_SIZE = 64 * 1024
# Tighter ceiling for responses that are read fully into memory and parsed as
# JSON. The 50 MiB MAX_DOWNLOAD_BYTES default is sized for archive/payload
# downloads; JSON metadata responses are far smaller, so capping them close to
# their real size shrinks the memory-DoS surface and keeps the "too large"
# error reachable (rather than only triggering on tens of MiB). Pass it
# explicitly at each JSON call site so the intended bound is pinned there.
# METADATA covers fixed-shape single-object responses (an OAuth token, one
# release's metadata): a few KiB in practice, 1 MiB is already generous.
MAX_JSON_METADATA_BYTES = 1 * 1024 * 1024
def _ip_address_without_scope(
hostname: str,
) -> IPv4Address | IPv6Address | None:
"""Parse a canonical IP literal, validating an optional IPv6 zone ID."""
if "%" in hostname:
# Accept only the RFC 6874 ``%25<zone>`` spelling. Other escapes can
# alter the IPv6 address when urllib unquotes the authority.
address_text, separator, zone = hostname.partition("%25")
if (
not separator
or ":" not in address_text
or "%" in address_text
or "%" in zone
):
return None
if not zone or any(
not (character.isascii() and (character.isalnum() or character in "._~-"))
for character in zone
):
return None
else:
address_text = hostname
try:
address = ip_address(address_text)
except ValueError:
return None
if "%" in hostname and not isinstance(address, IPv6Address):
return None
return address
def _is_ip_loopback(address: IPv4Address | IPv6Address | None) -> bool:
if address is None:
return False
mapped = getattr(address, "ipv4_mapped", None)
return address.is_loopback or bool(mapped and mapped.is_loopback)
def _is_ip_local_redirect_target(
address: IPv4Address | IPv6Address | None,
) -> bool:
"""Treat loopback and unspecified listener aliases as local targets."""
if address is None:
return False
mapped = getattr(address, "ipv4_mapped", None)
return _is_ip_loopback(address) or address.is_unspecified or bool(
mapped and mapped.is_unspecified
)
def _parse_url(url: str) -> ParseResult | None:
"""Parse *url*, rejecting missing hosts and malformed ports."""
try:
parsed = urlparse(url)
hostname = parsed.hostname
# Accessing ``port`` performs urllib's range and syntax validation.
parsed.port
except (TypeError, ValueError):
return None
if not hostname:
return None
if "%" in hostname:
# urllib unquotes reg-name/IPv4 authorities before connecting. Reject
# them so encoded dots, characters, ports, or brackets cannot make the
# validated hostname differ from the effective target. The only safe
# percent form retained is a validated bracketed IPv6 zone ID.
if _ip_address_without_scope(hostname) is None:
return None
elif ":" not in hostname:
try:
hostname.encode("idna")
except UnicodeError:
return None
return parsed
def _is_definite_loopback_host(hostname: str) -> bool:
"""Recognize only unambiguous hosts that may safely authorize HTTP."""
if not hostname.isascii():
return False
if hostname == "localhost":
return True
return _is_ip_loopback(_ip_address_without_scope(hostname))
def _is_potential_local_target_host(hostname: str) -> bool:
"""Conservatively classify aliases that could reach a local listener."""
if ":" in hostname:
return _is_ip_local_redirect_target(_ip_address_without_scope(hostname))
try:
host = hostname.encode("idna").decode("ascii").lower().removesuffix(".")
except UnicodeError:
return False
if host == "localhost" or host.endswith(".localhost"):
return True
address = _ip_address_without_scope(host)
if address is None:
# Historical IPv4 spellings are resolver-dependent. They are never
# trusted to authorize HTTP, but treating them as potentially local
# prevents them from bypassing a remote-to-loopback redirect check.
try:
address = ip_address(socket.inet_aton(host))
except OSError:
return False
return _is_ip_local_redirect_target(address)
def is_loopback_url(url: str) -> bool:
"""Return whether *url* has an unambiguous loopback host."""
parsed = _parse_url(url)
return parsed is not None and _is_definite_loopback_host(parsed.hostname)
def _is_potential_local_target_url(url: str) -> bool:
parsed = _parse_url(url)
return parsed is not None and _is_potential_local_target_host(parsed.hostname)
def is_https_or_localhost_http(url: str) -> bool:
"""Return True if *url* is HTTPS, or HTTP limited to loopback hosts.
Shared scheme-safety predicate used by the auth HTTP redirect handler and
direct URL validations in CLI download flows.
A hostname is always required: a URL without one (e.g. ``https:///x``)
has no real target and is rejected regardless of scheme.
The HTTP exception is deliberately limited to unambiguous ``localhost``
and canonical IPv4/IPv6 loopback literals. Ambiguous numeric, Unicode, and
unspecified-address aliases are classified defensively for redirects but
never authorize HTTP. No DNS lookup is performed; DNS and hosts-file
aliases require connection-level rebinding protection outside this helper.
"""
parsed = _parse_url(url)
if parsed is None:
return False
return parsed.scheme == "https" or (
parsed.scheme == "http" and _is_definite_loopback_host(parsed.hostname)
)
def is_safe_download_redirect(old_url: str, new_url: str) -> bool:
"""Return whether a redirect preserves the shared download URL policy."""
if not is_https_or_localhost_http(new_url):
return False
return not _is_potential_local_target_url(new_url) or is_loopback_url(old_url)
def _raise(error_type: type[ErrorT], message: str) -> NoReturn:
raise error_type(message)
def read_response_limited(
response,
*,
max_bytes: int = MAX_DOWNLOAD_BYTES,
error_type: type[ErrorT] = ValueError,
label: str = "download",
) -> bytes:
"""Read at most *max_bytes* from a response object.
``response.read(n)`` is only guaranteed to return *up to* ``n`` bytes and may
return fewer even when more data is pending (e.g. chunked transfer encoding),
so a single ``read(max_bytes + 1)`` cannot enforce the bound on its own. Read
in a loop until EOF or until one byte past the limit has been accumulated.
*max_bytes* is keyword-only. It defaults to the module-wide
``MAX_DOWNLOAD_BYTES`` (50 MiB) ceiling for archive/payload downloads;
callers with a tighter budget (e.g. small JSON responses) should pass an
explicit value so the intended bound is pinned at the call site rather than
tracking changes to the shared default.
"""
if isinstance(max_bytes, bool) or not isinstance(max_bytes, int):
raise TypeError("max_bytes must be an integer")
if max_bytes < 0:
raise ValueError("max_bytes must be non-negative")
output = io.BytesIO()
total = 0
limit = max_bytes + 1
while total < limit:
chunk = response.read(min(READ_CHUNK_SIZE, limit - total))
if not chunk:
break
total += len(chunk)
if total > max_bytes:
_raise(error_type, f"{label} exceeds maximum size of {max_bytes} bytes")
output.write(chunk)
return output.getvalue()

View File

@@ -100,6 +100,8 @@ def resolve_github_release_asset_api_url(
import json
import urllib.error
from specify_cli._download_security import read_response_limited
parsed = urlparse(download_url)
hostname = (parsed.hostname or "").lower()
parts = [unquote(part) for part in parsed.path.strip("/").split("/")]
@@ -158,10 +160,13 @@ def resolve_github_release_asset_api_url(
if redirect_validator is not None:
open_kwargs["redirect_validator"] = redirect_validator
with open_url_fn(release_url, **open_kwargs) as response:
raw_release_data = response.read(max_metadata_bytes + 1)
if len(raw_release_data) > max_metadata_bytes:
raise ValueError("GitHub release metadata exceeds size limit")
release_data = json.loads(raw_release_data)
release_data = json.loads(
read_response_limited(
response,
max_bytes=max_metadata_bytes,
label=f"GitHub release metadata {release_url}",
)
)
except (
urllib.error.URLError,
json.JSONDecodeError,

View File

@@ -4,8 +4,8 @@ Pure helpers for comparing PEP 440 versions and fetching the latest GitHub
release tag. The ``self_app`` Typer sub-command group is co-located here so
all version-related logic lives in one place.
Dependencies: stdlib + packaging + ._console only (no other internal imports
at module level, keeping this layer thin and circular-import-safe).
Dependencies: stdlib + packaging + ._console + ._download_security only
(keeping this layer thin and circular-import-safe).
"""
from __future__ import annotations
@@ -28,6 +28,7 @@ from pathlib import Path
import typer
from packaging.version import InvalidVersion, Version
from ._download_security import MAX_JSON_METADATA_BYTES, read_response_limited
from ._console import console
GITHUB_API_LATEST = "https://api.github.com/repos/github/spec-kit/releases/latest"
@@ -119,7 +120,13 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
timeout=5,
extra_headers={"Accept": "application/vnd.github+json"},
) as resp:
payload = json.loads(resp.read().decode("utf-8"))
payload = json.loads(
read_response_limited(
resp,
max_bytes=MAX_JSON_METADATA_BYTES,
label="GitHub latest release",
).decode("utf-8")
)
tag = payload.get("tag_name")
if not isinstance(tag, str) or not tag:
raise ValueError("GitHub API response missing valid tag_name")

View File

@@ -8,6 +8,7 @@ import os
import subprocess
from typing import TYPE_CHECKING
from .._download_security import MAX_JSON_METADATA_BYTES, read_response_limited
from .base import AuthProvider
if TYPE_CHECKING:
@@ -17,6 +18,20 @@ if TYPE_CHECKING:
_ADO_RESOURCE_ID = "499b84ac-1321-427f-aa17-267ca6975798"
class _TokenResponseTooLarge(Exception):
"""Raised when an Azure AD token response exceeds the bounded read limit."""
def _extract_token(payload: object, key: str) -> str | None:
"""Return a normalized token from a JSON object, or None for other shapes."""
if not isinstance(payload, dict):
return None
token = payload.get(key)
if not isinstance(token, str):
return None
return token.strip() or None
class AzureDevOpsAuth(AuthProvider):
"""Azure DevOps authentication provider.
@@ -74,8 +89,7 @@ class AzureDevOpsAuth(AuthProvider):
if result.returncode != 0:
return None
payload = _json.loads(result.stdout)
token = payload.get("accessToken", "").strip()
return token or None
return _extract_token(payload, "accessToken")
except (
OSError,
subprocess.TimeoutExpired,
@@ -119,9 +133,37 @@ class AzureDevOpsAuth(AuthProvider):
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
try:
with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310
payload = _json.loads(resp.read().decode("utf-8"))
token = payload.get("access_token", "").strip()
return token or None
except (urllib.error.URLError, OSError, _json.JSONDecodeError, KeyError):
from specify_cli.authentication.http import _StripAuthOnRedirect
def reject_token_redirect(_old_url: str, new_url: str) -> None:
# A 307/308 redirect preserves this POST body, including the
# client_secret. Refuse every redirect so credentials cannot
# leave the fixed Microsoft token endpoint.
raise urllib.error.URLError(
f"Azure AD token request must not be redirected to {new_url}"
)
opener = urllib.request.build_opener(
_StripAuthOnRedirect((), reject_token_redirect)
)
with opener.open(req, timeout=30) as resp: # noqa: S310
payload = _json.loads(
read_response_limited(
resp,
max_bytes=MAX_JSON_METADATA_BYTES,
error_type=_TokenResponseTooLarge,
label="Azure DevOps token response",
).decode("utf-8")
)
return _extract_token(payload, "access_token")
except (
urllib.error.URLError,
OSError,
_json.JSONDecodeError,
UnicodeDecodeError,
_TokenResponseTooLarge,
):
# Network failure, malformed JSON, or an oversized response — fall
# through to the next strategy. Unrelated programming errors (other
# ValueErrors, KeyErrors) intentionally propagate so they surface.
return None

View File

@@ -17,6 +17,7 @@ from fnmatch import fnmatch
from typing import Callable
from urllib.parse import urlparse
from .._download_security import is_safe_download_redirect
from . import get_provider
from .config import AuthConfigEntry, _default_config_path, find_entries_for_url, load_auth_config
@@ -60,8 +61,23 @@ def _hostname_in_hosts(hostname: str, hosts: tuple[str, ...]) -> bool:
RedirectValidator = Callable[[str, str], None]
def _validate_strict_redirect(old_url: str, new_url: str) -> None:
if not is_safe_download_redirect(old_url, new_url):
raise urllib.error.URLError(
f"unsafe redirect to {new_url}: target must use HTTPS with a hostname, "
"must not enter a local target from a remote host, and may use HTTP only "
"within loopback (for example localhost, 127.0.0.1, ::1)"
)
class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
"""Drop ``Authorization`` when a redirect leaves trusted hosts or downgrades."""
"""Redirect handler that guards every redirect it is installed for.
1. Run any caller-provided redirect validator.
2. Reject redirects that are not HTTPS with a hostname. HTTP loopback is
allowed only when the previous hop is also loopback.
3. Drop ``Authorization`` when a redirect leaves trusted hosts or downgrades.
"""
def __init__(
self,
@@ -75,6 +91,8 @@ class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
try:
new_parsed = urlparse(newurl)
# Force urllib's syntax and range validation before following.
new_parsed.port
except ValueError as exc:
# Malformed redirect target (e.g. unterminated IPv6 bracket).
# Surface as URLError so callers' download error handling applies.
@@ -82,6 +100,7 @@ class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
if self._redirect_validator is not None:
self._redirect_validator(req.full_url, newurl)
_validate_strict_redirect(req.full_url, newurl)
original_auth = (
req.get_header("Authorization")
@@ -155,6 +174,12 @@ def open_url(
*extra_headers* (e.g. ``Accept``) are merged into every attempt.
*redirect_validator*, when provided, is called with ``(old_url, new_url)``
before following each redirect and may raise to reject the redirect.
Every attempt uses an isolated opener so a process-wide opener installed
with ``urllib.request.install_opener`` cannot replace the redirect guard.
Redirect scheme safety: every attempt goes through
``_StripAuthOnRedirect``, which rejects redirects to non-HTTPS URLs except
HTTP between loopback URLs, and rejects remote-to-local redirects.
"""
entries = find_entries_for_url(url, _load_config())
@@ -188,7 +213,7 @@ def open_url(
# No entry worked (or none matched) — unauthenticated fallback
req = _make_req({})
if redirect_validator is not None:
opener = urllib.request.build_opener(_StripAuthOnRedirect((), redirect_validator))
return opener.open(req, timeout=timeout)
return urllib.request.urlopen(req, timeout=timeout) # noqa: S310
# No auth is attached on this path, so the handler's host list is empty:
# here it runs redirect validation only, not auth stripping.
opener = urllib.request.build_opener(_StripAuthOnRedirect((), redirect_validator))
return opener.open(req, timeout=timeout)

View File

@@ -143,6 +143,13 @@ def add_source(
raise BundlerError("A catalog url is required.")
try:
parsed = urlparse(url)
# Read .hostname inside the try: a bracketed-but-invalid IPv6 authority
# (e.g. "https://[not-an-ip]/c.json") parses cleanly under urlparse() on
# Python < 3.14 but raises ValueError lazily on the first .hostname access
# (the raise moved eager into urlparse() only in 3.14). Reading it here
# keeps that ValueError inside the guard instead of leaking a raw
# traceback past the CLI's `except BundlerError`. Reuse the value below.
hostname = parsed.hostname
except ValueError as exc:
raise BundlerError(f"Invalid catalog url: '{url}'.") from exc
if not (parsed.scheme or parsed.path):
@@ -161,13 +168,13 @@ def add_source(
# 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")
is_localhost = 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:
if not hostname:
raise BundlerError(f"Catalog url must be a valid URL with a host: {url}")
url = _canonicalize_url(url)

View File

@@ -60,7 +60,13 @@ def dump_yaml(path: Path, data: Any, *, within: Path | None = None) -> Path:
try:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as handle:
yaml.safe_dump(data, handle, sort_keys=False, default_flow_style=False)
yaml.safe_dump(
data,
handle,
sort_keys=False,
default_flow_style=False,
allow_unicode=True,
)
except OSError as exc:
raise BundlerError(f"Could not write {path}: {exc}") from exc
return path

View File

@@ -251,8 +251,22 @@ def _merge_config(by_id: dict[str, CatalogSource], config_path: Path, scope: Sco
return
data = load_yaml(config_path)
catalogs = data.get("catalogs") if isinstance(data, dict) else None
if not catalogs:
if catalogs is None:
return
if not isinstance(catalogs, list):
# Treat only an absent/``None`` ``catalogs`` as "nothing to merge"; any
# other non-list value (``catalogs: 5``, ``false``, ``0``, ``''``,
# ``{}``) is a malformed config and must raise, not be silently skipped
# by a falsy check. Otherwise a truthy scalar would raise a raw
# ``TypeError: 'int' object is not iterable`` from the loop below, while
# falsy non-lists would be swallowed. Report the same actionable
# BundlerError the sibling reader of this file raises
# (commands_impl/catalog_config.py) so both readers of
# bundle-catalogs.yml agree. An empty list stays valid (loop is a no-op).
raise BundlerError(
f"Malformed catalog config at {config_path}: 'catalogs' must be a "
f"list, got {type(catalogs).__name__}."
)
for raw in catalogs:
src = CatalogSource.from_dict(raw, scope)
by_id[src.id] = src

View File

@@ -111,8 +111,10 @@ class BundleManifest:
license=str(bundle_raw.get("license", "")).strip(),
)
requires_raw = data.get("requires") or {}
if not isinstance(requires_raw, dict):
requires_raw = data.get("requires")
if requires_raw is None:
requires_raw = {}
elif not isinstance(requires_raw, dict):
raise BundlerError("'requires' must be a mapping when present.")
requires = Requires(
speckit_version=str(requires_raw.get("speckit_version", "")).strip(),
@@ -122,11 +124,18 @@ class BundleManifest:
integration = None
integration_raw = data.get("integration")
# Mirror the requires/provides guards above: a present-but-non-mapping
# 'integration' (e.g. a bare string "copilot") was silently dropped,
# leaving the bundle wrongly integration-agnostic. Reject it instead.
if integration_raw is not None and not isinstance(integration_raw, dict):
raise BundlerError("'integration' must be a mapping when present.")
if isinstance(integration_raw, dict) and integration_raw.get("id"):
integration = IntegrationRef(id=str(integration_raw["id"]).strip())
provides = data.get("provides") or {}
if not isinstance(provides, dict):
provides = data.get("provides")
if provides is None:
provides = {}
elif not isinstance(provides, dict):
raise BundlerError("'provides' must be a mapping when present.")
tags_raw = data.get("tags")

View File

@@ -142,4 +142,10 @@ def _collect_files(
# Skip symlinked files to avoid escaping the bundle directory.
continue
collected.append(path)
return sorted(collected)
# Order by the canonical POSIX arcname (the same key build_bundle uses to
# NAME each member), not by pathlib.Path comparison. Path ordering is
# platform-dependent (Windows folds case and uses backslash separators),
# which would lay out zip members differently across build hosts and break
# the byte-for-byte reproducible-build guarantee even though the member
# names are identical.
return sorted(collected, key=lambda p: p.relative_to(bundle_dir).as_posix())

View File

@@ -700,6 +700,7 @@ def register(app: typer.Typer) -> None:
zed_skill_mode = selected_ai == "zed" and _is_skills_integration
grok_skill_mode = selected_ai == "grok" and _is_skills_integration
cline_skill_mode = selected_ai == "cline"
forge_skill_mode = selected_ai == "forge"
bob_skill_mode = selected_ai == "bob" and _is_skills_integration
native_skill_mode = (
codex_skill_mode
@@ -776,6 +777,7 @@ def register(app: typer.Typer) -> None:
if (
_is_slash_skills_agent(selected_ai, _ai_skills_enabled)
or cline_skill_mode
or forge_skill_mode
):
return f"/speckit-{name}"
return f"/speckit.{name}"

View File

@@ -3608,6 +3608,7 @@ class HookExecutor:
dollar_skill_mode = is_dollar_skills_agent(selected_ai, ai_skills_enabled)
kimi_skill_mode = selected_ai == "kimi"
cline_mode = selected_ai == "cline"
forge_mode = selected_ai == "forge"
skill_name = self._skill_name_from_command(command_id)
if dollar_skill_mode and skill_name:
@@ -3618,6 +3619,10 @@ class HookExecutor:
from ..integrations.cline import format_cline_command_name
return f"/{format_cline_command_name(command_id)}"
if forge_mode:
from ..integrations.forge import format_forge_command_name
return f"/{format_forge_command_name(command_id)}"
use_slash = is_slash_skills_agent(selected_ai, ai_skills_enabled)

View File

@@ -64,8 +64,15 @@ def with_integration_setting(
elif raw_options is not None:
current.pop("parsed_options", None)
# Recompute the separator from the options actually STORED on ``current``
# after the update, not the raw ``parsed_options`` argument. When only
# ``script_type`` changes (``parsed_options`` and ``raw_options`` both
# None), the previously-stored ``parsed_options`` are retained above, so
# deriving the separator from the argument (None) would drop an
# options-dependent separator (e.g. Copilot ``--skills`` -> "-") back to
# the default ".".
current["invoke_separator"] = integration.effective_invoke_separator(
parsed_options, project_root
current.get("parsed_options"), project_root
)
settings[key] = current
return settings

View File

@@ -58,6 +58,7 @@ def _register_builtins() -> None:
from .copilot import CopilotIntegration
from .cursor_agent import CursorAgentIntegration
from .devin import DevinIntegration
from .droid import DroidIntegration
from .firebender import FirebenderIntegration
from .forge import ForgeIntegration
from .gemini import GeminiIntegration
@@ -95,6 +96,7 @@ def _register_builtins() -> None:
_register(CopilotIntegration())
_register(CursorAgentIntegration())
_register(DevinIntegration())
_register(DroidIntegration())
_register(FirebenderIntegration())
_register(ForgeIntegration())
_register(GeminiIntegration())

View File

@@ -40,6 +40,25 @@ class IntegrationDescriptorError(Exception):
"""Raised when an integration.yml descriptor is invalid."""
def _catalog_shape_error(payload: Any) -> Optional[str]:
"""Return a human-readable reason if *payload* is not a valid integration
catalog document, else ``None``.
Shared by the fresh-fetch and cache-read paths so both enforce the same
format contract: a JSON object carrying ``schema_version`` and a mapping
``integrations``. Keeping a single validator prevents the two paths from
drifting (e.g. a cache that skips the ``schema_version`` check and lets an
older/poisoned payload bypass validation).
"""
if not isinstance(payload, dict):
return "expected a JSON object"
if "schema_version" not in payload or "integrations" not in payload:
return "missing required 'schema_version' or 'integrations' key"
if not isinstance(payload.get("integrations"), dict):
return "'integrations' must be a JSON object"
return None
# ---------------------------------------------------------------------------
# IntegrationCatalogEntry
# ---------------------------------------------------------------------------
@@ -153,7 +172,18 @@ class IntegrationCatalog(CatalogStackBase):
cached_at = cached_at.replace(tzinfo=timezone.utc)
age = (datetime.now(timezone.utc) - cached_at).total_seconds()
if age < self.CACHE_DURATION:
return json.loads(cache_file.read_text(encoding="utf-8"))
cached = json.loads(cache_file.read_text(encoding="utf-8"))
# A poisoned/older-format cache must clear the SAME shape
# contract as a fresh fetch (via the shared validator) —
# otherwise a payload like [], {"integrations": []}, or one
# missing "schema_version" is returned and later crashes on
# .items()/.get() or silently bypasses the format contract.
# The ValueError is caught just below, which drops the
# corrupt cache and refetches from source.
shape_error = _catalog_shape_error(cached)
if shape_error is not None:
raise ValueError(f"cached catalog has invalid shape: {shape_error}")
return cached
except (json.JSONDecodeError, ValueError, KeyError, TypeError, AttributeError, OSError, UnicodeError):
# Cache is invalid or stale metadata; delete and refetch from source.
try:
@@ -172,20 +202,10 @@ class IntegrationCatalog(CatalogStackBase):
self._validate_catalog_url(final_url)
catalog_data = json.loads(resp.read())
if not isinstance(catalog_data, dict):
shape_error = _catalog_shape_error(catalog_data)
if shape_error is not None:
raise IntegrationCatalogError(
f"Invalid catalog format from {entry.url}: expected a JSON object"
)
if (
"schema_version" not in catalog_data
or "integrations" not in catalog_data
):
raise IntegrationCatalogError(
f"Invalid catalog format from {entry.url}"
)
if not isinstance(catalog_data.get("integrations"), dict):
raise IntegrationCatalogError(
f"Invalid catalog format from {entry.url}: 'integrations' must be a JSON object"
f"Invalid catalog format from {entry.url}: {shape_error}"
)
try:

View File

@@ -77,6 +77,19 @@ class ClineIntegration(MarkdownIntegration):
"""Cline uses hyphenated filenames (e.g. speckit-git-commit.md)."""
return format_cline_command_name(template_name) + ".md"
def build_command_invocation(self, command_name: str, args: str = "") -> str:
"""Cline installs hyphenated slash-commands (``/speckit-<name>``), so the
dispatch invocation must match. The inherited MarkdownIntegration default
builds the dotted ``/speckit.<name>``, which references a command Cline
never registered. Reuse the same hyphenation as command_filename /
the injected frontmatter name (see ``format_cline_command_name``),
mirroring the forge integration.
"""
invocation = "/" + format_cline_command_name(command_name)
if args:
invocation = f"{invocation} {args}"
return invocation
def process_template(self, *args, **kwargs):
"""Ensure shared templates render Cline command references with hyphens."""
kwargs.setdefault("invoke_separator", self.invoke_separator)
@@ -125,8 +138,14 @@ class ClineIntegration(MarkdownIntegration):
content,
)
def post_process_content(self, content: str) -> str:
"""Apply Cline-specific transformations to command content."""
def post_process_command_content(self, content: str) -> str:
"""Apply Cline-specific transformations to command content.
Overrides the ``IntegrationBase`` hook of the same name so that
``CommandRegistrar.register_commands()`` (which dispatches to
``post_process_command_content``) applies these transforms to
extension/preset command files too, not just core commands.
"""
updated = self._inject_hook_command_note(content)
updated = self._rewrite_handoff_references(updated)
return updated
@@ -156,7 +175,7 @@ class ClineIntegration(MarkdownIntegration):
content_bytes = path.read_bytes()
content = content_bytes.decode("utf-8")
updated = self.post_process_content(content)
updated = self.post_process_command_content(content)
if updated != content:
path.write_bytes(updated.encode("utf-8"))

View File

@@ -0,0 +1,135 @@
"""Factory Droid CLI integration — skills-based agent.
Droid discovers project skills from
``.factory/skills/speckit-<name>/SKILL.md``. Spec Kit installs into that
native tree so the generated skills are visible to Droid without extra
configuration.
See: https://docs.factory.ai/cli/configuration/skills
"""
from __future__ import annotations
from ..base import SkillsIntegration
class DroidIntegration(SkillsIntegration):
"""Integration for Factory Droid CLI."""
key = "droid"
config = {
"name": "Factory Droid",
"folder": ".factory/",
"commands_subdir": "skills",
"install_url": "https://docs.factory.ai/cli/getting-started/overview",
"requires_cli": True,
}
registrar_config = {
"dir": ".factory/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
multi_install_safe = True
@staticmethod
def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str:
"""Insert ``key: value`` before the closing ``---`` if not already present.
Mirrors the helper used by ``ClaudeIntegration`` / ``VibeIntegration``
so per-agent frontmatter injection stays consistent across skills-based
integrations. Pre-scans for the key to keep injection idempotent.
"""
lines = content.splitlines(keepends=True)
# Pre-scan: bail out if already present in frontmatter
dash_count = 0
for line in lines:
stripped = line.rstrip("\n\r")
if stripped == "---":
dash_count += 1
if dash_count == 2:
break
continue
if dash_count == 1 and stripped.startswith(f"{key}:"):
return content
# Inject before the closing --- of frontmatter. Always emit a
# newline after the injected key so the key and the closing ---
# stay on separate lines even when the closing delimiter is the
# last line of the file with no trailing newline.
out: list[str] = []
dash_count = 0
injected = False
for line in lines:
stripped = line.rstrip("\n\r")
if stripped == "---":
dash_count += 1
if dash_count == 2 and not injected:
out.append(f"{key}: {value}\n")
injected = True
out.append(line)
return "".join(out)
def post_process_skill_content(self, content: str) -> str:
"""Inject Droid-specific skill frontmatter flags.
Applies the shared hook-command normalization note (skills agents use
hyphenated ``/speckit-<name>`` invocations, not dotted ``/speckit.<name>``)
and the Droid-specific ``user-invocable`` / ``disable-model-invocation``
frontmatter flags so skills are both user- and Droid-invocable.
"""
updated = super().post_process_skill_content(content)
updated = self._inject_frontmatter_flag(updated, "user-invocable")
updated = self._inject_frontmatter_flag(updated, "disable-model-invocation", "false")
return updated
def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
"""Build CLI arguments for non-interactive ``droid`` execution.
Uses ``droid exec "<prompt>"`` for headless dispatch. Spec Kit does
not auto-apply any permission-bypass flag: operators who want to
skip interactive confirmation can pass it through
``SPECKIT_INTEGRATION_DROID_EXTRA_ARGS`` (e.g.
``SPECKIT_INTEGRATION_DROID_EXTRA_ARGS="--skip-permissions-unsafe"``).
Output format and model selection mirror the documented CLI flags:
``--output-format json`` (when ``output_json`` is set) and
``--model <id>``. Operator-supplied extra args via
``SPECKIT_INTEGRATION_DROID_EXTRA_ARGS`` are appended after the
canonical Spec Kit flags so the canonical flags are guaranteed to
be present in argv. Note that with duplicate-flag CLI parsing the
later (operator-supplied) value may take precedence over the
canonical one, so operators can still override ``--model`` or
``--output-format``.
"""
if not self.config or not self.config.get("requires_cli"):
return None
args = [
self._resolve_executable(),
"exec",
prompt,
]
# Operator-injected extra args are appended after Spec Kit's
# canonical --model / --output-format flags so the canonical
# flags are guaranteed to be present in argv regardless of
# whatever the operator passes via SPECKIT_INTEGRATION_DROID_EXTRA_ARGS.
# This is a deliberate inversion of the cursor-agent / opencode /
# codex ordering (which all apply extra args first, then append
# canonical flags so the canonical values win under duplicate-flag
# parsing). For Droid the canonical flag values are written into
# argv first, then the operator-supplied values follow; with
# duplicate-flag parsing the later (operator) value may therefore
# take precedence.
if model:
args.extend(["--model", model])
if output_json:
args.extend(["--output-format", "json"])
self._apply_extra_args_env_var(args)
return args

View File

@@ -13,6 +13,13 @@ _KIRO_ARG_FALLBACK = "(the user will provide the argument in this conversation)"
class KiroCliIntegration(MarkdownIntegration):
key = "kiro-cli"
# Kiro CLI keeps everything under a static, isolated agent root
# (``.kiro/`` with commands in ``.kiro/prompts``) that no other
# integration writes to, so it is safe to install alongside others
# (issue #3471). IntegrationBase defaults this to False; declaring it
# True here is the actual behavior change this integration opts into.
# The registry's multi-install-safe contract tests enforce that
# isolation for every integration setting this flag.
multi_install_safe = True
config = {
"name": "Kiro CLI",
@@ -27,10 +34,3 @@ class KiroCliIntegration(MarkdownIntegration):
"args": _KIRO_ARG_FALLBACK,
"extension": ".md",
}
# Kiro CLI keeps everything under a static, isolated agent root
# (``.kiro/`` with commands in ``.kiro/prompts``) that no other
# integration writes to, so it is safe to install alongside others
# (issue #3471). The registry's multi-install-safe contract tests
# enforce that isolation for every integration setting this flag.
multi_install_safe = True

View File

@@ -27,6 +27,7 @@ class LingmaIntegration(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
multi_install_safe = True
@classmethod
def options(cls) -> list[IntegrationOption]:

View File

@@ -16,6 +16,10 @@ import yaml
from rich.markup import escape as _escape_markup
from .._console import console
from .._download_security import (
is_https_or_localhost_http,
is_safe_download_redirect,
)
preset_app = typer.Typer(
name="preset",
@@ -102,38 +106,25 @@ def preset_add(
elif from_url:
# Validate URL scheme before downloading
from ipaddress import ip_address
from urllib.parse import urlparse as _urlparse
try:
_parsed = _urlparse(from_url)
_parsed.port
except ValueError:
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
if not host:
return False
is_loopback = host == "localhost"
if not is_loopback:
try:
is_loopback = ip_address(host).is_loopback
except ValueError:
# Host is not an IP literal (e.g., a regular hostname); treat as non-loopback.
pass
return parsed_url.scheme == "https" or (parsed_url.scheme == "http" and is_loopback)
def _validate_download_redirect(old_url, new_url):
if not _is_allowed_download_url(_urlparse(new_url)):
if not is_safe_download_redirect(old_url, new_url):
import urllib.error
raise urllib.error.URLError(
"redirect target must use HTTPS with a hostname, "
"or HTTP for localhost/loopback"
"redirect target must use HTTPS without entering a local "
"target, or stay within loopback over HTTP"
)
if not _is_allowed_download_url(_parsed):
if not is_https_or_localhost_http(from_url):
console.print(
"[red]Error:[/red] URL must use HTTPS with a hostname, "
"or HTTP for localhost/loopback."
@@ -167,7 +158,7 @@ def preset_add(
redirect_validator=_validate_download_redirect,
) as response:
final_url = response.geturl() if hasattr(response, "geturl") else from_url
if not _is_allowed_download_url(_urlparse(final_url)):
if not is_https_or_localhost_http(final_url):
console.print(
"[red]Error:[/red] Preset URL redirected to a disallowed URL: "
f"{final_url}. Redirect targets must use HTTPS with a hostname, "

View File

@@ -20,6 +20,10 @@ import yaml
from rich.markup import escape as _escape_markup
from .._console import console, err_console
from .._download_security import (
is_https_or_localhost_http,
is_safe_download_redirect,
)
from .._project import _resolve_init_dir_override
workflow_app = typer.Typer(
@@ -383,27 +387,12 @@ _RESERVED_WORKFLOW_IDS: frozenset[str] = frozenset({"overlays", "runs", "steps"}
def _reject_insecure_download_redirect(old_url: str, new_url: str) -> None:
"""Reject insecure redirects before they are followed."""
import urllib.error
from ipaddress import ip_address
from urllib.parse import urlparse
def _is_loopback_http(url: str) -> bool:
parsed = urlparse(url)
if parsed.scheme != "http":
return False
host = parsed.hostname or ""
if host == "localhost":
return True
try:
return ip_address(host).is_loopback
except ValueError:
return False
if urlparse(new_url).scheme == "https":
return
if _is_loopback_http(old_url) and _is_loopback_http(new_url):
if is_safe_download_redirect(old_url, new_url):
return
raise urllib.error.URLError(
"redirect target must use HTTPS; loopback HTTP may only redirect from loopback HTTP"
"redirect target must use HTTPS without entering a local target; "
"loopback HTTP may only redirect from another loopback URL"
)
@@ -1555,7 +1544,7 @@ def workflow_add(
# precedence over --from so a URL that would be ignored is never fetched.
if dev:
dev_path = Path(source).expanduser()
if dev_path.is_file() and dev_path.suffix in (".yml", ".yaml"):
if dev_path.is_file() and dev_path.suffix.lower() in (".yml", ".yaml"):
_validate_and_install_local(dev_path, str(dev_path))
return
if dev_path.is_dir():
@@ -1579,24 +1568,15 @@ def workflow_add(
else (source if source.startswith(("http://", "https://")) else None)
)
if download_url is not None:
from ipaddress import ip_address
from urllib.parse import urlparse
from specify_cli.authentication.http import open_url as _open_url
try:
parsed_src = urlparse(download_url)
urlparse(download_url).port
except ValueError:
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(download_url)}")
raise typer.Exit(1)
src_host = parsed_src.hostname or ""
src_loopback = src_host == "localhost"
if not src_loopback:
try:
src_loopback = ip_address(src_host).is_loopback
except ValueError:
# Host is not an IP literal (e.g., a DNS name); keep default non-loopback.
pass
if parsed_src.scheme != "https" and not (parsed_src.scheme == "http" and src_loopback):
if not is_https_or_localhost_http(download_url):
console.print("[red]Error:[/red] Only HTTPS URLs are allowed, except HTTP for localhost.")
raise typer.Exit(1)
@@ -1647,16 +1627,7 @@ def workflow_add(
redirect_validator=_reject_insecure_download_redirect,
) as resp:
final_url = resp.geturl()
final_parsed = urlparse(final_url)
final_host = final_parsed.hostname or ""
final_lb = final_host == "localhost"
if not final_lb:
try:
final_lb = ip_address(final_host).is_loopback
except ValueError:
# Redirect host is not an IP literal; keep loopback as determined above.
pass
if final_parsed.scheme != "https" and not (final_parsed.scheme == "http" and final_lb):
if not is_https_or_localhost_http(final_url):
console.print(
f"[red]Error:[/red] URL redirected to non-HTTPS: {_escape_markup(final_url)}"
)
@@ -1714,7 +1685,7 @@ def workflow_add(
# Try as a local file/directory
source_path = Path(source)
if source_path.exists():
if source_path.is_file() and source_path.suffix in (".yml", ".yaml"):
if source_path.is_file() and source_path.suffix.lower() in (".yml", ".yaml"):
_validate_and_install_local(source_path, str(source_path))
return
elif source_path.is_dir():
@@ -1788,27 +1759,17 @@ def _install_workflow_from_catalog(
raise typer.Exit(1)
# Validate URL scheme (HTTPS required, HTTP allowed for localhost only)
from ipaddress import ip_address
from urllib.parse import urlparse
try:
parsed_url = urlparse(workflow_url)
url_host = parsed_url.hostname or ""
parsed_url.port
except ValueError:
console.print(
f"[red]Error:[/red] Workflow '{safe_wf_id}' has a malformed install URL."
)
raise typer.Exit(1)
is_loopback = False
if url_host == "localhost":
is_loopback = True
else:
try:
is_loopback = ip_address(url_host).is_loopback
except ValueError:
# Host is not an IP literal (e.g., a regular hostname); treat as non-loopback.
pass
if parsed_url.scheme != "https" and not (parsed_url.scheme == "http" and is_loopback):
if not is_https_or_localhost_http(workflow_url):
console.print(
f"[red]Error:[/red] Workflow '{safe_wf_id}' has an invalid install URL. "
"Only HTTPS URLs are allowed, except HTTP for localhost/loopback."
@@ -1862,16 +1823,7 @@ def _install_workflow_from_catalog(
) as response:
# Validate final URL after redirects
final_url = response.geturl()
final_parsed = urlparse(final_url)
final_host = final_parsed.hostname or ""
final_loopback = final_host == "localhost"
if not final_loopback:
try:
final_loopback = ip_address(final_host).is_loopback
except ValueError:
# Host is not an IP literal (e.g., a regular hostname); treat as non-loopback.
pass
if final_parsed.scheme != "https" and not (final_parsed.scheme == "http" and final_loopback):
if not is_https_or_localhost_http(final_url):
_safe_discard_staged_workflow_file(staged_file, workflow_dir, existed_before)
console.print(
f"[red]Error:[/red] Workflow '{safe_wf_id}' redirected to non-HTTPS URL: {_escape_markup(final_url)}"
@@ -2694,28 +2646,17 @@ def workflow_step_add(
)
raise typer.Exit(1)
from urllib.parse import urlparse
from specify_cli.authentication.http import open_url as _open_url
def _safe_fetch(url: str) -> bytes:
parsed = urlparse(url)
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
if not is_https_or_localhost_http(url):
raise ValueError(f"Refusing to fetch from non-HTTPS URL: {url}")
if not parsed.hostname:
raise ValueError(f"Refusing to fetch from URL with no hostname: {url}")
with _open_url(
url, timeout=30, redirect_validator=_reject_insecure_download_redirect
) as resp:
final_url = resp.geturl()
final_parsed = urlparse(final_url)
final_is_localhost = final_parsed.hostname in ("localhost", "127.0.0.1", "::1")
if final_parsed.scheme != "https" and not (
final_parsed.scheme == "http" and final_is_localhost
):
if not is_https_or_localhost_http(final_url):
raise ValueError(f"Redirect to non-HTTPS URL: {final_url}")
if not final_parsed.hostname:
raise ValueError(f"Redirect to URL with no hostname: {final_url}")
return _read_response_within_limit(resp)
_validate_step_id_or_exit(step_id)

View File

@@ -894,7 +894,11 @@ class StepRegistry:
import copy
from datetime import datetime, timezone
existing = self.data["steps"].get(step_id, {})
raw_existing = self.data["steps"].get(step_id)
# Corrupted-but-parseable registries may hold non-dict entries; treat
# them as absent rather than crashing on existing.get() (mirrors
# WorkflowRegistry.add).
existing = raw_existing if isinstance(raw_existing, dict) else {}
metadata_to_store = copy.deepcopy(metadata)
metadata_to_store["installed_at"] = existing.get(
"installed_at", datetime.now(timezone.utc).isoformat()

View File

@@ -535,6 +535,10 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
items = [
_evaluate_simple_expression(i.strip(), namespace)
for i in _split_top_level_commas(inner)
# Drop empty segments from trailing/leading/double commas ([1, 2,] ->
# [1, 2], not [1, 2, None]). An intentional empty-string element
# ('') strips to "''" (truthy), so ['', 'a'] is preserved.
if i.strip()
]
return items

View File

@@ -292,9 +292,21 @@ def _traverse_and_apply(
cases[case_key] = _traverse_and_apply(case_steps, edits_by_anchor, sources)
result.append(step)
# Insert after (highest priority closest to anchor — reversed merge order).
for layer, edit in reversed(edits):
if edit.operation == "insert_after":
# Insert after: higher-priority overlays land closer to the anchor
# (reversed merge order), but a single overlay's own inserts must keep
# their declared order — mirroring the forward insert_before loop above.
# Reversing the whole flat list would also flip an overlay's own edits,
# so group contiguous same-layer edits and reverse the GROUP order only.
after_groups: list[list[tuple[OverlayLayer, OverlayEdit]]] = []
for layer, edit in edits:
if edit.operation != "insert_after":
continue
if after_groups and after_groups[-1][0][0] is layer:
after_groups[-1].append((layer, edit))
else:
after_groups.append([(layer, edit)])
for group in reversed(after_groups):
for layer, edit in group:
new_step = copy.deepcopy(edit.step)
_record_sources_recursively(new_step, layer.source, sources)
result.append(new_step)

View File

@@ -189,7 +189,11 @@ class CommandStep(StepBase):
not possible (integration not found, CLI not installed, or
dispatch not supported).
"""
if not integration_key:
if not integration_key or not isinstance(integration_key, str):
# A non-string integration (a list/dict/expression that resolved to
# one) would raise TypeError: unhashable type from get_integration's
# dict lookup below and abort the whole run. Treat it as "not
# dispatchable" so execute() falls through to its FAILED StepResult.
return None
try:

View File

@@ -26,7 +26,7 @@ class GateStep(StepBase):
later with ``specify workflow resume``.
The user's choice is stored in ``output.choice``. ``on_reject``
controls abort / skip behaviour.
controls abort / skip / retry behaviour.
"""
type_key = "gate"
@@ -168,7 +168,11 @@ class GateStep(StepBase):
except (EOFError, KeyboardInterrupt):
print()
return options[-1] # default to last (usually reject)
if raw.isdigit() and 1 <= int(raw) <= len(options):
# isdecimal() (not isdigit()): int() accepts exactly the decimal-digit
# set, whereas isdigit() also returns True for superscripts/subscripts
# (e.g. "²") that int() then rejects with ValueError — crashing
# this interactive loop.
if raw.isdecimal() and 1 <= int(raw) <= len(options):
return options[int(raw) - 1]
# Also accept the option name directly
if raw.lower() in [o.lower() for o in options]:

View File

@@ -59,7 +59,7 @@ class InitStep(StepBase):
Extra options for the integration (e.g. ``"--skills"`` or
``"--commands-dir .myagent/cmds"``).
``script``
Script type, ``sh`` or ``ps``.
Script type, ``sh``, ``ps``, or ``py``.
``force``
Merge/overwrite without confirmation when the directory is not
empty.

View File

@@ -138,7 +138,10 @@ class PromptStep(StepBase):
context: StepContext,
) -> dict[str, Any] | None:
"""Dispatch *prompt* directly through the integration CLI."""
if not integration_key or not prompt:
if not integration_key or not isinstance(integration_key, str) or not prompt:
# A non-string integration would raise TypeError: unhashable type
# from get_integration's dict lookup and abort the run; treat it as
# not dispatchable so execute() falls through to its FAILED result.
return None
try:

View File

@@ -14,6 +14,25 @@ $ARGUMENTS
You **MUST** consider the user input before proceeding (if not empty).
## Scope Guard
This command's own work is limited to updating the project constitution and propagating
constitution-driven changes to the dependent artifacts identified in this command.
- Classify every part of the user input as either constitution content or a separate,
non-governance intent.
- If the input includes feature implementation, code generation, refactoring, building, or
deployment requests, you **MUST NOT** execute them. Extract them as deferred intents instead.
- You **MUST NOT** create, modify, or delete application source files, feature routes,
components, tests, deployment files, or other artifacts unrelated to the constitution
workflow and its required propagation.
- If it is unclear whether an instruction is constitution content, ask for clarification before
making changes.
- After completing the constitution update, include a `Next Actions` section for each deferred
intent. List the original intent and suggest the appropriate follow-up Spec Kit command, such
as `__SPECKIT_COMMAND_SPECIFY__`, without invoking it.
- If there are no non-governance intents, omit the `Next Actions` section.
## Pre-Execution Checks
**Check for extension hooks (before constitution update)**:
@@ -104,6 +123,7 @@ Follow this execution flow:
- New version and bump rationale.
- Any files flagged for manual follow-up.
- Suggested commit message (e.g., `docs: amend constitution to vX.Y.Z (principle additions + governance update)`).
- A `Next Actions` section for any deferred non-governance intents.
Formatting & Style Requirements:

View File

@@ -139,9 +139,9 @@ Given that feature description, do this:
7. Identify Key Entities (if data involved)
8. Return: SUCCESS (spec ready for planning)
6. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
7. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
7. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria:
8. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria:
a. **Create Spec Quality Checklist**: Generate a checklist file at `SPECIFY_FEATURE_DIRECTORY/checklists/requirements.md` using the checklist template structure with these validation items:

View File

@@ -43,6 +43,42 @@ def test_builtin_default_stack_when_no_config(tmp_path: Path):
assert all(s.scope is Scope.BUILTIN for s in sources)
def test_non_list_catalogs_raises_actionable_error(tmp_path: Path):
"""A scalar ``catalogs:`` value raises a clean BundlerError, not a raw
'int object is not iterable' TypeError — matching what the sibling reader
(bundle catalog list) already reports for the same file."""
make_project(tmp_path)
(tmp_path / ".specify" / "bundle-catalogs.yml").write_text(
"catalogs: 5\n", encoding="utf-8"
)
with pytest.raises(BundlerError, match="must be a list"):
load_source_stack(tmp_path)
@pytest.mark.parametrize("value", ["false", "0", "''", "{}"])
def test_falsy_non_list_catalogs_still_raises(tmp_path: Path, value: str):
"""A *falsy* non-list ``catalogs:`` value (false/0/''/{}) must also raise —
only an absent/``None`` value means "nothing to merge". A plain falsy check
would silently swallow these, diverging from the sibling reader."""
make_project(tmp_path)
(tmp_path / ".specify" / "bundle-catalogs.yml").write_text(
f"catalogs: {value}\n", encoding="utf-8"
)
with pytest.raises(BundlerError, match="must be a list"):
load_source_stack(tmp_path)
@pytest.mark.parametrize("body", ["catalogs:\n", "catalogs: []\n"])
def test_absent_or_empty_catalogs_is_noop(tmp_path: Path, body: str):
"""An absent (``None``) or empty-list ``catalogs:`` is valid: it contributes
no project sources and falls back to the built-in default stack."""
make_project(tmp_path)
(tmp_path / ".specify" / "bundle-catalogs.yml").write_text(body, encoding="utf-8")
# Does not raise; still yields the built-in defaults.
sources = load_source_stack(tmp_path)
assert len(sources) > 0
def test_project_config_overrides_same_id(tmp_path: Path):
make_project(tmp_path)
config = {

View File

@@ -124,3 +124,44 @@ def test_string_mcp_rejected_not_split_per_character():
data["requires"]["mcp"] = "github"
with pytest.raises(BundlerError, match="'requires.mcp' must be a list of strings"):
BundleManifest.from_dict(data)
def test_string_integration_rejected_not_silently_dropped():
# A present-but-non-mapping 'integration' (a bare string) was silently
# dropped, leaving the bundle wrongly integration-agnostic. Reject it like
# the sibling requires/provides mapping fields.
data = valid_manifest_dict()
data["integration"] = "copilot"
with pytest.raises(BundlerError, match="'integration' must be a mapping when present"):
BundleManifest.from_dict(data)
@pytest.mark.parametrize("bad", [[], "", 0, False, "extensions"])
def test_non_mapping_provides_rejected_including_falsy(bad):
# `data.get("provides") or {}` coerced a FALSY non-mapping ([], '', 0, False)
# to {} before the type check, so a malformed manifest passed validation as
# a bundle that provides nothing. Only an absent/None value means "empty".
data = valid_manifest_dict()
data["provides"] = bad
with pytest.raises(BundlerError, match="'provides' must be a mapping when present"):
BundleManifest.from_dict(data)
@pytest.mark.parametrize("bad", [[], "", 0, False, "speckit>=0.1"])
def test_non_mapping_requires_rejected_including_falsy(bad):
# Same falsy-coercion hole for `requires`.
data = valid_manifest_dict()
data["requires"] = bad
with pytest.raises(BundlerError, match="'requires' must be a mapping when present"):
BundleManifest.from_dict(data)
def test_absent_provides_and_requires_do_not_raise_mapping_error():
# Absent (None) optional mappings default to empty and must NOT trigger the
# "must be a mapping when present" guard — that is reserved for present
# non-mappings. (Structural completeness, e.g. requires.speckit_version, is
# a separate concern checked by structural_errors().)
data = valid_manifest_dict()
data.pop("provides", None)
data.pop("requires", None)
BundleManifest.from_dict(data) # does not raise BundlerError

View File

@@ -0,0 +1,40 @@
"""Contract tests for the script variants bundled into the wheel's core_pack.
``specify init --script <type>`` installs from ``specify_cli/core_pack/scripts/``
when the CLI runs from a wheel. Any script variant that lives in the repository
must therefore be force-included at build time, otherwise the generated
commands reference scripts the released package never ships (#3665).
"""
from __future__ import annotations
import tomllib
from pathlib import Path
REPO_ROOT = Path(__file__).parents[2]
def _force_include() -> dict[str, str]:
with (REPO_ROOT / "pyproject.toml").open("rb") as pyproject_file:
pyproject = tomllib.load(pyproject_file)
return pyproject["tool"]["hatch"]["build"]["targets"]["wheel"]["force-include"]
def test_every_script_variant_is_bundled_into_core_pack():
force_include = _force_include()
variants = sorted(
path.name for path in (REPO_ROOT / "scripts").iterdir() if path.is_dir()
)
assert variants, "expected at least one script variant under scripts/"
for variant in variants:
assert force_include.get(f"scripts/{variant}") == (
f"specify_cli/core_pack/scripts/{variant}"
), f"scripts/{variant} is missing from the wheel force-include list"
def test_python_script_variant_is_bundled():
# Explicit regression guard for #3665: `--script py` shipped skills that
# invoked python3 .specify/scripts/python/*.py while the wheel bundled
# only the bash and PowerShell variants.
assert _force_include()["scripts/python"] == "specify_cli/core_pack/scripts/python"

View File

@@ -698,6 +698,22 @@ class TestCreateFeaturePowerShell:
assert rt.returncode == 0, rt.stderr
assert "HAS_GIT" not in rt.stdout
def test_persist_hint_matches_twins(self, tmp_path: Path):
"""The non-JSON SPECIFY_FEATURE hint must use the '# To persist in your
shell: $env:SPECIFY_FEATURE = '<name>' form — matching the core
create-new-feature.ps1 twin and the bash/python twins of this script —
not the old 'environment variable set to:' wording (the env var is only
set in this child process, so the actionable output is the persist hint)."""
project = _setup_project(tmp_path)
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-ShortName", "persist", "Persist hint feature",
)
assert result.returncode == 0, result.stderr
assert "# To persist in your shell:" in result.stdout
assert "$env:SPECIFY_FEATURE = '001-persist'" in result.stdout
assert "environment variable set to:" not in result.stdout
def test_help_documents_branch_prefix(self, tmp_path: Path):
"""-Help documents both template config knobs."""
project = _setup_project(tmp_path)

View File

@@ -515,6 +515,32 @@ class TestAutoCommitParity:
assert p.stderr.strip() == b.stderr.strip()
assert self._last_message(bash_proj) == self._last_message(py_proj) == "spec done"
def test_custom_message_with_trailing_whitespace_after_quote(self, tmp_path: Path):
"""Trailing whitespace after a closing quote must not leave the quote
dangling in the commit message. A raw close-quote strip anchored to
end-of-string skips the quote when spaces follow it (``spec done" ``);
trimming first (matching the PowerShell twin) yields a clean message and
keeps bash/python in parity."""
bash_proj, py_proj = _twin_projects(tmp_path)
config = (
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
' message: "spec done" \n' # trailing spaces after the closing quote
)
for proj in (bash_proj, py_proj):
_write_config(proj, config)
self._dirty(proj)
b = _run_bash("auto-commit.sh", bash_proj, "after_specify")
p = _run_py("auto-commit", py_proj, "after_specify")
_assert_parity(b, p)
assert (
self._last_message(bash_proj)
== self._last_message(py_proj)
== "spec done"
)
def test_default_true_applies_to_unlisted_event(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
for proj in (bash_proj, py_proj):

View File

@@ -1,15 +1,46 @@
"""HTTP test helpers shared by version-related CLI tests."""
"""HTTP test helpers shared by CLI tests."""
import io
import json
import urllib.request
from unittest.mock import MagicMock
import pytest
def mock_urlopen_response(payload: dict) -> MagicMock:
"""Build a urlopen context-manager mock whose read returns JSON."""
body = json.dumps(payload).encode("utf-8")
resp = MagicMock()
resp.read.return_value = body
resp.read.side_effect = io.BytesIO(body).read
cm = MagicMock()
cm.__enter__.return_value = resp
cm.__exit__.return_value = False
return cm
@pytest.fixture(autouse=True)
def route_opener_open_through_urlopen(monkeypatch):
"""Route build_opener().open through urllib.request.urlopen.
``open_url(...)`` fetches via ``build_opener(...).open()``, which bypasses
``urllib.request.urlopen`` — and with it the urlopen patches these test
modules are built on.
Delegating ``open()`` to urlopen at call time keeps those patches
effective; the redirect handler's own behavior is covered by
``TestRedirectStripping`` in test_authentication.py.
Import this fixture into a test module to activate it there.
"""
class _UrlopenDelegatingOpener:
def open(self, req, data=None, timeout=None):
if data is None:
return urllib.request.urlopen(req, timeout=timeout)
return urllib.request.urlopen(req, data=data, timeout=timeout)
monkeypatch.setattr(
urllib.request,
"build_opener",
lambda *handlers: _UrlopenDelegatingOpener(),
)

View File

@@ -236,6 +236,24 @@ class TestBuildCommandInvocation:
== "/speckit-git-commit fix typo"
)
def test_cline_core_command_hyphenated(self):
"""Cline installs hyphenated slash-commands (/speckit-<name>), so the
dispatch invocation must be hyphenated too — not the dotted default it
would inherit from MarkdownIntegration."""
from specify_cli.integrations import get_integration
i = get_integration("cline")
assert i.build_command_invocation("speckit.plan") == "/speckit-plan"
assert i.build_command_invocation("plan") == "/speckit-plan"
def test_cline_extension_command_hyphenated(self):
from specify_cli.integrations import get_integration
i = get_integration("cline")
assert i.build_command_invocation("speckit.git.commit") == "/speckit-git-commit"
assert (
i.build_command_invocation("speckit.git.commit", "fix typo")
== "/speckit-git-commit fix typo"
)
class TestResolveCommandRefs:
"""Tests for __SPECKIT_COMMAND_<NAME>__ placeholder resolution."""

View File

@@ -6,6 +6,8 @@ import os
import pytest
import yaml
from tests.http_helpers import route_opener_open_through_urlopen # noqa: F401
from specify_cli.integrations.catalog import (
IntegrationCatalog,
IntegrationCatalogEntry,
@@ -13,9 +15,34 @@ from specify_cli.integrations.catalog import (
IntegrationDescriptor,
IntegrationDescriptorError,
IntegrationValidationError,
_catalog_shape_error,
)
class TestCatalogShapeValidator:
"""The shared shape validator used by BOTH the fresh-fetch and cache-read
paths, so a poisoned/older cache can't bypass the format contract the fresh
fetch enforces (dict + 'schema_version' + dict 'integrations')."""
def test_valid_payload_returns_none(self):
assert _catalog_shape_error({"schema_version": "1.0", "integrations": {}}) is None
def test_missing_schema_version_is_rejected(self):
# The exact bypass the two paths used to disagree on: a dict with a dict
# 'integrations' but no 'schema_version'.
assert _catalog_shape_error({"integrations": {}}) is not None
def test_missing_integrations_is_rejected(self):
assert _catalog_shape_error({"schema_version": "1.0"}) is not None
def test_non_dict_integrations_is_rejected(self):
assert _catalog_shape_error({"schema_version": "1.0", "integrations": []}) is not None
@pytest.mark.parametrize("payload", [[], "x", 5, None])
def test_non_dict_payload_is_rejected(self, payload):
assert _catalog_shape_error(payload) is not None
# ---------------------------------------------------------------------------
# IntegrationCatalogEntry
# ---------------------------------------------------------------------------
@@ -251,6 +278,48 @@ class TestCatalogFetch:
ids = [r["id"] for r in results]
assert "acme-coder" in ids
def test_poisoned_cache_shape_is_dropped_and_refetched(self, tmp_path, monkeypatch):
"""A fresh-but-mis-shaped cache (e.g. integrations as a list) must be
dropped and refetched, not returned — otherwise it later crashes on
.items(). The cache path must clear the same shape checks as a fresh
fetch."""
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
(tmp_path / ".specify").mkdir()
cat = IntegrationCatalog(tmp_path)
catalog = {
"schema_version": "1.0",
"updated_at": "2026-01-01T00:00:00Z",
"integrations": {
"acme-coder": {
"id": "acme-coder", "name": "Acme Coder", "version": "2.0.0",
"description": "Community integration", "author": "acme-org",
"tags": ["cli"],
},
},
}
self._patch_urlopen(monkeypatch, catalog)
cat.search() # populate the cache legitimately
# Poison the cached payload (integrations as a list), keeping the fresh
# metadata so the age check passes and the cache branch is taken.
cache_dir = tmp_path / ".specify" / "integrations" / ".cache"
data_files = [
f for f in cache_dir.glob("catalog-*.json")
if not f.name.endswith("-metadata.json")
]
assert data_files, "cache was not populated"
data_files[0].write_text(
json.dumps({"schema_version": "1.0", "integrations": []}),
encoding="utf-8",
)
# The poisoned cache is dropped and the (valid) source is refetched.
results = cat.search()
assert "acme-coder" in [r["id"] for r in results]
def test_search_by_tag(self, tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))

View File

@@ -0,0 +1,262 @@
"""Tests for DroidIntegration (Factory Droid CLI)."""
from urllib.parse import urlparse
import pytest
from specify_cli.integrations import get_integration
from specify_cli.integrations.droid import DroidIntegration
from specify_cli.integrations.manifest import IntegrationManifest
from .test_integration_base_skills import SkillsIntegrationTests
class TestDroidIntegration(SkillsIntegrationTests):
KEY = "droid"
FOLDER = ".factory/"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".factory/skills"
def test_options_include_skills_flag(self):
"""Not applicable — Droid only supports the skills layout."""
pytest.skip("Droid is always skills-based and does not expose a --skills option")
def test_options_do_not_include_skills_flag(self):
"""Droid is always skills-based; no --skills option is exposed."""
i = get_integration(self.KEY)
assert i is not None
opts = i.options()
skills_opts = [o for o in opts if o.name == "--skills"]
assert len(skills_opts) == 0, (
"Droid is always skills-based and should not expose a --skills option"
)
def test_requires_cli_is_true(self):
"""Droid is a CLI tool; requires_cli must be True."""
i = get_integration(self.KEY)
assert i is not None
assert i.config["requires_cli"] is True
assert i.config["name"] == "Factory Droid"
def test_multi_install_safe_is_true(self):
"""Droid uses an isolated .factory/ root — safe to install alongside others."""
i = get_integration(self.KEY)
assert i.multi_install_safe is True
def test_install_url_points_to_factory(self):
i = get_integration(self.KEY)
url = i.config.get("install_url")
assert url is not None
host = (urlparse(url).hostname or "").lower()
assert host == "factory.ai" or host.endswith(".factory.ai"), (
f"install_url must point at the Factory domain, got: {url}"
)
class TestDroidInitFlow:
"""--integration droid creates expected files."""
def test_integration_droid_creates_skills(self, tmp_path):
"""--integration droid should create skills under .factory/skills."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
target = tmp_path / "test-proj"
result = runner.invoke(
app,
[
"init",
str(target),
"--integration",
"droid",
"--ignore-agent-tools",
"--script",
"sh",
],
catch_exceptions=False,
)
assert result.exit_code == 0, f"init --integration droid failed: {result.output}"
assert (target / ".factory" / "skills" / "speckit-plan" / "SKILL.md").exists()
assert (target / ".factory" / "skills" / "speckit-specify" / "SKILL.md").exists()
class TestDroidBuildExecArgs:
"""Droid non-interactive execution argument building."""
def test_default_argv_uses_exec_subcommand(self):
"""Default argv: ``droid exec <prompt> --output-format json``.
No permission-bypass flag is auto-applied — operators who need it
must pass it through ``SPECKIT_INTEGRATION_DROID_EXTRA_ARGS``.
"""
i = get_integration("droid")
args = i.build_exec_args("/speckit-specify some-feature")
assert args == [
"droid",
"exec",
"/speckit-specify some-feature",
"--output-format",
"json",
]
assert "--skip-permissions-unsafe" not in args, (
"Spec Kit must not auto-apply --skip-permissions-unsafe; "
"it is a dangerous flag and operators must opt in explicitly"
)
def test_text_output_omits_format_flag(self):
i = get_integration("droid")
args = i.build_exec_args("/speckit-plan", output_json=False)
assert args == [
"droid",
"exec",
"/speckit-plan",
]
assert "--skip-permissions-unsafe" not in args
def test_model_is_appended(self):
i = get_integration("droid")
args = i.build_exec_args(
"/speckit-specify", model="claude-opus-4-7", output_json=False
)
assert args == [
"droid",
"exec",
"/speckit-specify",
"--model",
"claude-opus-4-7",
]
assert "--skip-permissions-unsafe" not in args
def test_extra_args_inserted_after_canonical_flags(self, monkeypatch):
"""Operator-injected extra args land after Spec Kit's canonical
``--model`` / ``--output-format`` flags so the canonical flags are
always present in argv regardless of operator override."""
from specify_cli.integrations import get_integration
i = get_integration("droid")
monkeypatch.setenv("SPECKIT_INTEGRATION_DROID_EXTRA_ARGS", "--foo bar")
args = i.build_exec_args(
"/speckit-plan", model="claude-sonnet", output_json=True
)
assert "--foo" in args
assert "bar" in args
assert args.index("bar") == args.index("--foo") + 1
# Extra args land AFTER the canonical flags so the canonical flags
# are always present in argv.
assert args.index("--model") < args.index("--foo")
assert args.index("--output-format") < args.index("--foo")
assert args[args.index("--model") + 1] == "claude-sonnet"
assert args[args.index("--output-format") + 1] == "json"
def test_executable_override(self, monkeypatch):
"""``SPECKIT_INTEGRATION_DROID_EXECUTABLE`` overrides argv[0]."""
monkeypatch.setenv(
"SPECKIT_INTEGRATION_DROID_EXECUTABLE", "/custom/droid"
)
i = get_integration("droid")
args = i.build_exec_args("/speckit-plan", output_json=False)
assert args[0] == "/custom/droid"
# No dangerous permission-bypass flag should leak in via the override path.
assert "--skip-permissions-unsafe" not in args
def test_returns_none_when_requires_cli_is_false(self, monkeypatch):
"""When ``requires_cli`` is False, ``build_exec_args`` returns None."""
i = get_integration("droid")
monkeypatch.setitem(i.config, "requires_cli", False)
assert i.build_exec_args("/speckit-plan") is None
class TestDroidFrontmatter:
"""Every generated SKILL.md must carry Droid-specific frontmatter flags."""
def test_skills_carry_user_invocable_true(self, tmp_path):
i = get_integration("droid")
m = IntegrationManifest("droid", tmp_path)
i.setup(tmp_path, m, script_type="sh")
skill_files = [
f
for f in (tmp_path / ".factory" / "skills").rglob("SKILL.md")
]
assert skill_files, "expected at least one SKILL.md"
for f in skill_files:
content = f.read_text(encoding="utf-8")
assert "user-invocable: true" in content, (
f"{f} missing user-invocable: true"
)
def test_skills_carry_disable_model_invocation_false(self, tmp_path):
i = get_integration("droid")
m = IntegrationManifest("droid", tmp_path)
i.setup(tmp_path, m, script_type="sh")
skill_files = [
f
for f in (tmp_path / ".factory" / "skills").rglob("SKILL.md")
]
assert skill_files, "expected at least one SKILL.md"
for f in skill_files:
content = f.read_text(encoding="utf-8")
assert "disable-model-invocation: false" in content, (
f"{f} missing disable-model-invocation: false"
)
def test_inject_frontmatter_flag_adds_key_when_absent(self):
"""Fresh content (key absent) gets the flag injected on its own line."""
content = "---\nname: x\ndescription: y\n---\n\nBody.\n"
result = DroidIntegration._inject_frontmatter_flag(content, "user-invocable")
assert "user-invocable: true" in result
# The injected key must sit on its own line, not glued to the closing ---.
assert "\nuser-invocable: true\n---" in result, (
"Injected key must be on its own line, not fused to closing ---"
)
def test_inject_frontmatter_flag_injects_custom_value(self):
"""The value parameter must be honored (used for disable-model-invocation: false)."""
content = "---\nname: x\n---\n\nBody.\n"
result = DroidIntegration._inject_frontmatter_flag(
content, "disable-model-invocation", "false"
)
assert "disable-model-invocation: false" in result
def test_inject_frontmatter_flag_no_trailing_newline(self):
"""Regression for the frontmatter-fusion P2 bug.
When the closing ``---`` is the literal last line of the file with
no trailing newline, the injected key must still land on its own
line (not fused onto the closing delimiter). Previously this
produced ``user-invocable: true---``, an unparseable YAML line.
"""
content = "---\nname: x\ndescription: y\n---"
result = DroidIntegration._inject_frontmatter_flag(content, "user-invocable")
assert "user-invocable: true" in result
# The injected key and the closing delimiter must NOT be fused.
assert "user-invocable: true---" not in result, (
"Injected key fused onto closing ---; no-trailing-newline regression"
)
# And the injected key must be on its own line.
assert "\nuser-invocable: true\n---" in result
def test_frontmatter_injection_is_idempotent(self):
"""Running the post-processor twice must not duplicate the flag."""
content = "---\nname: x\n---\n\nBody.\n"
once = DroidIntegration._inject_frontmatter_flag(content, "user-invocable")
twice = DroidIntegration._inject_frontmatter_flag(once, "user-invocable")
assert once == twice, "Frontmatter injection must be idempotent"
# Belt-and-braces: the flag must appear exactly once.
assert once.count("user-invocable: true") == 1
class TestDroidCommandInvocation:
"""Skills agents use the hyphenated ``/speckit-<name>`` slash form."""
def test_build_command_invocation_uses_hyphenated_skill_name(self):
i = get_integration("droid")
assert i.build_command_invocation("speckit.plan", "feature-x") == (
"/speckit-plan feature-x"
)
assert i.build_command_invocation("plan") == "/speckit-plan"

View File

@@ -475,3 +475,39 @@ class TestForgeCommandRegistrar:
"Found '/speckit.specify' (dot notation) in generated Forge git.feature command body. "
"Forge requires hyphen notation for ZSH compatibility."
)
class TestForgeInitNextSteps:
"""The post-init 'Next steps' panel must show hyphenated /speckit-<name>
commands for Forge, since Forge only registers the hyphenated form
(see the generated command-file tests above)."""
def test_init_next_steps_show_hyphenated_commands(self, tmp_path):
import os
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "forge-nextsteps"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(
app,
["init", "--here", "--integration", "forge", "--ignore-agent-tools"],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
# Forge registers /speckit-<name>; the next-steps panel must match.
assert "/speckit-plan" in result.output, (
f"Expected /speckit-plan in next steps but got:\n{result.output}"
)
# Must NOT show the dotted /speckit.plan form Forge can't invoke.
assert "/speckit.plan" not in result.output, (
f"Should not show dotted /speckit.plan for Forge:\n{result.output}"
)

View File

@@ -1,5 +1,7 @@
"""Tests for LingmaIntegration."""
from specify_cli.integrations import get_integration
from .test_integration_base_skills import SkillsIntegrationTests
@@ -8,3 +10,9 @@ class TestLingmaIntegration(SkillsIntegrationTests):
FOLDER = ".lingma/"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".lingma/skills"
def test_multi_install_safe(self):
# Lingma writes only to its isolated, static root .lingma/skills,
# disjoint from every other integration, so it must be co-install safe
# (mirrors trae/zcode and the kiro-cli #3471 precedent).
assert get_integration(self.KEY).multi_install_safe is True

View File

@@ -84,3 +84,31 @@ def test_write_integration_json_strips_integration_key(tmp_path):
assert state["integration"] == "claude"
assert state["default_integration"] == "claude"
assert state["installed_integrations"] == ["claude"]
def test_with_integration_setting_recomputes_separator_from_retained_options():
"""Updating only script_type must not drop an options-dependent separator.
Copilot resolves the command-ref separator to '-' when '--skills' options
are stored and '.' otherwise. A second call that changes only script_type
(parsed_options=None, raw_options=None) retains the stored parsed_options,
so invoke_separator must stay '-', not be recomputed from the None argument.
"""
from specify_cli.integrations import get_integration
from specify_cli.integration_runtime import with_integration_setting
copilot = get_integration("copilot")
settings = with_integration_setting(
{}, "copilot", copilot, parsed_options={"skills": True}
)
assert settings["copilot"]["invoke_separator"] == "-"
settings2 = with_integration_setting(
{"integration_settings": settings}, "copilot", copilot, script_type="ps"
)
# parsed_options are retained (only script_type changed) ...
assert settings2["copilot"]["parsed_options"] == {"skills": True}
assert settings2["copilot"]["script"] == "ps"
# ... so the separator must reflect them, not the (None) argument.
assert settings2["copilot"]["invoke_separator"] == "-"

View File

@@ -28,6 +28,7 @@ ALL_INTEGRATION_KEYS = [
"gemini", "tabnine",
# Stage 5 — skills, generic & option-driven integrations
"codex", "kimi", "agy", "zed", "generic",
"droid",
]

View File

@@ -18,7 +18,7 @@ from specify_cli._version import (
_verify_upgrade,
)
from tests.conftest import strip_ansi
from tests.http_helpers import mock_urlopen_response
from tests.http_helpers import mock_urlopen_response, route_opener_open_through_urlopen
__all__ = (
"SENTINEL_GH_TOKEN",
@@ -31,6 +31,7 @@ __all__ = (
"_verify_upgrade",
"mock_urlopen_response",
"requires_posix",
"route_opener_open_through_urlopen",
"runner",
"strip_ansi",
)

View File

@@ -20,6 +20,7 @@ ISSUE_TEMPLATE_AGENT_KEYS = [
"codex",
"cursor-agent",
"devin",
"droid",
"firebender",
"forge",
"gemini",

View File

@@ -14,6 +14,7 @@ Covers:
from __future__ import annotations
import base64
import io
import json
import os
@@ -515,6 +516,23 @@ class TestAzureDevOpsAuth:
with patch("specify_cli.authentication.azure_devops.subprocess.run", side_effect=boom):
assert AzureDevOpsAuth().resolve_token(entry) is None
@pytest.mark.parametrize("payload", [[], {"accessToken": None}, {"accessToken": 123}])
def test_resolve_token_azure_cli_unexpected_json_shape_returns_none(
self, payload
):
from unittest.mock import MagicMock, patch
entry = AuthConfigEntry(
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli",
)
result = MagicMock(returncode=0, stdout=json.dumps(payload))
with patch(
"specify_cli.authentication.azure_devops.subprocess.run",
return_value=result,
):
assert AzureDevOpsAuth().resolve_token(entry) is None
def test_resolve_token_azure_ad_success(self, monkeypatch):
"""azure-ad acquires token via OAuth2 client credentials."""
from unittest.mock import patch, MagicMock
@@ -524,10 +542,15 @@ class TestAzureDevOpsAuth:
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
)
mock_resp = MagicMock()
mock_resp.read.return_value = b'{"access_token": "ad-acquired-token"}'
mock_resp.read.side_effect = io.BytesIO(b'{"access_token": "ad-acquired-token"}').read
mock_resp.__enter__ = lambda s: s
mock_resp.__exit__ = MagicMock(return_value=False)
with patch("urllib.request.urlopen", return_value=mock_resp):
# The token request goes through a strict-redirect opener (so a 307/308
# cannot forward the client_secret body to a non-HTTPS host), not bare
# urlopen; patch the opener it builds.
mock_opener = MagicMock()
mock_opener.open.return_value = mock_resp
with patch("urllib.request.build_opener", return_value=mock_opener):
assert AzureDevOpsAuth().resolve_token(entry) == "ad-acquired-token"
def test_resolve_token_azure_ad_missing_secret_returns_none(self, monkeypatch):
@@ -542,14 +565,123 @@ class TestAzureDevOpsAuth:
def test_resolve_token_azure_ad_network_error_returns_none(self, monkeypatch):
"""azure-ad returns None on network errors."""
import urllib.error
from unittest.mock import patch
from unittest.mock import MagicMock, patch
monkeypatch.setenv("MY_SECRET", "secret-value")
entry = AuthConfigEntry(
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad",
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
)
with patch("urllib.request.urlopen",
side_effect=urllib.error.URLError("connection refused")):
mock_opener = MagicMock()
mock_opener.open.side_effect = urllib.error.URLError("connection refused")
with patch("urllib.request.build_opener", return_value=mock_opener):
assert AzureDevOpsAuth().resolve_token(entry) is None
@pytest.mark.parametrize(
("status", "reason"),
[(307, "Temporary Redirect"), (308, "Permanent Redirect")],
)
def test_resolve_token_azure_ad_rejects_https_redirect(
self, monkeypatch, status, reason
):
"""The client-secret POST must never be redirected to another host."""
import urllib.error
from unittest.mock import MagicMock, patch
from urllib.request import Request
monkeypatch.setenv("MY_SECRET", "secret-value")
entry = AuthConfigEntry(
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad",
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
)
mock_opener = MagicMock()
mock_opener.open.side_effect = urllib.error.URLError("stop after setup")
with patch("urllib.request.build_opener", return_value=mock_opener) as build_opener:
assert AzureDevOpsAuth().resolve_token(entry) is None
redirect_handler = build_opener.call_args.args[0]
request = Request(
"https://login.microsoftonline.com/tid/oauth2/v2.0/token",
data=b"grant_type=client_credentials&client_secret=secret-value",
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
assert request.get_method() == "POST"
assert b"client_secret=secret-value" in request.data
with pytest.raises(urllib.error.URLError, match="must not be redirected"):
redirect_handler.redirect_request(
request,
io.BytesIO(b""),
status,
reason,
{},
"https://evil.example/token",
)
def test_resolve_token_azure_ad_oversized_response_returns_none(
self, monkeypatch
):
"""Oversized token metadata is rejected before JSON parsing."""
from unittest.mock import MagicMock, patch
from specify_cli._download_security import MAX_JSON_METADATA_BYTES
monkeypatch.setenv("MY_SECRET", "secret-value")
entry = AuthConfigEntry(
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad",
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
)
mock_resp = MagicMock()
mock_resp.read.side_effect = io.BytesIO(
b"x" * (MAX_JSON_METADATA_BYTES + 1)
).read
mock_resp.__enter__ = lambda s: s
mock_resp.__exit__ = MagicMock(return_value=False)
mock_opener = MagicMock()
mock_opener.open.return_value = mock_resp
with patch("urllib.request.build_opener", return_value=mock_opener), patch(
"specify_cli.authentication.azure_devops._json.loads",
side_effect=AssertionError("oversized body must not be parsed"),
):
assert AzureDevOpsAuth().resolve_token(entry) is None
@pytest.mark.parametrize("payload", [[], {"access_token": None}, {"access_token": 123}])
def test_resolve_token_azure_ad_unexpected_json_shape_returns_none(
self, monkeypatch, payload
):
from unittest.mock import MagicMock, patch
monkeypatch.setenv("MY_SECRET", "secret-value")
entry = AuthConfigEntry(
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad",
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
)
mock_resp = MagicMock()
mock_resp.read.side_effect = io.BytesIO(json.dumps(payload).encode()).read
mock_resp.__enter__ = lambda s: s
mock_resp.__exit__ = MagicMock(return_value=False)
mock_opener = MagicMock()
mock_opener.open.return_value = mock_resp
with patch("urllib.request.build_opener", return_value=mock_opener):
assert AzureDevOpsAuth().resolve_token(entry) is None
def test_resolve_token_azure_ad_invalid_utf8_returns_none(self, monkeypatch):
"""azure-ad returns None when the token response is not valid UTF-8."""
from unittest.mock import MagicMock, patch
monkeypatch.setenv("MY_SECRET", "secret-value")
entry = AuthConfigEntry(
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad",
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
)
mock_resp = MagicMock()
mock_resp.read.side_effect = io.BytesIO(b"\xff").read
mock_resp.__enter__ = lambda s: s
mock_resp.__exit__ = MagicMock(return_value=False)
mock_opener = MagicMock()
mock_opener.open.return_value = mock_resp
with patch("urllib.request.build_opener", return_value=mock_opener):
assert AzureDevOpsAuth().resolve_token(entry) is None
@@ -615,13 +747,15 @@ class TestAuthenticatedHttp:
monkeypatch.setenv("GH_TOKEN", "my-token")
self._set_config(monkeypatch, [_github_entry()])
captured = {}
def fake_urlopen(req, timeout=None):
def fake_open(req, timeout=None):
captured["req"] = req
resp = MagicMock()
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_urlopen):
mock_opener = MagicMock()
mock_opener.open.side_effect = fake_open
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
open_url("https://example.com/file.json")
assert captured["req"].get_header("Authorization") is None
@@ -630,13 +764,15 @@ class TestAuthenticatedHttp:
from specify_cli.authentication.http import open_url
self._set_config(monkeypatch, [])
captured = {}
def fake_urlopen(req, timeout=None):
def fake_open(req, timeout=None):
captured["req"] = req
resp = MagicMock()
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_urlopen):
mock_opener = MagicMock()
mock_opener.open.side_effect = fake_open
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
open_url("https://github.com/org/repo")
assert captured["req"].get_header("Authorization") is None
@@ -658,8 +794,7 @@ class TestAuthenticatedHttp:
return resp
mock_opener = MagicMock()
mock_opener.open.side_effect = fake_side_effect
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener), \
patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_side_effect):
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
open_url("https://github.com/org/repo")
assert call_count == 2
@@ -700,21 +835,23 @@ class TestAuthenticatedHttpNegative:
def test_urlerror_propagates(self, monkeypatch):
import urllib.error
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from specify_cli.authentication.http import open_url
self._set_config(monkeypatch, [])
with patch("specify_cli.authentication.http.urllib.request.urlopen",
side_effect=urllib.error.URLError("refused")):
mock_opener = MagicMock()
mock_opener.open.side_effect = urllib.error.URLError("refused")
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
with pytest.raises(urllib.error.URLError):
open_url("https://example.com/file")
def test_timeout_propagates(self, monkeypatch):
import socket
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from specify_cli.authentication.http import open_url
self._set_config(monkeypatch, [])
with patch("specify_cli.authentication.http.urllib.request.urlopen",
side_effect=socket.timeout("timed out")):
mock_opener = MagicMock()
mock_opener.open.side_effect = socket.timeout("timed out")
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
with pytest.raises(socket.timeout):
open_url("https://example.com/file")
@@ -820,17 +957,18 @@ class TestRedirectStripping:
assert new_req.headers.get("Authorization") is None
assert new_req.unredirected_hdrs.get("Authorization") is None
def test_https_to_http_same_host_redirect_strips_auth(self):
def test_https_to_http_same_host_redirect_rejected(self):
from specify_cli.authentication.http import _StripAuthOnRedirect
from urllib.request import Request
import io
import urllib.error
handler = _StripAuthOnRedirect(("github.com",))
req = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
new_req = handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
"http://github.com/org/repo")
assert new_req is not None
assert new_req.headers.get("Authorization") is None
assert new_req.unredirected_hdrs.get("Authorization") is None
with pytest.raises(urllib.error.URLError, match="unsafe redirect"):
handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
"http://github.com/org/repo")
def test_redirect_validator_can_reject_before_following_redirect(self):
import urllib.error
@@ -888,6 +1026,177 @@ class TestRedirectStripping:
handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
"https://[::1/asset")
def test_redirect_rejects_https_downgrade(self):
"""HTTPS downloads must not follow redirects to non-local HTTP URLs."""
from specify_cli.authentication.http import _StripAuthOnRedirect
from urllib.request import Request
import io
import urllib.error
handler = _StripAuthOnRedirect(("example.com",))
req = Request("https://example.com/archive.zip")
with pytest.raises(urllib.error.URLError, match="unsafe redirect"):
handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
"http://evil.example.com/archive.zip")
@pytest.mark.parametrize(
"target",
[
"http://127.0.0.1/internal",
"https://localhost/internal",
"https://localhost./internal",
"https://service.localhost/internal",
"https://service.localhost./internal",
"https://127.0.0.2/internal",
"https://127.1/internal",
"https://2130706433/internal",
"https://0x7f000001/internal",
"https://017700000001/internal",
"https://0177.0.0.1/internal",
"https://[::1]/internal",
"https://[::1%25lo0]/internal",
"https://[::ffff:127.0.0.1]/internal",
"https://127%2e0%2e0%2e1/internal",
"https://%31%32%37.0.0.1/internal",
"https://127%2E1/internal",
"https://local%68ost/internal",
"https://[::ffff:127%2e0.0.1]/internal",
"https://[::ffff:7f00%3a1]/internal",
"https://[::ffff%3a127.0.0.1]/internal",
"https://ocalhost/internal",
"https:///internal",
"https://127。0。0。1/internal",
"https://0.0.0.0/internal",
"https://0/internal",
"https://00.00.00.00/internal",
"https://[::]/internal",
"https://[::ffff:0.0.0.0]/internal",
],
)
def test_redirect_rejects_remote_to_loopback(self, target):
"""A remote response must not redirect a download into loopback."""
import io
import urllib.error
from urllib.request import Request
from specify_cli.authentication.http import _StripAuthOnRedirect
handler = _StripAuthOnRedirect(())
request = Request("https://example.com/archive.zip")
with pytest.raises(urllib.error.URLError, match="unsafe redirect"):
handler.redirect_request(
request,
io.BytesIO(b""),
302,
"Found",
{},
target,
)
@pytest.mark.parametrize(
("source", "target"),
[
(
"http://localhost:8000/archive.zip",
"http://127.0.0.1:8001/archive.zip",
),
(
"http://127.0.0.2:8000/archive.zip",
"http://127.255.255.254:8001/archive.zip",
),
(
"https://[0:0:0:0:0:0:0:1]/archive.zip",
"http://[::1]:8001/archive.zip",
),
],
)
def test_redirect_allows_loopback_to_http_loopback(self, source, target):
"""Local development may continue redirecting between loopback URLs."""
import io
from urllib.request import Request
from specify_cli.authentication.http import _StripAuthOnRedirect
handler = _StripAuthOnRedirect(())
request = Request(source)
redirected = handler.redirect_request(
request,
io.BytesIO(b""),
302,
"Found",
{},
target,
)
assert redirected is not None
def test_multi_hop_remote_to_loopback_chain_is_rejected_at_first_hop(self):
import io
import urllib.error
from urllib.request import Request
from specify_cli.authentication.http import _StripAuthOnRedirect
handler = _StripAuthOnRedirect(())
request = Request("https://example.com/archive.zip")
with pytest.raises(urllib.error.URLError, match="unsafe redirect"):
handler.redirect_request(
request,
io.BytesIO(b""),
302,
"Found",
{},
"https://localhost:4443/hop",
)
@pytest.mark.parametrize(
"target",
[
"https://example.com:notaport/archive.zip",
"https://example.com:+443/archive.zip",
"https://example.com:65536/archive.zip",
],
)
def test_malformed_redirect_port_raises_urlerror(self, target):
import io
import urllib.error
from urllib.request import Request
from specify_cli.authentication.http import _StripAuthOnRedirect
handler = _StripAuthOnRedirect(())
request = Request("https://example.com/archive.zip")
with pytest.raises(urllib.error.URLError, match="malformed redirect URL"):
handler.redirect_request(
request,
io.BytesIO(b""),
302,
"Found",
{},
target,
)
def test_strict_redirect_error_describes_target_and_allowed_localhost(self):
from specify_cli.authentication.http import _StripAuthOnRedirect
from urllib.request import Request
import io
import urllib.error
handler = _StripAuthOnRedirect(("example.com",))
req = Request("https://example.com/archive.zip")
with pytest.raises(urllib.error.URLError) as exc_info:
handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
"http://evil.example.com/archive.zip")
error_message = str(exc_info.value)
assert "http://evil.example.com/archive.zip" in error_message
assert "localhost" in error_message
assert "127.0.0.1" in error_message
assert "::1" in error_message
# ---------------------------------------------------------------------------
# _fetch_latest_release_tag delegation
@@ -907,7 +1216,7 @@ class TestFetchLatestReleaseTagDelegation:
captured["request"] = req
body = _json.dumps({"tag_name": "v9.9.9"}).encode()
resp = MagicMock()
resp.read.return_value = body
resp.read.side_effect = io.BytesIO(body).read
cm = MagicMock()
cm.__enter__.return_value = resp
cm.__exit__.return_value = False
@@ -927,20 +1236,25 @@ class TestFetchLatestReleaseTagDelegation:
assert captured["request"].get_header("Authorization") == "Bearer forwarded-sentinel"
def test_no_config_means_no_auth(self, monkeypatch):
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from specify_cli._version import _fetch_latest_release_tag
self._set_config(monkeypatch, [])
captured, side_effect = self._capture_request()
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
# The unauthenticated path uses the strict redirect opener too.
mock_opener = MagicMock()
mock_opener.open.side_effect = side_effect
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
_fetch_latest_release_tag()
assert captured["request"].get_header("Authorization") is None
def test_accept_header_present(self, monkeypatch):
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from specify_cli._version import _fetch_latest_release_tag
self._set_config(monkeypatch, [])
captured, side_effect = self._capture_request()
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
mock_opener = MagicMock()
mock_opener.open.side_effect = side_effect
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
_fetch_latest_release_tag()
assert captured["request"].get_header("Accept") == "application/vnd.github+json"

View File

@@ -0,0 +1,227 @@
"""Tests for bounded HTTP download helpers."""
from __future__ import annotations
import weakref
import pytest
from specify_cli._download_security import (
is_https_or_localhost_http,
is_loopback_url,
read_response_limited,
)
@pytest.mark.parametrize(
"url, allowed",
[
("https://example.com/preset.zip", True),
("http://localhost:8000/preset.zip", True),
("http://127.0.0.1/preset.zip", True),
("http://127.0.0.2/preset.zip", True),
("http://127.255.255.254/preset.zip", True),
("http://[::1]/preset.zip", True),
("http://[0:0:0:0:0:0:0:1]/preset.zip", True),
("http://[::ffff:127.0.0.2]/preset.zip", True),
("http://[::1%25lo0]/preset.zip", True),
# Non-loopback HTTP is rejected.
("http://example.com/preset.zip", False),
("http://192.0.2.1/preset.zip", False),
("http://[fe80::1]/preset.zip", False),
("http://[fe80::1%25lo0]/preset.zip", False),
("http://0.0.0.0/preset.zip", False),
("http://0/preset.zip", False),
("http://[::]/preset.zip", False),
("http://[::ffff:0.0.0.0]/preset.zip", False),
# Ambiguous/platform-dependent spellings may never authorize HTTP.
("http://127.1/preset.zip", False),
("http://2130706433/preset.zip", False),
("http://0x7f000001/preset.zip", False),
("http://017700000001/preset.zip", False),
("http://0177.0.0.1/preset.zip", False),
("http://00177.0.0.1/preset.zip", False),
("http://localhost./preset.zip", False),
("http://ocalhost/preset.zip", False),
("http://127。0。0。1/preset.zip", False),
# A hostname is always required, even for HTTPS.
("https:///preset.zip", False),
("https://", False),
# Invalid ports must be rejected before urllib opens the URL.
("https://example.com:notaport/preset.zip", False),
("https://example.com:+443/preset.zip", False),
("https://example.com:65536/preset.zip", False),
# urllib decodes escapes in the authority before connecting; reject
# encoded reg-names so validation and connection cannot disagree.
("https://127%2e0%2e0%2e1/preset.zip", False),
("https://%31%32%37.0.0.1/preset.zip", False),
("https://local%68ost/preset.zip", False),
("https://example.com%3a443/preset.zip", False),
("https://[::1%lo0]/preset.zip", False),
("https://[::ffff:127%2e0.0.1]/preset.zip", False),
("https://[::ffff:7f00%3a1]/preset.zip", False),
("https://[::ffff%3a127.0.0.1]/preset.zip", False),
],
)
def test_is_https_or_localhost_http(url, allowed):
assert is_https_or_localhost_http(url) is allowed
@pytest.mark.parametrize(
"url",
[
"https://localhost/internal",
"https://127.0.0.2/internal",
"https://[::1]/internal",
"https://[::1%25lo0]/internal",
"https://[::ffff:127.0.0.2]/internal",
],
)
def test_is_loopback_url_recognizes_effective_loopback_literals(url):
assert is_loopback_url(url) is True
@pytest.mark.parametrize(
"url",
[
"https://localhost./internal",
"https://service.localhost/internal",
"https://service.localhost./internal",
"https://127.1/internal",
"https://2130706433/internal",
"https://0x7f000001/internal",
"https://017700000001/internal",
"https://0177.0.0.1/internal",
"https://ocalhost/internal",
"https://127。0。0。1/internal",
"https://127%2e0%2e0%2e1/internal",
"https://0.0.0.0/internal",
"https://0/internal",
"https://00.00.00.00/internal",
"https://[::]/internal",
"https://[::ffff:0.0.0.0]/internal",
],
)
def test_is_loopback_url_does_not_authorize_ambiguous_spellings(url):
assert is_loopback_url(url) is False
class _Response:
"""Faithful stream stand-in: read() advances a cursor and returns b"" at EOF."""
def __init__(self, data: bytes, *, chunk: int | None = None):
self.data = data
self.pos = 0
# When set, never return more than *chunk* bytes per call even if more is
# requested - simulates short reads (e.g. chunked transfer encoding).
self.chunk = chunk
def read(self, size: int = -1) -> bytes:
if size < 0:
size = len(self.data) - self.pos
if self.chunk is not None:
size = min(size, self.chunk)
out = self.data[self.pos : self.pos + size]
self.pos += len(out)
return out
class _RecordingResponse(_Response):
def __init__(self, data: bytes, *, chunk: int | None = None):
super().__init__(data, chunk=chunk)
self.requested_sizes: list[int] = []
def read(self, size: int = -1) -> bytes:
self.requested_sizes.append(size)
return super().read(size)
class _TrackedChunk(bytearray):
pass
class _OneByteResponse:
"""Return distinct weak-referenceable chunks to detect retained fragments."""
def __init__(self, count: int):
self.remaining = count
self.refs: list[weakref.ReferenceType[_TrackedChunk]] = []
self.peak_live = 0
def read(self, _size: int = -1) -> bytes | _TrackedChunk:
if self.remaining == 0:
return b""
self.remaining -= 1
chunk = _TrackedChunk(b"x")
self.refs.append(weakref.ref(chunk))
self.peak_live = max(
self.peak_live,
sum(ref() is not None for ref in self.refs),
)
return chunk
def test_read_response_limited_rejects_oversized_download():
with pytest.raises(ValueError, match="exceeds maximum size"):
read_response_limited(_Response(b"abcde"), max_bytes=4)
def test_read_response_limited_returns_full_body_within_limit():
assert read_response_limited(_Response(b"abcde"), max_bytes=10) == b"abcde"
def test_read_response_limited_enforces_bound_under_short_reads():
# A server that streams more than max_bytes total while every read() returns
# fewer bytes than requested (chunked encoding) must still be rejected - a
# single read(max_bytes + 1) could be fooled, the accumulating loop cannot.
response = _Response(b"x" * 100, chunk=8)
with pytest.raises(ValueError, match="exceeds maximum size"):
read_response_limited(response, max_bytes=16)
def test_read_response_limited_does_not_retain_short_read_fragments():
response = _OneByteResponse(64)
assert read_response_limited(response, max_bytes=64) == b"x" * 64
assert response.peak_live <= 2
def test_read_response_limited_caps_underlying_reads_at_64_kib():
response = _RecordingResponse(b"x" * (64 * 1024 + 1))
with pytest.raises(ValueError, match="exceeds maximum size"):
read_response_limited(response, max_bytes=64 * 1024)
assert max(response.requested_sizes) <= 64 * 1024
@pytest.mark.parametrize("value", [None, "1", 1.5, True])
def test_read_response_limited_rejects_non_integer_limits(value):
with pytest.raises(TypeError, match="integer"):
read_response_limited(_Response(b""), max_bytes=value)
def test_read_response_limited_rejects_negative_limit_without_reading():
response = _RecordingResponse(b"")
with pytest.raises(ValueError, match="non-negative"):
read_response_limited(response, max_bytes=-1)
assert response.requested_sizes == []
def test_read_response_limited_allows_empty_response_at_zero_limit():
assert read_response_limited(_Response(b""), max_bytes=0) == b""
class _CustomLimitError(Exception):
pass
def test_read_response_limited_rejects_first_byte_at_zero_limit():
with pytest.raises(_CustomLimitError, match="exceeds maximum size"):
read_response_limited(
_Response(b"x"),
max_bytes=0,
error_type=_CustomLimitError,
)

View File

@@ -9,6 +9,7 @@ Tests cover:
- Catalog stack (multi-catalog support)
"""
import io
import pytest
import json
import os
@@ -22,6 +23,7 @@ from datetime import datetime, timezone
from unittest.mock import MagicMock
from tests.conftest import strip_ansi
from tests.http_helpers import route_opener_open_through_urlopen # noqa: F401
from specify_cli import extensions as _ext_module
from specify_cli.extensions import (
CatalogEntry,
@@ -4978,7 +4980,7 @@ class TestExtensionCatalog:
zip_bytes = zip_buf.getvalue()
release_response = MagicMock()
release_response.read.return_value = json.dumps(
release_response.read.side_effect = io.BytesIO(json.dumps(
{
"assets": [
{
@@ -4987,12 +4989,12 @@ class TestExtensionCatalog:
}
]
}
).encode()
).encode()).read
release_response.__enter__ = lambda s: s
release_response.__exit__ = MagicMock(return_value=False)
asset_response = MagicMock()
asset_response.read.return_value = zip_bytes
asset_response.read.side_effect = io.BytesIO(zip_bytes).read
asset_response.__enter__ = lambda s: s
asset_response.__exit__ = MagicMock(return_value=False)
@@ -8238,6 +8240,42 @@ class TestHookInvocationRendering:
assert execution["command"] == "my-extension.do-something"
assert execution["invocation"] == "/speckit-my-extension-do-something"
def test_forge_hooks_render_hyphenated_invocation(self, project_dir):
"""Forge projects should render /speckit-* invocations (like Cline)."""
init_options = project_dir / ".specify" / "init-options.json"
init_options.parent.mkdir(parents=True, exist_ok=True)
init_options.write_text(json.dumps({"ai": "forge"}))
hook_executor = HookExecutor(project_dir)
execution = hook_executor.execute_hook(
{
"extension": "test-ext",
"command": "speckit.tasks",
"optional": False,
}
)
assert execution["command"] == "speckit.tasks"
assert execution["invocation"] == "/speckit-tasks"
def test_forge_hooks_render_extension_command(self, project_dir):
"""Forge projects should render /speckit-my-ext-cmd for extension hooks."""
init_options = project_dir / ".specify" / "init-options.json"
init_options.parent.mkdir(parents=True, exist_ok=True)
init_options.write_text(json.dumps({"ai": "forge"}))
hook_executor = HookExecutor(project_dir)
execution = hook_executor.execute_hook(
{
"extension": "test-ext",
"command": "my-extension.do-something",
"optional": False,
}
)
assert execution["command"] == "my-extension.do-something"
assert execution["invocation"] == "/speckit-my-extension-do-something"
def test_non_skill_command_keeps_slash_invocation(self, project_dir):
"""Custom hook commands should keep slash invocation style."""
init_options = project_dir / ".specify" / "init-options.json"
@@ -8725,10 +8763,10 @@ def test_extension_wrapper_resolves_ghes_asset_when_host_configured(tmp_path, mo
def fake_open(url, timeout=None, extra_headers=None):
captured.append(url)
resp = MagicMock()
resp.read.return_value = json.dumps({
resp.read.side_effect = io.BytesIO(json.dumps({
"assets": [{"name": "ext.zip",
"url": "https://ghes.example/api/v3/repos/o/r/releases/assets/7"}]
}).encode()
}).encode()).read
yield resp
monkeypatch.setattr(catalog, "_open_url", fake_open)

View File

@@ -1,16 +1,20 @@
"""Tests for GitHub-authenticated HTTP request helpers."""
import io
import json
import os
from contextlib import contextmanager
from unittest.mock import MagicMock, patch
from urllib.request import Request
import pytest
from specify_cli._github_http import (
GITHUB_HOSTS,
build_github_request,
resolve_github_release_asset_api_url,
)
from specify_cli.authentication.http import _StripAuthOnRedirect
class TestBuildGitHubRequest:
@@ -90,7 +94,7 @@ class TestResolveGitHubReleaseAssetApiUrl:
@contextmanager
def fake_open(url, timeout=None, extra_headers=None):
resp = MagicMock()
resp.read.return_value = json.dumps(release_json).encode()
resp.read.side_effect = io.BytesIO(json.dumps(release_json).encode()).read
yield resp
return fake_open
@@ -198,7 +202,7 @@ class TestResolveGitHubReleaseAssetApiUrl:
def capturing_open(url, timeout=None, extra_headers=None):
captured_urls.append(url)
resp = MagicMock()
resp.read.return_value = json.dumps({"assets": []}).encode()
resp.read.side_effect = io.BytesIO(json.dumps({"assets": []}).encode()).read
yield resp
resolve_github_release_asset_api_url(
@@ -217,7 +221,7 @@ class TestResolveGitHubReleaseAssetApiUrl:
def capturing_open(url, timeout=None, extra_headers=None):
captured_urls.append(url)
resp = MagicMock()
resp.read.return_value = json.dumps({"assets": []}).encode()
resp.read.side_effect = io.BytesIO(json.dumps({"assets": []}).encode()).read
yield resp
resolve_github_release_asset_api_url(
@@ -260,7 +264,7 @@ class TestResolveGitHubReleaseAssetApiUrl:
def recording_open(url, timeout=None, extra_headers=None):
called.append(url)
resp = MagicMock()
resp.read.return_value = b"{}"
resp.read.side_effect = io.BytesIO(b"{}").read
yield resp
result = resolve_github_release_asset_api_url(
@@ -299,7 +303,7 @@ class TestResolveGitHubReleaseAssetApiUrl:
def recording_open(url, timeout=None, extra_headers=None):
called.append(url)
resp = MagicMock()
resp.read.return_value = b"{}"
resp.read.side_effect = io.BytesIO(b"{}").read
yield resp
url = "https://ghes.example/api/v3/repos/o/r/releases/assets/7"
@@ -317,7 +321,7 @@ class TestResolveGitHubReleaseAssetApiUrl:
def capturing_open(url, timeout=None, extra_headers=None):
captured.append(url)
resp = MagicMock()
resp.read.return_value = json.dumps({"assets": []}).encode()
resp.read.side_effect = io.BytesIO(json.dumps({"assets": []}).encode()).read
yield resp
resolve_github_release_asset_api_url(
@@ -344,10 +348,10 @@ class TestResolveGitHubReleaseAssetApiUrl:
def capturing_open(url, timeout=None, extra_headers=None):
captured.append(url)
resp = MagicMock()
resp.read.return_value = json.dumps({
resp.read.side_effect = io.BytesIO(json.dumps({
"assets": [{"name": "pack.zip",
"url": "https://api.github.com/repos/org/repo/releases/assets/99"}]
}).encode()
}).encode()).read
yield resp
result = resolve_github_release_asset_api_url(
@@ -357,3 +361,43 @@ class TestResolveGitHubReleaseAssetApiUrl:
)
assert result == "https://api.github.com/repos/org/repo/releases/assets/99"
assert captured == ["https://api.github.com/repos/org/repo/releases/tags/v1.0"]
class TestGitHubRedirectAuth:
"""Tests for GitHub-owned redirect auth handling."""
def test_multi_hop_github_redirect_preserves_unredirected_auth(self):
"""Auth survives a multi-hop redirect chain within GitHub hosts."""
handler = _StripAuthOnRedirect(tuple(GITHUB_HOSTS))
req1 = Request(
"https://github.com/org/repo",
headers={"Authorization": "Bearer tok"},
)
req2 = handler.redirect_request(
req1,
io.BytesIO(b""),
302,
"Found",
{},
"https://codeload.github.com/org/repo/zip",
)
assert req2 is not None
auth2 = req2.get_header("Authorization") or req2.unredirected_hdrs.get(
"Authorization"
)
assert auth2 == "Bearer tok"
req3 = handler.redirect_request(
req2,
io.BytesIO(b""),
302,
"Found",
{},
"https://raw.githubusercontent.com/org/repo/main/file",
)
assert req3 is not None
auth3 = req3.get_header("Authorization") or req3.unredirected_hdrs.get(
"Authorization"
)
assert auth3 == "Bearer tok"

View File

@@ -243,3 +243,34 @@ class TestRegressionPlainTemplate:
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"
class TestClineRealPostProcess:
"""Cline's real command-content transforms (hook-command note + handoff
dot->hyphen rewrite) must run for extension/preset commands registered via
CommandRegistrar. This exercises the REAL method (not a monkeypatched
marker), so it fails if Cline's override does not match the base hook name
the registrar dispatches to (post_process_command_content)."""
def test_cline_transforms_applied_via_registrar(
self, tmp_path, registrar, ext_dir
):
ext, cmd_dir = ext_dir
body = (
"- For each executable hook, output the following:\n"
"agent: speckit.foo\n"
)
_write_cmd(cmd_dir, body=body)
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
registrar.register_commands("cline", commands, "test-ext", ext, tmp_path)
outputs = list((tmp_path / ".clinerules" / "workflows").rglob("*.md"))
assert outputs, "no cline command file was written"
content = outputs[0].read_text(encoding="utf-8")
# _inject_hook_command_note fired (its note text contains "replace dots")
assert "replace dots" in content
# _rewrite_handoff_references rewrote the dotted agent handoff
assert "agent: speckit-foo" in content
assert "agent: speckit.foo" not in content

View File

@@ -2303,7 +2303,7 @@ class TestPresetCatalog:
zip_bytes = zip_buf.getvalue()
release_response = MagicMock()
release_response.read.return_value = json.dumps(
release_response.read.side_effect = io.BytesIO(json.dumps(
{
"assets": [
{
@@ -2312,12 +2312,12 @@ class TestPresetCatalog:
}
]
}
).encode()
).encode()).read
release_response.__enter__ = lambda s: s
release_response.__exit__ = MagicMock(return_value=False)
asset_response = MagicMock()
asset_response.read.return_value = zip_bytes
asset_response.read.side_effect = io.BytesIO(zip_bytes).read
asset_response.__enter__ = lambda s: s
asset_response.__exit__ = MagicMock(return_value=False)
@@ -5381,6 +5381,9 @@ class TestPresetEnableDisable:
LEAN_PRESET_DIR = Path(__file__).parent.parent / "presets" / "lean"
CORE_CONSTITUTION_COMMAND = (
Path(__file__).parent.parent / "templates" / "commands" / "constitution.md"
)
LEAN_COMMAND_NAMES = [
"speckit.specify",
@@ -5391,6 +5394,31 @@ LEAN_COMMAND_NAMES = [
]
@pytest.mark.parametrize(
"command_path",
[
CORE_CONSTITUTION_COMMAND,
LEAN_PRESET_DIR / "commands" / "speckit.constitution.md",
],
ids=["core", "lean"],
)
def test_constitution_commands_guard_against_non_governance_work(command_path):
"""Constitution commands defer non-governance work instead of executing it."""
content = command_path.read_text()
lower_content = content.lower()
normalized_content = " ".join(lower_content.split())
assert "## Scope Guard" in content
assert "**MUST NOT**" in content
assert "Classify every part" in content
assert "application source files" in content
assert "non-governance intent" in content
assert "`Next Actions`" in content
assert "__SPECKIT_COMMAND_SPECIFY__" in content
assert "omit" in lower_content
assert "do not invoke it" in normalized_content or "without invoking it" in normalized_content
class TestLeanPreset:
"""Tests for the lean preset that ships with the repo."""
@@ -7458,10 +7486,10 @@ def test_preset_wrapper_resolves_ghes_asset_when_host_configured(tmp_path, monke
def fake_open(url, timeout=None, extra_headers=None):
captured.append(url)
resp = MagicMock()
resp.read.return_value = json.dumps({
resp.read.side_effect = io.BytesIO(json.dumps({
"assets": [{"name": "pack.zip",
"url": "https://ghes.example/api/v3/repos/o/r/releases/assets/9"}]
}).encode()
}).encode()).read
yield resp
monkeypatch.setattr(catalog, "_open_url", fake_open)

View File

@@ -13,6 +13,7 @@ import specify_cli
from specify_cli import app
from tests.self_upgrade_helpers import (
route_opener_open_through_urlopen, # noqa: F401 (autouse fixture)
_InstallMethod,
_assemble_installer_argv,
_completed_process,

View File

@@ -7,6 +7,7 @@ from unittest.mock import patch
from specify_cli import app
from tests.self_upgrade_helpers import (
route_opener_open_through_urlopen, # noqa: F401 (autouse fixture)
_completed_process,
mock_urlopen_response,
requires_posix,

View File

@@ -6,6 +6,7 @@ from specify_cli import app
from tests.self_upgrade_helpers import (
mock_urlopen_response,
route_opener_open_through_urlopen, # noqa: F401 (autouse fixture)
runner,
strip_ansi,
)

View File

@@ -8,6 +8,7 @@ import specify_cli
from specify_cli import app
from tests.self_upgrade_helpers import (
route_opener_open_through_urlopen, # noqa: F401 (autouse fixture)
SENTINEL_GH_TOKEN,
SENTINEL_GITHUB_TOKEN,
_InstallMethod,

View File

@@ -0,0 +1,39 @@
"""Regression tests for top-level step numbering in specify.md."""
import re
from pathlib import Path
REPO_ROOT = Path(__file__).parent.parent
SPECIFY_TEMPLATE = REPO_ROOT / "templates" / "commands" / "specify.md"
MAIN_LIST_START = "Given that feature description, do this:"
MAIN_LIST_END = "## Mandatory Post-Execution Hooks"
def _main_execution_ordinals(text: str) -> list[int]:
"""Extract top-level ordinals from the main execution flow."""
_, start, execution_flow = text.partition(MAIN_LIST_START)
execution_flow, end, _ = execution_flow.partition(MAIN_LIST_END)
if not start or not end:
return []
return [
int(match.group(1))
for line in execution_flow.splitlines()
if (match := re.match(r"^(\d+)\. ", line))
]
def test_main_execution_list_has_no_duplicate_ordinals():
"""The main execution list must not reuse a step number."""
ordinals = _main_execution_ordinals(SPECIFY_TEMPLATE.read_text(encoding="utf-8"))
duplicates = {ordinal for ordinal in ordinals if ordinals.count(ordinal) > 1}
assert not duplicates, f"Duplicate top-level ordinals found: {sorted(duplicates)}"
def test_main_execution_list_is_sequential():
"""The main execution list must run from 1 through N without gaps."""
ordinals = _main_execution_ordinals(SPECIFY_TEMPLATE.read_text(encoding="utf-8"))
assert ordinals, "Could not find the main execution list in specify.md"
assert ordinals == list(range(1, 9))

View File

@@ -2,11 +2,12 @@
Network isolation contract (SC-004 / FR-014): every test that exercises
`specify self check` or `_fetch_latest_release_tag()` MUST mock the outbound
urllib path it expects (`urlopen` for unauthenticated requests, `build_opener`
for authenticated requests) so no real outbound call ever reaches api.github.com.
Tests for non-network `self upgrade` behavior should keep that contract explicit
with local mocks. Run this module under `pytest-socket` (if installed) with
`--disable-socket` as an extra safety net.
urllib path so no real call reaches api.github.com. Production always uses an
isolated `build_opener`; this module's autouse fixture routes its `open()` back
through the locally mocked `urlopen`. Tests for non-network `self upgrade`
behavior should keep that contract explicit with local mocks. Run this module
under `pytest-socket` (if installed) with `--disable-socket` as an extra safety
net.
"""
import urllib.error
@@ -17,6 +18,7 @@ import pytest
from typer.testing import CliRunner
from specify_cli import app
from specify_cli._download_security import read_response_limited as _real_read_response_limited
from specify_cli._version import (
_fetch_latest_release_tag,
_get_installed_version,
@@ -24,7 +26,10 @@ from specify_cli._version import (
_normalize_tag,
)
from tests.conftest import strip_ansi
from tests.http_helpers import mock_urlopen_response
from tests.http_helpers import (
mock_urlopen_response,
route_opener_open_through_urlopen, # noqa: F401 (autouse fixture)
)
runner = CliRunner()
@@ -235,6 +240,46 @@ class TestFailureCategorization:
_fetch_latest_release_tag()
class TestBoundedRead:
"""Regression test for the read_response_limited hardening.
A future refactor could silently revert `_fetch_latest_release_tag` to
`resp.read()` (the unbounded form) — this test pins the contract that
the response body is read through ``read_response_limited`` with a
bounded ``max_bytes``.
"""
def test_response_body_is_bounded(self):
recorded: dict[str, int | str] = {}
def _spy(response, *, max_bytes: int, label: str, **kwargs):
# max_bytes and label are keyword-only with no defaults: if the
# caller forgets to pass either, the call raises TypeError here
# (instead of recording a misleading None).
recorded["max_bytes"] = max_bytes
recorded["label"] = label
# Forward to the real implementation so the function under test
# still gets a parseable body.
return _real_read_response_limited(
response, max_bytes=max_bytes, label=label, **kwargs
)
with patch(
"specify_cli.authentication.http.urllib.request.urlopen",
return_value=mock_urlopen_response({"tag_name": "v9.9.9"}),
), patch("specify_cli._version.read_response_limited", side_effect=_spy):
tag, reason = _fetch_latest_release_tag()
assert tag == "v9.9.9"
assert reason is None
# The cap (1 MiB) is a deliberate ceiling for the GitHub release
# JSON — keep it explicit so a future refactor that drops the
# `max_bytes=` argument fails this test instead of regressing
# silently to the default.
assert recorded["max_bytes"] == 1024 * 1024
assert recorded["label"] == "GitHub latest release"
_FAILURE_CASES = [
("offline or timeout", urllib.error.URLError("down")),
(_RATE_LIMITED_REASON, _http_error(403)),

View File

@@ -404,6 +404,17 @@ class TestExpressions:
assert evaluate_expression('{{ [["a", "b"], "c"] }}', ctx) == [["a", "b"], "c"]
assert evaluate_expression("{{ [[1, 2], [3, 4]] }}", ctx) == [[1, 2], [3, 4]]
def test_list_literal_ignores_trailing_and_empty_commas(self):
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
ctx = StepContext()
# A trailing comma must not append a spurious None element.
assert evaluate_expression("{{ [1, 2,] }}", ctx) == [1, 2]
assert evaluate_expression("{{ [1,, 2] }}", ctx) == [1, 2]
# …but an intentional empty-string element is still preserved.
assert evaluate_expression("{{ ['', 'a'] }}", ctx) == ["", "a"]
def test_operator_splitting_is_quote_aware(self):
from specify_cli.workflows.expressions import (
evaluate_condition,
@@ -1162,6 +1173,21 @@ class TestCommandStep:
result = step.execute(config, ctx)
assert result.output["integration"] == "gemini"
def test_execute_non_string_integration_fails_cleanly(self):
"""A non-string integration (e.g. a list from an expression that resolved
to one) must FAIL the step cleanly, not crash the run with
'TypeError: unhashable type: list' from get_integration's dict lookup."""
from specify_cli.workflows.steps.command import CommandStep
from specify_cli.workflows.base import StepContext, StepStatus
step = CommandStep()
config = {
"id": "s", "command": "speckit.plan",
"integration": ["claude"], "input": {},
}
result = step.execute(config, StepContext())
assert result.status == StepStatus.FAILED
def test_step_override_model(self):
from unittest.mock import patch
from specify_cli.workflows.steps.command import CommandStep
@@ -1359,6 +1385,20 @@ class TestPromptStep:
assert result.output["integration"] == "claude"
assert result.output["dispatched"] is False
def test_execute_non_string_integration_fails_cleanly(self):
"""A non-string integration must FAIL the step cleanly, not crash with
'TypeError: unhashable type: list' from get_integration's dict lookup."""
from specify_cli.workflows.steps.prompt import PromptStep
from specify_cli.workflows.base import StepContext, StepStatus
step = PromptStep()
config = {
"id": "p", "type": "prompt", "prompt": "do it",
"integration": ["claude"],
}
result = step.execute(config, StepContext())
assert result.status == StepStatus.FAILED
def test_execute_with_step_integration(self):
from unittest.mock import patch
from specify_cli.workflows.steps.prompt import PromptStep
@@ -1895,6 +1935,14 @@ def _force_gate_stdin(monkeypatch, *, tty: bool):
class TestInitStep:
"""Test the init step type."""
def test_docstring_lists_every_valid_script_type(self):
# The `script` field docstring must not contradict the step's own
# VALID_SCRIPT_TYPES (which includes 'py'); validate() accepts all three.
from specify_cli.workflows.steps.init import InitStep, VALID_SCRIPT_TYPES
for script_type in VALID_SCRIPT_TYPES:
assert f"``{script_type}``" in InitStep.__doc__
def test_builds_here_argv_and_bootstraps(self, tmp_path):
from specify_cli.workflows.steps.init import InitStep
from specify_cli.workflows.base import StepContext, StepStatus
@@ -2060,6 +2108,15 @@ class TestInitStep:
class TestGateStep:
"""Test the gate step type."""
def test_docstring_lists_every_on_reject_behaviour(self):
# The docstring must not contradict validate()/execute(): on_reject
# accepts 'abort', 'skip', AND 'retry' (execute() has a dedicated
# retry -> PAUSED branch), but the summary omitted 'retry'.
from specify_cli.workflows.steps.gate import GateStep
for behaviour in ("abort", "skip", "retry"):
assert behaviour in GateStep.__doc__
@pytest.fixture(autouse=True)
def _non_tty_stdin_by_default(self, monkeypatch):
# Default every gate test to a non-TTY stdin so none can drop into
@@ -2145,6 +2202,19 @@ class TestGateStep:
assert result.status == StepStatus.COMPLETED
assert result.output["choice"] == "approve"
def test_interactive_prompt_rejects_non_decimal_digit(self, monkeypatch, capsys):
"""A Unicode digit int() can't parse — e.g. the superscript '²', which
str.isdigit() accepts but int() rejects — must be treated as an invalid
choice, not crash the prompt loop with an uncaught ValueError."""
from specify_cli.workflows.steps.gate import GateStep
_force_gate_stdin(monkeypatch, tty=True)
inputs = iter(["²", "1"]) # superscript-two, then a real "1"
monkeypatch.setattr("builtins.input", lambda _prompt="": next(inputs))
choice = GateStep._prompt("Review the spec.", ["approve", "reject"])
assert choice == "approve"
def test_interactive_prompt_missing_show_file_does_not_crash(
self, tmp_path, monkeypatch, capsys
):
@@ -7733,6 +7803,63 @@ class TestWorkflowRemoveGuard:
assert "[stage]permissiondenied" in output_compact
assert "[reg]diskfull" in output_compact
class TestWorkflowAddCaseInsensitiveSuffix:
"""`workflow add` must detect a local YAML file case-insensitively, matching
`workflow run` (_commands.py:workflow_run) and the engine loader
(engine.py:WorkflowEngine.load_workflow), which both use `.suffix.lower()`.
Without it, `workflow run Sample.YAML` works but `workflow add Sample.YAML`
fails — an add/run inconsistency for an uppercase extension."""
def test_plain_path_accepts_uppercase_extension(self, temp_dir, monkeypatch, sample_workflow_yaml):
from typer.testing import CliRunner
from specify_cli import app
(temp_dir / ".specify" / "workflows").mkdir(parents=True)
src = temp_dir / "Sample.YAML"
src.write_text(sample_workflow_yaml, encoding="utf-8")
monkeypatch.chdir(temp_dir)
result = CliRunner().invoke(app, ["workflow", "add", str(src)])
# Before the fix: `.suffix in (...)` is case-sensitive, so ".YAML" is not
# recognized as a local file; the path falls through to catalog lookup
# and fails. After the fix it installs like the lowercase happy path.
assert result.exit_code == 0, result.output
assert "installed" in result.output
def test_dev_path_accepts_uppercase_extension(self, temp_dir, monkeypatch, sample_workflow_yaml):
from typer.testing import CliRunner
from specify_cli import app
(temp_dir / ".specify" / "workflows").mkdir(parents=True)
src = temp_dir / "Sample.YAML"
src.write_text(sample_workflow_yaml, encoding="utf-8")
monkeypatch.chdir(temp_dir)
result = CliRunner().invoke(app, ["workflow", "add", "--dev", str(src)])
# Before the fix the --dev branch rejects ".YAML" with
# "--dev source must be a workflow YAML file ...".
assert result.exit_code == 0, result.output
assert "installed" in result.output
def test_lowercase_extension_still_installs(self, temp_dir, monkeypatch, sample_workflow_yaml):
"""Happy path (lowercase .yml) is unchanged by the case-normalization."""
from typer.testing import CliRunner
from specify_cli import app
(temp_dir / ".specify" / "workflows").mkdir(parents=True)
src = temp_dir / "sample.yml"
src.write_text(sample_workflow_yaml, encoding="utf-8")
monkeypatch.chdir(temp_dir)
result = CliRunner().invoke(app, ["workflow", "add", str(src)])
assert result.exit_code == 0, result.output
assert "installed" in result.output
class TestWorkflowAddSymlinkGuard:
def test_add_malformed_ipv6_url_exits_cleanly(self, temp_dir, monkeypatch):
"""A malformed IPv6 URL must produce a clean error, not a ValueError traceback."""
@@ -8548,18 +8675,15 @@ steps:
class FakeResponse:
def __init__(self, data, url=None):
self._data = data
self._pos = 0
self._url = url or "https://api.github.com/repos/org/repo/releases/assets/42"
def read(self, amt=None):
if not hasattr(self, "_pos"):
self._pos = 0
if amt is None:
chunk = self._data[self._pos :]
self._pos = len(self._data)
return chunk
chunk = self._data[self._pos : self._pos + amt]
self._pos += len(chunk)
return chunk
def read(self, size=-1):
if size < 0:
size = len(self._data) - self._pos
out = self._data[self._pos : self._pos + size]
self._pos += len(out)
return out
def geturl(self):
return self._url
@@ -8619,18 +8743,15 @@ steps:
class FakeResponse:
def __init__(self, data, url=None):
self._data = data
self._pos = 0
self._url = url or "https://api.github.com/repos/org/repo/releases/assets/42"
def read(self, amt=None):
if not hasattr(self, "_pos"):
self._pos = 0
if amt is None:
chunk = self._data[self._pos :]
self._pos = len(self._data)
return chunk
chunk = self._data[self._pos : self._pos + amt]
self._pos += len(chunk)
return chunk
def read(self, size=-1):
if size < 0:
size = len(self._data) - self._pos
out = self._data[self._pos : self._pos + size]
self._pos += len(out)
return out
def geturl(self):
return self._url
@@ -8670,18 +8791,15 @@ steps:
class FakeResponse:
def __init__(self, data, url=None):
self._data = data
self._pos = 0
self._url = url or "https://api.github.com/repos/org/repo/releases/assets/55"
def read(self, amt=None):
if not hasattr(self, "_pos"):
self._pos = 0
if amt is None:
chunk = self._data[self._pos :]
self._pos = len(self._data)
return chunk
chunk = self._data[self._pos : self._pos + amt]
self._pos += len(chunk)
return chunk
def read(self, size=-1):
if size < 0:
size = len(self._data) - self._pos
out = self._data[self._pos : self._pos + size]
self._pos += len(out)
return out
def geturl(self):
return self._url
@@ -8763,18 +8881,15 @@ steps:
class FakeResponse:
def __init__(self, data, url=None):
self._data = data
self._pos = 0
self._url = url or "https://ghes.example/api/v3/repos/org/repo/releases/assets/42"
def read(self, amt=None):
if not hasattr(self, "_pos"):
self._pos = 0
if amt is None:
chunk = self._data[self._pos :]
self._pos = len(self._data)
return chunk
chunk = self._data[self._pos : self._pos + amt]
self._pos += len(chunk)
return chunk
def read(self, size=-1):
if size < 0:
size = len(self._data) - self._pos
out = self._data[self._pos : self._pos + size]
self._pos += len(out)
return out
def geturl(self):
return self._url
@@ -8826,18 +8941,15 @@ steps:
class FakeResponse:
def __init__(self, data, url=None):
self._data = data
self._pos = 0
self._url = url or "https://ghes.example/api/v3/repos/org/repo/releases/assets/55"
def read(self, amt=None):
if not hasattr(self, "_pos"):
self._pos = 0
if amt is None:
chunk = self._data[self._pos :]
self._pos = len(self._data)
return chunk
chunk = self._data[self._pos : self._pos + amt]
self._pos += len(chunk)
return chunk
def read(self, size=-1):
if size < 0:
size = len(self._data) - self._pos
out = self._data[self._pos : self._pos + size]
self._pos += len(out)
return out
def geturl(self):
return self._url
@@ -9973,6 +10085,17 @@ steps:
registry.add("align-wf", {"version": "1.0.0", "source": "catalog"})
assert registry.get("align-wf")["version"] == "1.0.0"
def test_step_registry_add_survives_non_dict_existing_entry(self, project_dir):
"""StepRegistry.add must treat a corrupted non-dict existing entry as
absent rather than crash on existing.get() (parity with
WorkflowRegistry.add)."""
from specify_cli.workflows.catalog import StepRegistry
registry = StepRegistry(project_dir)
registry.data["steps"]["my-step"] = "corrupted"
registry.add("my-step", {"version": "1.0.0"})
assert registry.get("my-step")["version"] == "1.0.0"
@pytest.mark.parametrize(
"contents",
[
@@ -11630,6 +11753,10 @@ steps:
_reject_insecure_download_redirect(
"https://example.com/wf.yml", "http://localhost:8000/wf.yml"
)
with pytest.raises(urllib.error.URLError):
_reject_insecure_download_redirect(
"https://example.com/wf.yml", "https://127.0.0.2/wf.yml"
)
# Allowed: HTTPS anywhere, or loopback HTTP that stays on loopback HTTP.
_reject_insecure_download_redirect(
"https://example.com/wf.yml", "https://cdn.example.com/wf.yml"
@@ -11640,6 +11767,9 @@ steps:
_reject_insecure_download_redirect(
"http://127.0.0.1/source.yml", "http://127.0.0.1/wf.yml"
)
_reject_insecure_download_redirect(
"http://127.0.0.2/source.yml", "http://127.255.255.254/wf.yml"
)
def test_add_from_url_passes_redirect_validator(self, project_dir, monkeypatch):
from unittest.mock import patch

View File

@@ -253,6 +253,47 @@ def test_add_source_wraps_invalid_ipv6_as_bundler_error(tmp_path: Path):
cc.add_source(project, "https://[::1/c.json", policy="install-allowed", priority=50)
def test_add_source_wraps_bracketed_non_ip_host_as_bundler_error(tmp_path: Path):
# A bracketed-but-invalid IPv6 authority (e.g. "https://[not-an-ip]/c.json")
# parses cleanly under urlparse() on Python < 3.14 and only raises ValueError
# lazily on the first .hostname access; the raise moved eager into urlparse()
# in 3.14. add_source must surface its own BundlerError on every supported
# version, never leak a raw ValueError past the CLI's `except BundlerError`.
project = tmp_path / "proj"
(project / ".specify").mkdir(parents=True)
with pytest.raises(BundlerError, match="Invalid catalog url"):
cc.add_source(project, "https://[not-an-ip]/c.json", policy="install-allowed", priority=50)
def test_add_source_wraps_lazy_hostname_valueerror(tmp_path: Path, monkeypatch):
# Simulate the Python < 3.14 shape explicitly (independent of the running
# interpreter): urlparse() succeeds but .hostname raises ValueError lazily.
# This is the exact path the fix guards; it fails with a raw ValueError if
# .hostname is read outside the try/except.
from urllib.parse import urlparse as _real_urlparse
class _LazyHostnameRaiser:
def __init__(self, parsed):
self._parsed = parsed
@property
def hostname(self):
raise ValueError("simulated lazy IPv6 hostname failure")
def __getattr__(self, name):
return getattr(self._parsed, name)
def _fake_urlparse(url, *args, **kwargs):
return _LazyHostnameRaiser(_real_urlparse(url, *args, **kwargs))
monkeypatch.setattr(cc, "urlparse", _fake_urlparse)
project = tmp_path / "proj"
(project / ".specify").mkdir(parents=True)
with pytest.raises(BundlerError, match="Invalid catalog url"):
cc.add_source(project, "https://example.com/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)

View File

@@ -73,6 +73,23 @@ def test_build_is_deterministic(tmp_path: Path):
assert first.artifact_path.read_bytes() == second.artifact_path.read_bytes()
def test_member_order_is_platform_independent(tmp_path: Path):
# Members must be laid out in canonical POSIX-arcname order (the same key
# build_bundle uses to NAME them), not pathlib.Path order — which folds case
# on Windows and would otherwise reorder members across build hosts, breaking
# the byte-for-byte reproducibility guarantee. Mixed-case names make the
# difference observable: Path order on Windows groups differently than the
# canonical string sort.
bundle = _make_bundle(
tmp_path / "b",
extra_files={"Zeta.txt": "z", "apple.txt": "a", "Foo.txt": "f", "bar.txt": "b"},
)
result = build_bundle(bundle, output_dir=tmp_path / "out")
with zipfile.ZipFile(result.artifact_path) as archive:
names = archive.namelist()
assert names == sorted(names)
def test_output_dir_inside_bundle_excludes_prior_artifacts(tmp_path: Path):
bundle = _make_bundle(tmp_path / "b", extra_files={"a.txt": "a"})
out_dir = bundle / "dist"

View File

@@ -0,0 +1,26 @@
"""Unit tests for the bundler YAML I/O helpers."""
from __future__ import annotations
from pathlib import Path
from specify_cli.bundler.lib.yamlio import dump_yaml, load_yaml
def test_dump_yaml_preserves_unicode(tmp_path: Path):
# dump_yaml must write literal UTF-8, not \xNN / \uXXXX escapes, so bundle
# config stays human-readable — matching _utils.dump_frontmatter and the
# extensions/presets config writers (all allow_unicode=True).
path = tmp_path / "f.yml"
data = {"note": "café-münchen", "url": "https://例え.example"}
dump_yaml(path, data)
raw = path.read_text(encoding="utf-8")
assert "café-münchen" in raw
assert "例え" in raw
assert "\\x" not in raw and "\\u" not in raw
def test_dump_yaml_round_trips_unicode(tmp_path: Path):
path = tmp_path / "f.yml"
data = {"note": "café", "city": "münchen"}
dump_yaml(path, data)
assert load_yaml(path) == data

View File

@@ -162,6 +162,23 @@ class TestMergeSteps:
ComposedStep("low-step", "project:low"),
]
def test_merge_steps_multiple_insert_after_same_overlay_preserves_order(self):
# Two insert_after edits from ONE overlay on the same anchor must keep
# their declared order (a, x, y, b) — mirroring insert_before. The old
# reversed(edits) over the flat list flipped them to (a, y, x, b).
base = [_step("a"), _step("b")]
overlay = Overlay(
id="ov1",
extends="wf",
priority=10,
edits=[
OverlayEdit("insert_after", "a", _step("x")),
OverlayEdit("insert_after", "a", _step("y")),
],
)
steps, _ = merge_steps(base, [_layer(overlay, "project:ov1")])
assert [s["id"] for s in steps] == ["a", "x", "y", "b"]
def test_merge_steps_replace_wins_over_insert(self):
"""Overlays apply to the original tree only; targeting an overlay-introduced step raises."""
base = [_step("a")]