Compare commits

...

61 Commits

Author SHA1 Message Date
github-actions[bot]
ee883a1d4e chore: bump version to 0.13.4 2026-07-22 14:31:27 +00: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
Quratulain-bilal
d9e4565cf8 fix(integrations): escape Rich markup in --integration-options error messages (#3458)
* fix(integrations): exit cleanly on malformed --integration-options quoting

_parse_integration_options called shlex.split(raw_options) unguarded. an
unbalanced quote (e.g. --integration-options='--commands-dir "foo') makes
shlex raise ValueError('No closing quotation'), so a raw traceback escaped
instead of the typer.Exit(1) error every other bad-input path in this function
produces. reachable from specify init and every integration install/switch/
upgrade/migrate that accepts --integration-options.

wrap the split and convert ValueError into the same clean CLI error. added a
regression test; confirmed it fails on the pre-fix code (raw ValueError).

* escape user-controlled values in integration-options error messages

the malformed-quoting handler (and the unexpected/unknown option
branches) interpolate raw_options/token into console.print. a value
carrying an unbalanced rich tag like '--commands-dir "[/red]foo' first
trips the intended shlex ValueError, but the error print then raises
rich.errors.MarkupError and leaks a traceback anyway. escape all three
before printing so the clean typer.Exit survives.

added a regression covering both the shlex path and a bare markup token.

* address review: drop redundant, mis-described markup test

The removed test's docstring claimed the shlex failure branch
interpolates raw_options into console.print; it only prints {exc},
which never contains the caller's markup. Its two assertions also
duplicated test_bad_option_token_with_rich_markup_exits_cleanly (the
'[/red]foo' case) and the shlex-path case already covered by
test_unbalanced_quote_exits_cleanly. The real change here remains the
escape() of the two user-controlled token prints.
2026-07-22 06:50:45 -05:00
Quratulain-bilal
840fb8d786 docs: document __SPECKIT_COMMAND_ token for portable cross-command references (#3503)
* docs: document __SPECKIT_COMMAND_ token for cross-command references

the development guide's 'Body (Markdown)' section listed $ARGUMENTS and
{SCRIPT} but never mentioned __SPECKIT_COMMAND_<NAME>__, the agent-neutral
token that resolve_command_refs() renders into each agent's invocation
syntax. with no signal the token exists, an author naturally hard-codes a
literal like /speckit.my-ext.prepare — correct for one agent, broken on the
rest (the root cause behind #3451).

added it to the placeholder list plus a 'Referencing other commands'
subsection: why a literal isn't portable, the name->token encoding, and a
worked example showing the same token render as /speckit.bug.fix for a
slash agent and /speckit-bug-fix for a skills agent. examples verified
against resolve_command_refs and the first-party bug/git extensions.

phase 1 of the plan in #3474; addresses the discoverability gap for #3451.

* address review: describe separator-based token rendering accurately

Copilot flagged (and mnriem asked me to address) that the section implied
__SPECKIT_COMMAND_<NAME>__ always resolves to each agent's native
invocation, including $speckit-* for Codex/ZCode. the resolver
(resolve_command_refs) only emits /speckit<separator>... based on the
active integration's invoke_separator; the $ and /skill: prefixes come
from later skills-output post-processing, not the token resolver itself.

reworded both the placeholder-list entry and the two explanatory
paragraphs to describe separator-based rendering, and moved the
prefix-in-skills-mode detail to a parenthetical example rather than
stating it as the token's guaranteed output.

* address review: qualify token portability for skills-mode extensions

Copilot correctly noted the __SPECKIT_COMMAND_<NAME>__ token is not yet
resolved for extension-generated skills: _register_extension_skills()
calls resolve_skill_placeholders() and post_process_skill_content() but
never resolve_command_refs(), so the token reaches Codex/ZCode/Kimi
verbatim in skills mode. Token resolution only runs in the command-file
rendering path (CommandRegistrar). Add an explicit limitation note so the
guidance no longer implies universal portability.
2026-07-22 06:48:58 -05:00
github-actions[bot]
0d2e3b5e76 [preset] Add Parallel Autonomous Run Governance preset to community catalog (#3614)
* Add Parallel Autonomous Run Governance preset to community catalog

Add parallel-autonomous-run-governance preset submitted by @hindermath to:
- presets/catalog.community.json (alphabetical order)
- docs/community/presets.md community presets table

Closes #3591

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

* fix: align preset metadata with review feedback

Assisted-by: GitHub Copilot (autonomous)

* Potential fix for pull request finding

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

* Remove invalid preset dependency from extension requirements

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

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-22 06:10:50 -05:00
Ali jawwad
41a8e07f4c docs(workflows): fix stale FanOutStep docstring claiming sequential-only execution (#3639)
The FanOutStep class docstring stated that fan-out execution is
"currently sequential" and that `max_concurrency` is "accepted but not
enforced". That has been inaccurate since #3224, which added a bounded
thread-pool concurrency path to `WorkflowEngine._run_fan_out` that honors
`max_concurrency > 1`.

Update the docstring to match the engine's own `_run_fan_out` docstring:
`max_concurrency <= 1` (the default) runs items sequentially, while `> 1`
runs up to that many items concurrently on a bounded thread pool.
Docstring-only; no behavior change.

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 06:10:06 -05:00
github-actions[bot]
01d07e2f87 [bundle] Add SicarioSpec Security & Governance Bundle to community catalog (#3636)
* Add SicarioSpec Security & Governance Bundle to community catalog

- Adds sicario-spec v0.5.1 entry to bundles/catalog.community.json
- Adds row to docs/community/bundles.md
- Bundle provides 1 extension (sicario-guard) and 11 presets
- Required companion preset and extension catalogs are documented in README
- Validated: bundle ID, version, repository, release artifact, catalog entry shape, checklists

Closes #3619

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

* fix: revert sicario-spec bundle entry pending upstream README correction

The upstream README uses mutable raw.githubusercontent.com/main catalog URLs
instead of the v0.5.1 release-pinned URLs submitted in issue #3619, failing
the required-catalog consistency check. Removing the catalog entry and docs
row until the README is updated to match the submitted pinned URLs.

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

* Revert "fix: revert sicario-spec bundle entry pending upstream README correction"

This reverts commit 94ee59639f.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
2026-07-22 06:04:18 -05:00
github-actions[bot]
956ecab230 [preset] Update Autonomous Run Governance preset to v0.3.2 (#3615)
* Update Autonomous Run Governance preset to v0.3.2

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

Closes #3606

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

* fix(presets): remove extension requirement from autonomous preset

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

* Potential fix for pull request finding

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

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-22 05:59:07 -05:00
Quratulain-bilal
30e99ec083 fix(workflows): validate every redirect hop when fetching workflow/step catalogs (#3637)
WorkflowCatalog._fetch_single_catalog and StepCatalog._fetch_single_catalog
opened the catalog URL with open_url(entry.url, timeout=30) and validated
only the final resp.geturl(). open_url follows redirects, so an https://
catalog entry that 30x-redirects through a non-HTTPS host mid-chain could
let a network attacker rewrite the next hop and slip a payload past the
terminal-URL-only check. The payload then drives step/workflow catalog data.

Pass a redirect_validator that runs the existing HTTPS/hostname check before
every redirect hop, keeping the final geturl() check as a defense-in-depth
backstop. This brings both workflow catalog loaders to parity with the
presets (#3523) and extensions (#3524) catalog fetchers.

Tests: add per-hop redirect-validation tests for both WorkflowCatalog and
StepCatalog (a non-HTTPS intermediate hop is rejected); both fail before the
fix ("NoneType object is not callable" — no validator passed). Update the two
existing malformed-redirect tests whose open_url stub lacked the
redirect_validator kwarg.
2026-07-22 05:58:38 -05:00
Dominik Mattioli
717d7c4b89 Add pipeline workflow to community catalog (#3338)
* Add pipeline extension

Chains the Spec Kit phases into one guided, single-invocation pipeline with a
deterministic phase resolver and one interactive clarify gate.

* refactor(pipeline): replace extension with converge-based workflow

Drop the extensions/pipeline Python resolver, command wrappers, and test
suite in favor of a single workflows/pipeline/workflow.yml, per review
feedback that the workflows engine already owns phase orchestration,
ordering, validation, and resume.

Rework the loop around the new speckit.converge command: analyze runs as a
single pre-implement consistency pass (its findings are artifact-level, not
code-level), and a bounded post-implement convergence loop
(implement -> converge, up to 3 cycles) closes any code/spec gaps.

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

* refactor(pipeline): move workflow to external repo, register in community catalog

* chore: bump pipeline catalog entry to v1.1.0

Point the community catalog entry at the corrected v1.1.0 release of
domattioli/spec-kit-workflow-pipeline and raise the advertised minimum
to >=0.11.2 (the release that introduced speckit.converge). Addresses
Copilot review feedback on the catalog metadata.

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

* Potential fix for pull request finding

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

---------

Co-authored-by: domattioli <domattioli@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-22 05:47:11 -05:00
github-actions[bot]
b79ae330fd [extension] Add Linear Weave extension to community catalog (#3609)
* Add Linear Weave extension to community catalog

Add linear-weave extension submitted by @tonydwoodhouse to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #3603

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

* Potential fix for pull request finding

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

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-22 05:34:26 -05:00
WOLIKIMCHENG
b6b3ec49d9 docs: clarify hook priority validation semantics (#3594)
* docs: clarify hook priority validation semantics

* docs: clarify stored hook priority normalization

---------

Co-authored-by: root <kinsonnee@gmail.com>
2026-07-22 05:28:02 -05:00
Noor ul ain
48686521ff fix(workflows): reject a non-string 'integration'/'model' in command & prompt steps (#3597)
* fix(workflows): reject a non-string 'integration'/'model' in command & prompt steps

A non-string `integration` on a command or prompt step is passed to
`get_integration()`, which uses it as a dict key: an unhashable list/dict
raises a raw `TypeError` there — and because neither `validate()` nor
`validate_workflow` checked the type, this crashes even a *validated* run,
not just an unvalidated one. A non-string `model` likewise reaches
`build_exec_args()` and is fed into the CLI argv.

Guard both fields in `validate()` (reject a literal non-string, mirroring the
existing 'command'/'prompt'/'input'/'options' checks) and in `execute()`
(fail the step cleanly rather than take down the whole run, mirroring the
'input'/'options' guards). An explicit YAML-null (inherit the workflow
default) and a "{{ ... }}" expression both stay valid.

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

* fix(workflows): route falsey non-string integration/model to the type guard

Address Copilot review: `config.get("integration") or context.default_integration`
(and the model equivalent) coerced a *falsey* non-string ([], {}, 0, False) into
the workflow default before the type guard ran. On an unvalidated execute() such a
step was silently accepted and — with a configured default — could dispatch using
the wrong integration/model instead of failing with the contract error.

Fall back to the workflow default only for genuinely-unset values (missing /
YAML-null / empty string) so every non-string reaches the guard. Add parametrized
falsey execute() cases ([], {}, 0, False) to both TestCommandStep and
TestPromptStep; with the fix stashed all 8 fail (swallowed into the default).

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 05:24:50 -05:00
Pascal THUET
ebd3097eb3 ci: add dependency audit workflow (#3138)
* ci: add dependency audit workflow

Add a Security Audit workflow with a dependency-audit job. Push/PR/manual
runs pip-audit against a committed --generate-hashes requirements snapshot
(.github/security-audit-requirements.txt) for deterministic CI, while the
weekly scheduled run resolves the runtime + test dependency set live across
the supported Python/OS matrix to surface newly published advisories.

A sync gate (.github/scripts/check_security_requirements.py) fails PRs whose
dependency inputs changed without refreshing the committed snapshot, so the
committed file can't silently drift from pyproject.toml.

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

* ci: split dependency audit schedule matrix

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

* ci: harden dependency audit sync checks

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

* ci: align security workflow python pin

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

* ci: refresh dependency audit baseline

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

* docs: clarify security snapshot audit

Assisted-by: Codex (model: GPT-5, autonomous)
2026-07-22 05:20:55 -05:00
github-actions[bot]
115bc94cce Add Intake Review Governance preset to community catalog (#3613)
Add intake-review-governance preset submitted by @hindermath to:
- presets/catalog.community.json (alphabetical order)
- docs/community/presets.md community presets table

Closes #3604

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 05:00:26 -05:00
Noor ul ain
d7699c39f2 fix(workflows): reject non-list input 'enum' instead of crashing (#3601)
A workflow input whose `enum` is a scalar or string (e.g. `enum: 5`,
`enum: "abc"`) previously slipped past `validate_workflow` and crashed
at run time. The `value not in enum_values` membership test in
`_coerce_input` raises a raw `TypeError` ("argument of type 'int' is
not iterable") for a scalar, and a bare string turns enum membership
into a silent substring test. The `TypeError` also escapes
`validate_workflow`'s `except ValueError`, breaking its documented
"return a list of errors, never raise" contract.

This is the same unvalidated-`execute()` crash class as the fan-in
`wait_for` (#3482) and fan-out step-template (#3537) fixes: `validate()`
should reject the value, but the value can still reach the engine via
`execute()`, which accepts unvalidated definitions.

Fix:
- `_coerce_input` requires a list `enum` (or `None`), raising a clean
  ValueError for any other shape — so both `validate_workflow` and
  runtime `_resolve_inputs` fail fast with a clear message.
- `validate_workflow` checks `enum` shape directly (not only via the
  default-coercion path, which is reached only when a `default` exists),
  and strips a malformed `enum` before coercing the default so the
  wrong-typed-default error is not duplicated as an enum-shape error.
- The `integration: auto` sentinel only strips a *list* `enum`; a
  non-list `enum` stays in the definition so it is rejected rather than
  silently exempted by the `auto` membership skip.

Tests cover all three layers: `_coerce_input` directly, authoring-time
`validate_workflow` (with no default present), and runtime
`_resolve_inputs`, plus the `integration: auto` interaction.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 13:09:38 -05:00
Manfred Riem
e9d84ca4fb chore: release 0.13.2, begin 0.13.3.dev0 development (#3617)
* chore: bump version to 0.13.2

* chore: begin 0.13.3.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-21 12:50:03 -05:00
Noor ul ain
70c547cfab fix(workflows): reject a non-string 'command' in command-step (#3596)
`CommandStep.validate` only checked that a `command` field is *present*,
never its type. On an unvalidated run (the engine does not auto-validate
before `execute`) a non-string `command` — null, a list, an int — was
passed straight through `_try_dispatch` to the integration's
`build_command_invocation`, which does `command_name.startswith("speckit.")`
and crashes the whole workflow with a raw `AttributeError` once a
resolvable integration with an installed CLI is found.

Guard both paths, mirroring the sibling steps:
- `validate()` rejects a non-string `command` (like prompt-step `prompt`
  #3582 and shell-step `run`).
- `execute()` fails the step cleanly with the same contract error before
  dispatch (like the existing `input`/`options` guards in this file), so
  an unvalidated run FAILs the step instead of crashing the run.

An expression like `{{ inputs.cmd }}` is still a string, so it stays valid.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 12:34:33 -05:00
Noor ul ain
2f9e45514c fix(workflows): fail gate step loudly on a malformed 'options' (#3595)
`GateStep.validate` rejects a non-list (or empty) `options` and requires
every option to be a string, but the engine does not auto-validate before
`execute`. On an unvalidated run a scalar/dict/None `options` reached
`_prompt` and crashed the whole workflow with a raw `TypeError`
(`enumerate`/`len` on a non-iterable) or `KeyError` (indexing a dict); an
empty list spun `_prompt`'s input loop forever; a non-string option crashed
the reject check at `choice.lower()` with `AttributeError`.

Guard `execute` to FAIL the step cleanly instead, before the non-TTY
PAUSE short-circuit so the error surfaces in CI too rather than pausing
and only crashing later on interactive resume. Mirrors the switch 'cases'
and command 'input' unvalidated-execute guards.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 12:32:15 -05:00
Ali jawwad
69c8b64301 fix(extensions): re-validate catalog URL after redirects (HTTPS parity/security) (#3524)
* fix(extensions): re-validate catalog URL after redirects (HTTPS parity)

ExtensionCatalog._fetch_single_catalog opened the catalog URL and trusted the
payload without re-validating response.geturl() after redirects. _open_url
follows redirects (stripping auth only on an HTTPS->HTTP downgrade), so an
https:// catalog entry that 30x-redirects to http://attacker/... was still
fetched and trusted. The payload supplies each extension's download_url +
sha256, so a redirected payload can drive install of an arbitrary archive that
passes sha256 verification.

Add the post-redirect geturl() re-validation via _validate_catalog_url,
mirroring integrations/catalog.py, presets, workflows/catalog.py, and bundler
adapters. Sibling of the same fix in the presets catalog fetcher.

Test: an HTTPS URL whose response.geturl() reports http:// is rejected
(ExtensionError). Completed existing fetch-test mocks that predated this
behavior to report geturl() like a real urllib response.

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

* fix(extensions): correct auth-strip comment; validate every redirect hop + guard legacy fetch_catalog

- Correct the comment: _StripAuthOnRedirect strips auth not only on an
  HTTPS->HTTP downgrade but also whenever the redirect leaves the configured
  trusted hosts. The comment now describes both cases.
- Parity with the presets fix: validate EVERY redirect hop (not just the
  terminal URL) so an https -> http -> attacker-https chain can't slip a
  redirected payload past the final-URL check. _open_url forwards a
  redirect_validator to open_url; _fetch_single_catalog passes
  _validate_catalog_url through it while keeping the final geturl() check.
- Give the legacy public fetch_catalog() single-catalog path the same
  redirect_validator + final geturl() validation (it previously parsed the body
  with no redirect check).

Tests: an intermediate http hop is rejected, and the legacy fetch_catalog()
rejects an HTTPS->http redirected payload (both fail before). Full
test_extensions.py (356) green.

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

* test(extensions): cover legacy fetch_catalog() per-hop redirect validation

The legacy fetch_catalog() regression test only exercised the terminal geturl()
check, so it would still pass if the per-hop redirect_validator were dropped from
that duplicated path. Add test_fetch_catalog_legacy_validates_every_redirect_hop,
which asserts fetch_catalog() supplies a redirect_validator that rejects an
insecure intermediate hop (fails before: the legacy path passed no validator ->
NoneType not callable).

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-21 12:30:49 -05:00
Ben Buttigieg
5760061316 Add community bundle submission automation (#3553)
* Add community bundle submission automation

Add the discovery-only community bundle catalog, online and offline catalog loading, and a restricted agentic workflow for validating bundle submissions and opening draft catalog PRs.

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

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

Copilot-Session: fbe794bc-667a-4c9e-b48b-825067debc6d

* Address community bundle review feedback

Ensure explicit install-allowed catalogs take precedence over built-in discovery, tighten component installability validation, and use issue-linked community branches.

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

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

Copilot-Session: fbe794bc-667a-4c9e-b48b-825067debc6d

* Address follow-up bundle review feedback

Make offline catalog coverage content-agnostic and require autonomous catalog commits to include the assisted-by trailer.

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

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

Copilot-Session: fbe794bc-667a-4c9e-b48b-825067debc6d

* Harden bundle catalog table rendering

Require single-line escaped Markdown table values for untrusted submission metadata. The needs-info label used by validation is now present in the repository.

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

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

Copilot-Session: fbe794bc-667a-4c9e-b48b-825067debc6d

* Clear stale bundle validation labels

Allow the submission workflow to remove prior outcome labels before applying the current validation state.

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

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

Copilot-Session: fbe794bc-667a-4c9e-b48b-825067debc6d
2026-07-21 18:28:40 +01:00
Ali jawwad
1f7290c975 fix(presets): re-validate catalog URL after redirects (HTTPS parity/security) (#3523)
* fix(presets): re-validate catalog URL after redirects (HTTPS parity)

PresetCatalog._fetch_single_catalog opened the catalog URL and trusted the
payload without re-validating response.geturl() after redirects. _open_url
follows redirects (stripping auth only on an HTTPS->HTTP downgrade), so an
https:// catalog entry that 30x-redirects to http://attacker/... was still
fetched and trusted. The catalog payload supplies each preset's download_url +
sha256, so a redirected payload can drive install of an arbitrary archive that
passes verify_archive_sha256.

Add the post-redirect geturl() re-validation via _validate_catalog_url,
mirroring integrations/catalog.py, workflows/catalog.py, and bundler adapters —
and presets/_commands.py, which already does this on its --from download path.
This is the lone preset catalog-fetch site missing the guard.

Test: an HTTPS URL whose response.geturl() reports http:// is rejected
(PresetValidationError). Completed four existing fetch-test mocks that predated
this behavior to report geturl() like a real urllib response.

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

* fix(presets): validate every redirect hop + guard the legacy fetch_catalog path

Two follow-ups to the catalog redirect hardening:

1. Validate every redirect hop, not just the terminal URL. A final-geturl-only
   check passes an https -> http -> attacker-controlled-https chain: the insecure
   intermediate hop lets a network attacker rewrite the next redirect. _open_url
   now forwards a redirect_validator to open_url (called before each hop), and
   _fetch_single_catalog passes _validate_catalog_url through it while retaining
   the final geturl() check — mirroring bundler/services/adapters.py.

2. The legacy public fetch_catalog() single-catalog path parsed response.read()
   with no redirect check at all. Give it the same redirect_validator + final
   geturl() validation.

Tests: a stubbed intermediate http hop is rejected (redirect_validator), and the
legacy fetch_catalog() rejects an HTTPS->http redirected payload (fail before:
no raise). Existing fetch-test mocks updated to accept the redirect_validator
kwarg and report geturl() like a real response. Full test_presets.py (365) green.

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

* test(presets): drop duplicate geturl mock; add legacy fetch_catalog per-hop test

- Remove the duplicate mock_response.geturl.return_value assignment left by the
  geturl mock-completion pass (the explanatory comment was stranded between the
  two identical assignments); keep a single assignment after the comment.
- Add test_fetch_catalog_legacy_validates_every_redirect_hop so the legacy
  fetch_catalog() path is verified to supply the redirect_validator (rejecting an
  insecure intermediate hop), not just the terminal geturl() — parity with
  _fetch_single_catalog and the #3524 sibling.

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-21 12:00:53 -05:00
Marsel Safin
2a0ada9a6a feat(scripts): port create-new-feature, setup-plan and setup-tasks to Python (#3386)
* feat(scripts): port create-new-feature, setup-plan and setup-tasks to Python

Ports the three core workflow scripts to Python as part of #3280,
following the check-prerequisites PoC pattern from #3302. Adds
resolve_template() to the shared common.py module and parity tests
that run bash and Python side by side.

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

* fix(tests): treat only None env as unset in parity run helper

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

* fix(scripts): fall back to directory scan on any registry error, skip hidden preset dirs

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

* feat(templates): add py: lines for setup_plan and setup_tasks

Ships with the scripts they reference; the remaining templates got
their py: lines in #3403.

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

* fix: support py variant in skills placeholder resolver

resolve_skill_placeholders only accepted sh/ps, so a py init option
fell into the fallback path and {SCRIPT} rendered without an
interpreter prefix. Accept py and prefix the resolved interpreter,
matching process_template. Also guard ps_cmd against a missing
PowerShell with a clear assert.

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

* test: pin clean-error behavior for invalid --number

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

* docs(scripts): reword unused-arg comment to match implementation

The loop accepts and silently ignores extra positional args (it doesn't
build a collected list); match the wording to what the code and
setup-plan.sh actually do.

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

* fix: fall back when configured script variant is missing from frontmatter

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

* fix(scripts): reject signed/whitespace --number values to match bash 10# parity

The bash twin uses $((10#$BRANCH_NUMBER)), which rejects signed and
whitespace-padded values. Python's int() accepted them (e.g. -1),
producing a malformed -01-... prefix that sequential scans ignore.
Restrict --number to unsigned decimal digits before conversion, and
pin the parity with a bash-comparison test.

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

* fix(scripts): complete Python port installation

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

* fix(integrations): fall back for missing script variants

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

* test: make Python script checks platform-aware

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

* fix Windows Python command invocation parity

Use PowerShell's call operator for spaced Python interpreter paths and align setup-tasks missing-template errors across script variants.

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

* fix(scripts): preserve cross-platform Python parity

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

* fix: reject signed PowerShell feature numbers

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

* fix(scripts): align feature number range

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

* fix(scripts): reject exhausted feature numbers

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

* fix(scripts): complete create feature parity

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

* fix(scripts): align create feature outputs

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

* fix(scripts): harden cross-platform parity

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

* fix(scripts): keep truncation JSON clean

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

* fix(scripts): align setup failure parity

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

* fix(scripts): close parity edge cases

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

* fix(scripts): propagate PowerShell setup errors

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

* fix(scripts): harden fallback resolution

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

* fix(scripts): stabilize PowerShell fallbacks

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

* fix(scripts): complete setup-plan parity

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

* fix(cli): require runnable script fallbacks

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

* fix(cli): preserve shell fallback without preference

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

* fix(scripts): restore help and symlink parity

- setup-tasks.ps1: check -Help before unknown-argument validation so
  '-Help --bogus' exits 0 like the Bash/Python variants
- common.py: strip the repo root prefix lexically in persist_feature_json
  instead of resolve(), so a symlinked specs/ still persists the relative
  'specs/NNN-name' path the Bash/PowerShell helpers store

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

* fix(scripts): align persist-hint quoting with shlex.quote

- create-new-feature.sh: replace printf %q with a shell_quote helper that
  emits shlex.quote-identical output, so the persistence hints stay
  byte-identical between the Bash and Python variants (printf %q output
  also varies between bash versions)
- promote the negative --number test to an all-variants parity test now
  that Bash and PowerShell reject signed values consistently
- add a spaced-repo-path parity test for the persistence hints

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-21 11:40:15 -05:00
Andrew Chen
7873c447bd fix(agents): parse frontmatter on the --- delimiter line, not any --- substring (#3590)
CommandRegistrar.parse_frontmatter located the closing delimiter with
content.find("---", 3), a raw substring search. It stopped at the first
"---" anywhere after the opening — including one embedded in a
frontmatter value (e.g. a description "Separate sections with ---
markers") or inside an indented literal block — which truncated the
frontmatter and spilled the remainder into the body, silently corrupting
both the parsed metadata and the rendered command body.

Match the closing "---" on line boundaries, mirroring the line-anchored
scan already used by VibeIntegration._inject_frontmatter_flag.
2026-07-21 10:22:52 -05:00
github-actions[bot]
eabfabb490 [bug-fix] Fix reinstall-overwrites-kept-config: preserve config on plain reinstall after --keep-config (#3449)
* Fix reinstall-overwrites-kept-config: preserve config on plain reinstall after --keep-config

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

Before the unconditional shutil.rmtree(dest_dir), scan dest_dir for any
*-config.yml and *-config.local.yml files and hold their contents in memory.
After shutil.copytree succeeds, write them back so user-customized values
always win over the packaged defaults.

This mirrors the existing backup/restore logic for the --force reinstall path
but handles the case where remove --keep-config left config files behind in
an unregistered extension directory.

Refs #3427

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

* fix: restore method decl, move config restore before registration, preserve file mode

- Restore missing `test_install_force_without_existing` method declaration in
  tests/test_extensions.py so pytest collects it as a separate test.
- Move stranded-config restoration to immediately after `copytree`, before
  command/skill/hook registration, so a failed registration step can't leave
  preserved configs permanently lost.
- Store `(bytes, mode)` tuples instead of bare bytes when rescuing stranded
  configs, and reapply the original file mode after writing so permission bits
  (e.g. 0600 for credential files) are faithfully restored.

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

* fix: mask setuid/setgid bits when restoring stranded config file mode

Only preserve user/group read-write bits (mode & 0o660) to avoid
restoring setuid, setgid, or world-writable permissions from a
user-modified config file.

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

* fix: add copytree rollback path and strengthen regression test with packaged default config

- Wrap shutil.copytree in a try/except BaseException so stranded configs
  rescued before rmtree are written back even if copytree fails mid-way
  (addresses review comment: configs were permanently lost on copy failure)
- Add a packaged default config to extension_dir in the regression test so
  a naive 'restore only when absent' implementation would fail; assert the
  user's customized values beat the packaged defaults after reinstall

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

* fix: restore configs with secure atomic writes

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

* fix: write secure temp file then chmod to preserved_mode; add copytree-failure test

- _restore_stranded_config_file: write content while temp file is at its
  secure OS-default mode (typically 0600 on POSIX), then apply the
  original preserved_mode after the file is fully written and before the
  atomic os.replace. Removes the & 0o660 mask that was silently stripping
  world-read and executable bits (e.g. 0644 → 0640).

- Add test_copytree_failure_restores_stranded_config: patches
  shutil.copytree to create a partial destination then raise OSError,
  then asserts that the preserved config bytes and file mode are restored
  by the rollback path and that the extension remains unregistered.

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

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

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

* Potential fix for pull request finding 'Module is imported with 'import' and 'import from''

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

* fix: durable staging for stranded configs and import style fix

- Stage stranded config files to a durable rescue_staging_dir
  (extensions_dir/.rescue-staging-<id>) before rmtree so original bytes
  survive partial rmtree, copytree failure, or partial restore on retry.
  On retry the staging dir is detected and its content reused instead of
  whatever mix of packaged defaults and partial restores remains on disk.
  The staging dir is cleaned up only after every restore succeeds.
- Fix CodeQL: change `import specify_cli.extensions as _ext_module` to
  `from specify_cli import extensions as _ext_module` in test file.

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

* fix: harden rescue staging dir - symlink checks, secure writes, cleanup errors

- Thread 14: Change except BaseException to except Exception in the staging
  fallback block so KeyboardInterrupt/SystemExit propagate correctly
- Thread 15: Add explanatory comment to the bare pass in the chmod except
  block to satisfy static analysis
- Thread 16: Reject a symlinked staging directory and only reload
  non-symlinked files whose names match the two recognised config suffixes
- Thread 17: Create each staging file via os.open with mode 0600 and
  O_CREAT|O_EXCL before writing so preserved bytes are never transiently
  exposed to other local users
- Thread 18: Remove ignore_errors=True from the final staging-dir cleanup
  so a failed rmtree propagates rather than silently leaving a stale
  backup that could be misread on the next retry

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

* fix: mask file-type bits from chmod, harden staging dir symlink check

- Add `import stat` to imports
- Use `stat.S_IMODE(mode)` before chmod in staging write (thread 20, line 1464)
- Use `stat.S_IMODE(preserved_mode)` and make chmod best-effort in
  `_restore_stranded_config_file` (thread 18, line 1492)
- Add `not rescue_staging_dir.is_symlink()` guard to cleanup (thread 19, line 1522)

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

* fix: use completion marker for rescue staging, abort on staging failure, full os.write

Assisted-by: GitHub Copilot (model: GPT-5.3-Codex, autonomous)

* fix(extensions): make rescue staging durable

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

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

* test(extensions): fix flaky copytree regression test

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

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

* test(extensions): fix module import alias for review feedback

Assisted-by: GitHub Copilot (model: MAI-Code-1-Flash, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(workflows): keep cleanup warnings single-line and remove dead helper

Assisted-by: GitHub Copilot (model: MAI-Code-1-Flash, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Potential fix for pull request finding 'Module is imported with 'import' and 'import from''

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

* Preserve rescued extension config across retry

Assisted-by: GitHub Copilot (model: MAI-Code-1-Flash, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Clarify ignored directory fsync cleanup errors

Assisted-by: GitHub Copilot (model: MAI-Code-1-Flash, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Fix reinstall durability and workflow cleanup warnings

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

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

* Open rescue staging file in binary mode to fix Windows CRLF corruption

On Windows os.open() defaults to text mode, so os.write() of preserved
config bytes containing \r\n was translated to \r\r\n, corrupting the
staged backup and failing the retry-restore regression test. Add
O_BINARY (0 on POSIX) to the staging file open flags.

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

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

* Load .extensionignore before deleting dest_dir on reinstall

The .extensionignore loader can raise ValidationError (invalid UTF-8) or
OSError. Previously it ran after dest_dir was removed, so such a failure
left the kept config only in the hidden staging directory rather than its
documented location. Load/validate it before the rmtree so every
post-deletion failure path restores the config. Adds a regression test.

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

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

* Validate .extensionignore before publishing rescue staging

Loading .extensionignore after the rescue staging directory was published
meant a validation failure left a complete staging copy behind. A later
retry (after the user fixed the ignore file and edited the kept config)
would reload the stale staged bytes and silently overwrite the newer
config. Move the loader ahead of reading/creating rescue staging so a
failure aborts while the kept config is still authoritative on disk, and
extend the regression test to prove no staging is published and a retry
adopts the newer bytes.

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

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

* Harden preserved-config rescue against divergence and long names

Address three review findings on the reinstall config-rescue path:

- A complete .rescue-complete marker proves only that staging finished,
  not that dest_dir was modified. A crash after staging sync but before
  the rmtree leaves the live kept config intact; if the user edits it
  before retrying, preferring the staged bytes silently overwrote the
  newer config. The two copies are indistinguishable in provenance from
  disk, so detect divergence between a complete staging copy and the live
  config and abort (preserving both) instead of unconditionally choosing
  staging.
- The staging directory embedded the full extension ID in one path
  component. Extension IDs are length-unbounded, so a valid long ID could
  install at dest_dir yet fail every reinstall-after-keep-config with
  ENAMETOOLONG. Derive the staging component from a fixed-length hash via
  a new _rescue_staging_dir() helper.
- The stranded-config restore used the full config filename as a
  NamedTemporaryFile prefix; a name already near the component limit plus
  the random suffix raised ENAMETOOLONG. Use a short fixed prefix.

Updates the retry regression test to the new divergence semantics and
adds conflict-abort, long-ID, and fixed-prefix coverage.

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

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

* Harden preserved-config rescue divergence check and fix test path

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

* fix: reject/flag symlinked preserved configs on reinstall

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

* fix: include symlinks in live-dir config enumeration and address review feedback

- _recognized_config_names() now accepts follow_symlinks=False for live dir
  so symlinked *-config.yml entries are detected and treated as conflicts
  rather than being silently deleted by rmtree.
- Add explanatory comment to bare 'except OSError: pass' in
  _restore_stranded_config_file's finally block.
- Resolve CodeQL dual-import style: use 'from specify_cli import extensions
  as _ext_module' instead of 'import specify_cli.extensions as _ext_module'.

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

* test: add staging-failure fault-injection test for rescue staging block

Add test_staging_failure_aborts_before_dest_dir_removal covering three
failure modes (mkdir, os.open/O_CREAT, fsync with EIO) in the rescue
staging block. Each parametrized case verifies:
- the install aborts before dest_dir is removed
- the preserved config bytes remain authoritative
- any partial staging is cleaned up and not left as complete
- the extension stays unregistered

Addresses review feedback on PRRT_kwDOPiFCnc6R351t.

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

* test: add test_retry_restores_config_from_staging_when_live_absent

Exercises the retry-from-staging branch (if staging_is_complete at
line 1505 of extensions/__init__.py) in a scenario where the live
config is absent — simulating a power loss that interrupted the
rollback before it could write the config back.

When the live copy is gone, the live-dir fallback (elif dest_dir.exists())
finds no stranded configs and the packaged default would be kept. Only the
staging-complete branch can restore the original bytes and mode. This proves
staging (not the fallback) is used on retry.

Addresses review feedback on PRRT_kwDOPiFCnc6SAL3L.

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

* fix: keep staging files writable; record modes in .rescue-modes.json; fix live-only conflict message

Thread 64: Remove os.fchmod/chmod from staged files to avoid Windows
read-only attribute that prevents shutil.rmtree from cleaning up.
Original permission bits are now written to a .rescue-modes.json sidecar
in the staging dir and reloaded during retry, with a fall-back to the
staged file's own mode for backwards-compat with pre-sidecar staging dirs.

Thread 65: Split the ValidationError message for staging-vs-live conflicts
into two accurate cases: files that diverged between both locations
("Both copies have been preserved") and live-only files that have no
backup counterpart, which previously incorrectly claimed "Both copies
have been preserved" and offered a restore instruction that was impossible.

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

* Potential fix for pull request finding 'Empty except'

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

* fix: add .keep-config provenance marker to guard rescue path against partially-failed installs

When `remove --keep-config` strands config files, write a `.keep-config`
marker into the extension directory.  `install_from_directory` now only
enters the rescue path when that marker is present, preventing a partially-
failed install (which also leaves dest_dir with no registry entry but no
marker) from having its packaged default configs treated as user-preserved
data on a retry from an updated package.

Refs: https://github.com/github/spec-kit/pull/3449#discussion_r3606283457

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

* refactor: extract _has_keep_config_marker helper and document empty-content choice

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

* fix: defer rescue-backup cleanup until registry commit; validate modes sidecar shape

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

* fix legacy keep-config rescue and retry baseline handling

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-07-21 10:22:07 -05:00
Davide Barletta
74662cffad feat: update Bob integration to skills-based layout for Bob 2.0 (#3415)
* feat: update Bob integration to skills-based layout for Bob 2.0

Bob 2.0 replaces the command-based workflow (.bob/commands/*.md) with
a skills-based layout (.bob/skills/speckit-<name>/SKILL.md), matching
the pattern used by Claude Code, Codex, and other skills-first agents.

- Switch BobIntegration from MarkdownIntegration to SkillsIntegration
- Update folder/dir from .bob/commands to .bob/skills
- Change extension from .md to /SKILL.md (skills layout)
- Add --skills option (default: True) consistent with Codex pattern
- Update tests to inherit from SkillsIntegrationTests (28 tests pass)
- Bump catalog entry to version 2.0.0 with updated description

Assisted-by: IBM Bob (model: claude-sonnet-4-5, autonomous)

* PR comments fix: keep old Bob 1 commands till next release

* Copilot suggested change

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

* feat(bob): address copilot comments, make skills layout default, demote legacy commands to opt-in

* fix(bob): honor legacy_commands in ai_skills persistence and add bob to ALWAYS_SLASH_AGENTS

- init.py: suppress ai_skills=True when --legacy-commands is passed so
  extensions and presets target .bob/commands, not .bob/skills
- _invocation_style.py: add 'bob' to ALWAYS_SLASH_AGENTS so init next-steps
  and hook invocations always show /speckit-<name> (skills is the default
  layout; no ai_skills flag required)

* Copilot suggestion

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

* fix(bob): extend IntegrationBase directly to avoid false isinstance(SkillsIntegration)

- bob/__init__.py: switch BobIntegration base from SkillsIntegration to
  IntegrationBase; add _BobSkillsHelper for skills-mode delegation; set
  invoke_separator='-' explicitly; set _skills_mode flag in setup() so
  consumers can derive the effective mode without isinstance checks
- _helpers.py: replace isinstance(integration, SkillsIntegration) guard
  with getattr(_skills_mode) so legacy-commands mode does not persist
  ai_skills=True
- _invocation_style.py: remove 'bob' from ALWAYS_SLASH_AGENTS — Bob 2.0
  skills are invoked via natural language, not /skill-name slash commands
- integrations/catalog.json: advance updated_at to 2026-07-15

* fix(lint): remove unused SkillsIntegration import from _helpers.py

* Copilot suggested change

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

* feat(bob): add bob skills integration with registrar-based mode detection

* address 3 comments from copilot

* feat(bob): update registrar config to use legacy commands layout

* fix lint

* Suggested fix from Copilot

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

* fix pr comment

* fix pr comment

* fix pr comment

* refactor(bob): resolve skills mode via base-class hooks + fix command-ref separators

Rework the dual-mode handling introduced for Bob 2.0 so an integration's
internal representation never leaks into shared init/install/upgrade code,
and fix the legacy command-reference separator surfaced in review.

Base-class contract:
- Add IntegrationBase.is_skills_mode(parsed_options) — the single hook the
  shared machinery consults to decide whether to persist ai_skills and render
  skill invocations. SkillsIntegration returns True; Copilot honors --skills /
  self._skills_mode; Bob returns `not legacy_commands`.
- Add IntegrationBase.invoke_separator_for_mode(skills_enabled) — resolves the
  command-ref separator from a project's persisted mode for registration paths
  that only have the ai_skills flag (no CLI parsed_options). Default is
  behavior-preserving; Bob maps skills->"-", legacy->".".
- BobIntegration stays on IntegrationBase (mirroring Copilot, the other
  dual-mode agent) and delegates setup() to internal _BobSkillsHelper /
  _BobMarkdownHelper. Removes the _skills_mode method and all
  isinstance(SkillsIntegration) / callable(_skills_mode) probing from
  _helpers.py and init.py.

Fix legacy separator (review feedback): CommandRegistrar.register_commands and
PresetManager._resolve_skill_command_refs previously read the single static
AGENT_CONFIGS[key]["invoke_separator"], so legacy .bob/commands/ extension and
preset command refs rendered /speckit-<cmd> instead of Bob 1.x /speckit.<cmd>.
Both now resolve the separator per project mode via invoke_separator_for_mode.

Tests: add regression coverage for the is_skills_mode / invoke_separator_for_mode
hooks and legacy extension command-ref separators; normalize a width-sensitive
workflow assertion to match its siblings. Full suite green.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 63f93544-a77f-4f01-bf04-c88806a97dbf

* fix(bob,copilot): address review — preserve legacy layout, dual-mode separators, extension-skill token resolution

Addresses PR review 4716036212 (3 comments):

1. Bob legacy-install regression: `use`/`switch`/`upgrade` on an existing
   Bob 1.x project (only `.bob/commands/` on disk, no stored
   `legacy_commands`) called `is_skills_mode(None)` -> True and rewrote
   `ai_skills=True`, silently switching extension/command-reference handling
   to the skills layout. `is_skills_mode` now takes an optional `project_root`;
   Bob preserves an already-installed legacy layout until an explicit upgrade
   creates `.bob/skills/`. A fresh project still defaults to skills.

2. Copilot dual-mode separator: `invoke_separator_for_mode` was inherited
   from the base (mode-independent) and returned Copilot's static `.`, so
   preset/extension command refs in a Copilot skills project rendered
   `/speckit.<name>` instead of `/speckit-<name>`. Override it on Copilot to
   track the persisted `ai_skills` state, consistent with
   `build_command_invocation` and `effective_invoke_separator`.

3. Bob extension-skill command-ref tokens: verified that merging main's
   generic `_resolve_command_ref_tokens` (#3544) resolves Bob's tokens via
   the `CONDITIONAL_SLASH_AGENTS` path (`/speckit-<name>`); added Bob to the
   command-ref regression parametrize plus dedicated Bob use-path tests.

All tests pass (full suite green; merged with current main incl. #3544).

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 63f93544-a77f-4f01-bf04-c88806a97dbf

* fix(bob): resolve command-ref separator with project-aware mode before shared-infra refresh (review #3415)

The `use`/`switch` paths refresh shared infrastructure via
`_with_integration_setting()` / `_invoke_separator_for_integration()`,
which previously resolved the invoke separator through
`effective_invoke_separator` / `is_skills_mode` WITHOUT a project_root.
For a pre-PR Bob 1.x project (.bob/commands/ on disk, no stored options),
this defaulted to the skills "-" separator and rewrote rendered
shared-template command refs to /speckit-*, even though ai_skills stayed
false. Thread project_root through effective_invoke_separator, the two
runtime helpers, and every call site so Bob's on-disk legacy detection
governs the separator before shared infra is refreshed.

Add a rendered-shared-template regression test covering `use --force`.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 63f93544-a77f-4f01-bf04-c88806a97dbf

* fix(bob): scope persisted ai_skills flag to active agent when resolving command-ref separator (review #3415)

`register_commands` runs once per detected agent, but the persisted
`ai_skills` flag describes only the active integration (`opts["ai"]`).
When another agent (e.g. Copilot) is active in skills mode while a
legacy `.bob/commands` layout is also present, the previous code passed
that global `True` to Bob's `invoke_separator_for_mode`, rewriting Bob
1.x command refs to `/speckit-*` instead of `/speckit.*`.

Only consult the persisted flag for the agent it describes
(`opts["ai"] == agent_name`); otherwise resolve the separator from the
agent's own project-aware `effective_invoke_separator(None, project_root)`.

Add regression tests covering the mismatched-active-agent case and a
control for Bob-active skills mode.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 63f93544-a77f-4f01-bf04-c88806a97dbf

* fix(bob): detect Spec Kit layout from managed artifacts, not any skills dir (review #3415)

Two related mis-detections from review 4723246468:

1. `BobIntegration.is_skills_mode` treated the mere presence of a
   `.bob/skills/` directory as proof the project is skills-based. A legacy
   Spec Kit install (managed `.bob/commands/speckit.*.md`) that also carried
   unrelated Bob 2 skills would be misclassified as skills, so
   `integration use bob` persisted `ai_skills` and rewrote shared refs.
   Now the layout is inferred from managed Spec Kit artifacts: legacy/command
   mode only when managed `speckit.*.md` command files exist and no managed
   `speckit-*` skill dirs do.

2. The `register_commands` separator for an inactive agent used a disk-based
   `effective_invoke_separator(None, project_root)` fallback that could pick
   the skills separator even though the registrar writes the static command
   layout (`.bob/commands/*.md`). Inactive agents now resolve the separator
   from the registrar's actual output layout (`extension == "/SKILL.md"`),
   so command-layout files keep `/speckit.*` refs regardless of sibling dirs.

Update the affected hook/E2E tests to use managed artifacts and add
regression tests for the mixed-layout and inactive-registrar scenarios.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 63f93544-a77f-4f01-bf04-c88806a97dbf

* fix(bob): apply managed-artifact detection on upgrade + consistent skill post-processing (review #3415)

Two issues from review 4723782860:

1. `BobIntegration.setup()` resolved the layout via `is_skills_mode(parsed_options)`
   WITHOUT `project_root`, so `integration upgrade bob` on a Bob 1.x install
   (managed `.bob/commands/speckit.*.md`, no stored options) ignored the
   existing command files, generated skills, and stale-deleted the legacy
   commands — silently migrating the project. Pass `project_root` so the same
   managed-artifact detection used by `use` also governs upgrades.

2. Only `_BobSkillsHelper` overrode `post_process_skill_content` to suppress
   the shared slash-command hook note. Preset/extension skill generators call
   that hook on the registered `BobIntegration`, which inherited
   `IntegrationBase`'s note-injecting default. Repeat the no-op (delegating to
   the skills helper) on the registered class so every Bob skill-generation
   path is consistent with intent-activated core Bob skills.

Add regression tests for the upgrade-preservation and post-processing paths.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 63f93544-a77f-4f01-bf04-c88806a97dbf

* feat(bob): add --skills migration opt-in; fix separator + manifest loss (review #3415)

Address review #3415 (4724160183):

- Comment 1: Add an explicit `--skills` opt-in to BobIntegration. It forces
  the skills layout over on-disk auto-detection, giving legacy Bob 1.x
  installs a supported migration path
  (`integration upgrade bob --integration-options="--skills"`). `--skills`
  and `--legacy-commands` are mutually exclusive (clean exit-1 error).

- Comment 2: In CommandRegistrar.register_commands, derive the command-ref
  separator from the output layout (agent_config["extension"]) for the
  active agent too, not the persisted ai_skills flag. A command-layout file
  (.bob/commands/*.md, .github/agents/*.agent.md) always renders /speckit.*;
  only a /SKILL.md scaffold uses /speckit-*. Dual-layout agents (Bob,
  Copilot) write skills via their own setup()/skills path, so
  register_commands only ever emits their command-layout files.

- Comment 3: Update docs/reference/integrations.md Bob entry to document the
  skills-based default (.bob/skills/), the deprecated --legacy-commands
  opt-out, and the --skills migration path.

Also fix a latent manifest-loss bug surfaced by the migration path: the
upgrade Phase 2 stale-file cleanup built a throwaway manifest sharing the
integration key and called uninstall(), which always deleted
{key}.manifest.json. Any layout-shrinking upgrade (e.g. legacy->skills)
thus wiped the freshly-saved manifest, leaving the project untracked and
un-upgradeable. uninstall() now takes remove_manifest (default True); the
stale-cleanup pass passes False.

Adds regression tests for the --skills opt-in, mutual exclusion, corrected
active-agent separator, remove_manifest=False, and an end-to-end
legacy->skills migration that verifies the manifest survives and the
project remains upgradeable. Full suite: 4555 passed, 5 skipped.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 63f93544-a77f-4f01-bf04-c88806a97dbf

* docs(agents): align token-resolution comment with output-layout separator rule (review #3415)

Address review #3415 (4725516805). The comment above resolve_command_refs
still described the removed state-based behavior ("resolve it from the
integration using the project's persisted skills state"). Update it to
describe the output-layout rule that register_commands now uses: _sep is
derived from the layout this registrar writes (a /SKILL.md scaffold uses the
skills separator; a command-layout file uses the command separator), not the
persisted ai_skills state. Comment-only change; no behavior change.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 63f93544-a77f-4f01-bf04-c88806a97dbf

* fix(bob): reconcile extension artifacts on layout change (review #3415)

When a dual-mode agent (Bob) flips between the legacy commands layout and
the skills layout during `integration upgrade` (via `--skills` /
`--legacy-commands`), the old layout's extension command/skill files were
left orphaned: Phase 2 stale cleanup only removes files tracked by the
*integration* manifest, while extension artifacts are tracked in the
extension registry. Detect the layout flip by comparing whether the old vs
new manifest tracks a `/SKILL.md` scaffold, and when it changed, unregister
the agent's extension artifacts before the existing re-registration so they
are recreated in the new layout (and the per-agent registry is updated).

Preset artifacts are documented as a known, pre-existing cross-cutting gap:
no agent-scoped preset re-registration exists in use/switch/upgrade for any
agent, so reconciling them is out of scope for this Bob migration.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 63f93544-a77f-4f01-bf04-c88806a97dbf

* fix(bob): reject layout migration when preset overrides are installed (review #3415)

A command↔skills layout change during `integration upgrade` cannot reconcile
preset artifacts: presets track their command/skill files in per-preset
`registered_commands`/`registered_skills` metadata, and there is no
agent-scoped preset re-registration anywhere in the CLI. Migrating would
delete a preset's old-layout files without recreating them in the new layout
and leave the preset registry claiming artifacts that no longer exist.

Detect the intended layout via `is_skills_mode` (so a plain same-layout
upgrade is unaffected) and, when it flips while preset overrides are
installed for the agent, reject the upgrade *before any mutation* with an
actionable error pointing at the remove → upgrade → reinstall workaround.
Extension artifacts are still reconciled for the safe (no-preset) case.

Adds a regression test and documents the migration caveat in the Bob
integration reference entry.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 63f93544-a77f-4f01-bf04-c88806a97dbf

* fix(bob): restrict layout reconciliation to the active integration (review #3415)

`integration_upgrade` supports upgrading a secondary (non-active) integration,
but the layout-change extension reconciliation was unsafe there.
`ExtensionManager.unregister_agent_artifacts()` treats the unscoped
per-extension `registered_skills` list as belonging to the passed agent and,
when that agent's skills directory is absent, falls back to scanning every
agent's skills directory — so reconciling a secondary Bob layout flip could
delete or untrack the *active* agent's extension skills. The subsequent
re-registration cannot repair that because extension skill rendering is
intentionally scoped to the active agent (#2948).

Gate the unregister-before-register reconciliation on `installed_key == key`
so it only runs for the active integration. Secondary agents only ever have
extension command files (skills are active-agent-only), which the existing
re-registration rewrites in place, so skipping the unregister orphans nothing
new. Adds a regression test asserting a secondary Bob layout change leaves the
active agent's extension skill intact on disk and in the registry.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 63f93544-a77f-4f01-bf04-c88806a97dbf

* fix(bob): fail closed when preset registry is unreadable (review #3415)

Address review 4744636079:

- _migrate_commands: the preset guard previously failed *open* — a
  registry read/parse error returned an empty "no presets" list, so a
  --force layout-changing upgrade could delete preset-overridden command
  files while their registry state was unknown. Read the registry file
  directly and raise _PresetRegistryUnreadableError on any read/parse
  failure or malformed structure, rejecting the migration before any
  mutation. A genuinely absent registry still returns [] (safe).

- bob: correct the is_skills_mode docstring — upgrade *does* run setup();
  disk detection is needed because legacy Bob 1.x installs never persisted
  a legacy_commands option, so the stored mode is unavailable.

- tests: add fail-closed E2E (corrupted registry rejected, valid-empty
  allowed) plus a unit test for _installed_presets_affecting_agent covering
  absent / corrupted / malformed / valid / affecting-agent cases.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 63f93544-a77f-4f01-bf04-c88806a97dbf

* fix(bob): fail closed on malformed preset entries too (review #3415)

Address review 4745191015: the preset guard read a parseable registry but
silently skipped malformed per-preset metadata and treated a malformed
registered_commands value as "no matching artifacts". A registry such as
{"presets":{"p1":[]}} therefore allowed a layout migration even though p1's
ownership is unknown, risking deletion of preset-managed files. Now raise
_PresetRegistryUnreadableError for a non-dict preset entry, a non-dict
registered_commands, or a non-list registered_skills. Extend the unit test
to cover these malformed shapes.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 63f93544-a77f-4f01-bf04-c88806a97dbf

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-07-21 09:20:20 -05:00
github-actions[bot]
7f97f1f1f8 Update OKF Knowledge Bundle Generator to v0.3.0 (#3608)
Update okf extension submitted by @alexcpn:
- extensions/catalog.community.json (version, download_url, description, provides.commands, updated_at)
- docs/community/extensions.md community extensions table

Closes #3602

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-21 09:11:30 -05:00
github-actions[bot]
3b611575b2 Add Test Coverage Drift Control extension to community catalog (#3607)
Add test-coverage-drift-control extension submitted by @benizzio to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #3600

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-21 09:01:01 -05:00
Pascal THUET
ec45dbd791 chore: align ruff lint scope (#3139)
Assisted-by: Codex (model: GPT-5, autonomous)
2026-07-21 08:37:27 -05:00
Markus Wondrak
d6fa0460ed feat(workflows): WorkflowResolver standalone (PR 1) (#3557)
* feat(workflows): add standalone WorkflowResolver and overlay subsystem

Implement PR 1 of the workflow-overlays plan: a concrete, standalone
WorkflowResolver for downstream workflow extensibility without touching the
Preset subsystem.

- Add overlay manifest schema (Overlay, OverlayEdit, validate_overlay_yaml)
- Add pure-function merge engine (find_step, apply_edit, merge_steps,
  validate_edits) with recursive anchor search and higher-wins semantics
- Add StepListComposer and tiered layer sources (project, installed, base)
- Add WorkflowResolver facade with inline HIGHER_WINS priority sorting
- Add CLI verbs: workflow overlay add/set-priority/enable/disable/remove/list
  and workflow resolve <id>
- Wire WorkflowEngine.load_workflow through WorkflowResolver
- Extend workflow add to copy optional overlays/ subdirectory from local
  workflow directories
- Add comprehensive unit, integration, and security tests

Refs: discussion #3473 (https://github.com/github/spec-kit/discussions/3473)

Assisted-by: Kimi (model: opencode-go/kimi-k2.7-code, autonomous)

* fix(workflows): reject symlinked overlay directories in layer sources

Address PR #3557 review comments r3594064534 and r3594064563:

- ProjectOverlaySource.collect now rejects symlinked per-workflow overlay
  directories (.specify/workflows/overlays/<id>) before iterating
- InstalledOverlaySource.collect now rejects symlinked installed overlay
  directories (.specify/workflows/<id>/overlays) before iterating
- workflow_overlay_list catches ValueError from resolver and exits with
  code 1 instead of crashing on unhandled exceptions
- Added .specify/workflows/overlays to _reject_unsafe_workflow_storage
  chokepoint for defense-in-depth

These guards prevent symlinked overlay directories from redirecting
auto-loaded overlay YAML to attacker-controlled content outside the
project, which could inject executable shell steps into trusted workflows.

Refs: PR #3557 review comments r3594064534, r3594064563

Assisted-by: opencode-go/qwen3.7-max (autonomous)

* fix(workflows): address Copilot review findings in merge engine

- Apply inserts before winning replace to prevent anchor-not-found errors
  when replace changes step ID (r3594064604)
- Track attribution recursively for nested steps in composite inserts/replaces
  so workflow resolve attributes all child steps correctly (r3594064638)
- Add regression tests for both fixes

Refs: PR #3557 review discussion

Assisted-by: GitHub Copilot (model: qwen3.7-plus, autonomous)

* refactor(workflows): simplify overlay architecture to 2-tier

Remove installed overlays tier to enforce clean separation of concerns:
- workflow add installs workflows only (no overlay copying)
- workflow overlay add installs overlays only (project-local)

Changes:
- Remove InstalledOverlaySource class and all references
- Remove overlay-copying logic from _validate_and_install_local()
- Update WorkflowResolver to 2-tier: project overlays + base workflow
- Fix --priority override timing: apply before validation, not after
- Remove tests for installed overlays (no longer applicable)

Rationale: If upstream controls both base workflow and shipped overlays,
and both get overwritten on bundle update, there's no reason to ship
overlays separately. Overlays only make sense when someone other than
the base author adds them.

Resolves all three review findings from PR #3557:
- r3594064677: workflow add no longer copies overlays from all call sites
- r3594064705: --priority override now applied before validation
- r3594064726: no stale installed overlays (tier removed entirely)

Assisted-by: Claude (model: claude-opus-4-7, autonomous)

* fix(workflows): harden overlay symlink handling

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

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

* docs(workflows): remove stale installed-overlay references from workflows.md

The 2-tier refactor (cc28185) removed the installed-overlay tier entirely,
but docs/reference/workflows.md was not updated. This commit addresses all
four Cluster 2 findings from the PR review:

- workflow add: remove sentence about copying overlays/ subdirectory
- How Overlays Work: drop installed-overlay table row and precedence prose;
  rewrite to 2-tier model (project overlays only, source-order tie-break)
- overlay remove: drop trailing sentence about installed overlays
- Interaction with Bundles: rewrite to say workflow add installs only
  workflow.yml; remove installed-overlay discovery language

Fixes: r3596368791, r3596368831, r3596368873, r3596368919

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

* fix(overlays): detect ancestor-conflict anchors in merge_steps

When two overlay edits target anchors that share a parent/descendant
relationship (e.g. remove an if-step + insert_after a nested child),
merge_steps processed them independently and in dict-insertion order,
making the outcome non-deterministic.

Add two private helpers to merge.py:
- _descendant_ids(step): returns all step IDs nested inside a step dict
  by delegating to the existing _all_base_step_ids helper on children.
- _check_anchor_conflicts(anchors, base_steps): for each targeted anchor
  finds its descendants and checks whether any other targeted anchor is
  among them; returns human-readable error strings.

Wire _check_anchor_conflicts into merge_steps immediately after
edits_by_anchor is built, before any tree mutation occurs. Raises
ValueError listing the conflicting anchor pair(s) so overlay authors
know exactly what to fix.

Add TestMergeStepsAncestorConflicts (6 cases):
- remove parent + insert_after child raises ValueError
- replace parent + remove child raises ValueError
- conflict across multiple overlays raises ValueError
- sibling anchors (not ancestor/descendant) pass
- single anchor passes
- parent targeted but child not targeted passes

Closes review comment r3596368746 (PR #3557, round 2, cluster 3).

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

* fix(overlays): fix over-broad conflict detection and non-deterministic ID collision

Finding 1.1 — _check_anchor_conflicts was rejecting any ancestor/descendant
anchor pair, including insert-only edits that are perfectly safe. Only
replace/remove on an ancestor can destroy its subtree and make a descendant
anchor unresolvable. Change the signature to accept a dict[str, str]
(anchor → winning operation) and skip the check for insert_after/insert_before.

Finding 1.2 — merge_steps was calling find_step on the already-mutated tree,
so a replacement step that reused a base step ID could be accidentally targeted
by a later edit group (non-deterministic result depending on dict iteration
order). Replace the anchor-group loop with a single-pass _traverse_and_apply
that walks the original tree structure and applies edits as each step is
encountered. Anchors are never re-looked up in a mutated tree.

Design invariant enforced: overlays always apply to the original base tree and
cannot target steps introduced by other overlays. Non-remove edits on non-base
anchors now raise ValueError early.

Also removes apply_edit (no production callers, only tested in isolation) and
its test class — the new traversal inlines the same mechanics without the
find_step round-trip.

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

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

* fix(overlays): reject ID trailing newlines and reuse existing .yaml path

Fix two input validation bugs in the overlay layer (Group 2 of copilot
review PR #3557):

1. _validate_safe_id in schema.py used re.match() which anchors only at
   the start of the string, so IDs like 'overlay\n' passed validation
   and could produce newline-containing file paths. Changed to fullmatch()
   so the entire string must satisfy the pattern.

2. workflow_overlay_add always wrote <id>.yml without checking whether
   <id>.yaml already existed. Since the resolver loads both extensions,
   this created two active layers whose edits applied twice. Now uses
   the existing _find_overlay_file() to detect a pre-existing file and
   reuse its path, falling back to .yml only for new overlays.

Tests added for both fixes.

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

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

* fix(overlays): fix display order inversion and wrap file-read errors

Finding group 3 from copilot-review-v2.md:

3.1 — Precedence display inverted (overlays/__init__.py)
collect_all_layers used a single-pass sort by (-priority, source_asc),
which placed the *losing* equal-priority source first in the display
while claiming "highest first". Fix: two-pass stable sort — source
descending then priority descending — so the actual winner (last applied
by the composer) rises to the top of the display.

3.2 — Unwrapped file-read errors (overlays/layer_sources.py)
Only yaml.YAMLError was caught around path.read_text(), so an
unreadable or non-UTF-8 overlay produced a raw traceback. Fix: widen
the except clause to (yaml.YAMLError, OSError, UnicodeDecodeError),
matching the pattern used throughout catalog.py.

Tests:
- test_workflow_resolve_equal_priority_winner_shown_first: verifies
  project:zzz (the winner) appears before project:aaa in workflow resolve
  output when both overlays share the same priority.
- tests/workflows/test_overlay_layer_sources.py (new): OSError and
  non-UTF-8 bytes both produce OverlayLoadError, not raw tracebacks.

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

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

* fix: rename misleading overlay test

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

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

* fix: remove EOF blank line in overlay resolver

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

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

* Potential fix for pull request finding

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

* fix: handle overlay read and enumeration errors

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

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

* fix(overlays): validate resolver workflow IDs

Reject unsafe and reserved workflow IDs before overlay or base sources construct paths, preventing traversal through resolver and engine fallback paths.

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

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

* Potential fix for pull request finding

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

* fix(overlays): drop _remove_sources_recursively from remove branch

In _traverse_and_apply, the remove branch called _remove_sources_recursively
to clean up attribution entries for the deleted step.  This was inherited from
the old apply_edit loop (c70a5d6) where it was needed because the sources dict
was queried exhaustively.

In the current single-pass design, _build_attribution only traverses the result
list, so stale sources entries for removed steps are never read.  The cleanup
call is therefore unnecessary — and actively harmful when another overlay has
replaced a different step with a new step that reuses the same ID: the pop
clobbers the replacement's attribution entry, causing workflow resolve to report
the surviving step as 'unknown'.

Fix: simply remove the _remove_sources_recursively call from the remove branch.
Add an attribution assertion to the existing reused-ID regression test to catch
this case.

Fixes: r3604242050 (Copilot review finding)

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

* Fix CLI overlay ID validation anchoring

Use fullmatch for CLI workflow/overlay ID validation so trailing newlines are rejected consistently with manifest validation.

Add regression coverage for newline-suffixed workflow and overlay IDs in overlay set-priority.

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

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

* fix(workflows): validate workflow_id in layer sources before path construction

ProjectOverlaySource.collect() and BaseWorkflowSource.collect() joined
workflow_id directly onto storage paths without validation, enabling
path traversal (e.g. '../../outside') when called outside the
WorkflowResolver.

Add _validate_workflow_id() to layer_sources.py — mirrors the same
_SAFE_ID_PATTERN / _RESERVED_WORKFLOW_IDS check used by WorkflowResolver
in overlays/__init__.py — and call it at the top of both collect()
methods before any path is constructed.

Adds parametrised tests covering unsafe IDs and verifying no filesystem
access occurs for an invalid ID.

Closes review finding r3604772700.

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

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

* fix(workflows): align layer source validation with _safe_workflow_id_dir

Plan §4.1 requires that Workflow-ID-Validierung, Symlink-/Containment-
Prüfungen and Fehlerübersetzung must not diverge between workflow
management and the overlay resolver.

My previous fix added ID pattern + reserved-name validation to both
collect() methods but was missing the containment step and the
BaseWorkflowSource directory/file checks that _safe_workflow_id_dir
performs.

Changes:
- Add _ensure_contained_dir(path, root) to layer_sources.py — pure
  domain mirror of overlays/_commands.py::_ensure_contained_dir that
  raises OverlayLoadError instead of typer.Exit
- ProjectOverlaySource.collect(): replace two inline symlink/dir checks
  with _ensure_contained_dir(workflow_overlay_dir, self.overlays_dir),
  adding the missing resolve().relative_to() containment step
- BaseWorkflowSource.collect(): add _ensure_contained_dir on the
  workflow directory, and add workflow.yml symlink check before is_file()

The same logic now lives in three places (workflow CLI, overlay CLI,
layer sources). The DRY extraction to workflows/_validation.py is
deferred to PR 3 per plan §4.1.

Tests: add containment and symlink tests for both sources.

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

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

* Potential fix for pull request finding

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

* fix(overlays): resolve identity from manifest field, not filename

Align overlay identity resolution with the project-wide convention:
presets use preset.id, extensions use extension.id, workflows use
workflow.id, and workflow steps use step.type_key. Overlays must
derive identity from the manifest id field, not the filename.

Rewrite _find_overlay_file() to scan all YAML files in the overlay
directory and match on the manifest id field, fixing the bug where
enable/disable/remove/set-priority failed when filename != manifest id.

Closes: PR #3557 discussion r3605010197

Assisted-by: opencode-go/qwen3.7-max (autonomous)

* Potential fix for pull request finding

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

* fix(workflows): make validation behavior consistent across YAML loading paths

Address PR #3557 review finding r3607632921:

- Wrap yaml.YAMLError → ValueError in from_yaml() and from_string() so
  malformed YAML matches the documented exception contract
- Add except ValueError to workflow_info to handle composition errors
  cleanly instead of crashing with a raw traceback
- Remove validate_workflow() from compose() so the resolver path is
  parse-only like all other YAML loading mechanisms; callers validate
  explicitly via engine.validate()
- Update test to reflect new behavior: resolve() returns composed
  definition, caller validates separately

Assisted-by: opencode-go/qwen3.7-max (autonomous)

* fix(overlays): list disabled overlays in management view

Keep disabled overlays visible in workflow overlay list while leaving resolution behavior unchanged.

- add an include_disabled opt-in to overlay source/resolver collection
- use include_disabled=True for workflow overlay list
- add regression tests for list visibility and default filtering

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

* Potential fix for pull request finding

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

* docs: align overlay extends and resolver contract

Assisted-by: GitHub Copilot (model: GPT-5.3-Codex, autonomous)

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

* fix: use atomic write for overlay file updates to prevent hard-link attack

Replace in-place write_text() calls in workflow_overlay_add() and
_update_overlay_field() with the same mkstemp → write → os.replace()
pattern used by the workflow installer (_stage_workflow_file /
_commit_workflow_file / _discard_staged_workflow_file).

The prior code rejected symlinks and validated path containment, but a
hard-linked destination file passes both checks while sharing an inode
with an external file. write_text() would then truncate and overwrite
that external inode. The atomic staging approach never opens the
existing destination for writing, eliminating the hard-link vector.

Fixes findings r3608669512 and r3608669517 on PR #3557.

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

* fix(composer): preserve invalid base definition instead of coercing steps to []

When 'steps' is not a list, returning early with the unmodified
WorkflowDefinition lets validate_workflow surface the proper error
("'steps' must be a list.") to the caller. The previous silent
coercion to [] masked the validation error entirely.

Fixes: https://github.com/github/spec-kit/pull/3557#discussion_r3608669506

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

* fix: align workflow overlay priority semantics

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

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

* fix: validate overlay priority presentation

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

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

* fix: catch OverflowError in normalize_priority for float infinity values

YAML values like `priority: .inf` parse to float('inf'), causing
int() to raise OverflowError. This broke validate_overlay_yaml()'s
'validation never raises' contract. Adding OverflowError to the
except clause makes it fall back to the default priority (10),
consistent with other invalid value handling.

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

---------

Co-authored-by: Markus <markus@example.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-21 08:35:25 -05:00
Noor ul ain
8a5bcc21a5 fix(extensions,presets): surface clean error on malformed download URL (#3577)
* fix(extensions,presets): surface clean error on malformed download URL

`ExtensionCatalog.download_extension` and `PresetCatalog.download_pack` read
`download_url` from catalog payload data and pass it to `urlparse(...).hostname`
during the HTTPS validation. A malformed authority (e.g. an unterminated IPv6
bracket like `https://[::1`) makes urlparse/hostname raise a raw `ValueError`,
which escapes past the command handlers — they only catch `ExtensionError` /
`PresetError` — and surfaces as an uncaught traceback.

Guard the parse in a try/except and re-raise as the domain error so the CLI
reports a clean "download URL is malformed" message. Mirrors the same fix in
catalogs (#3435) and workflows/catalog.py (#3484).

Adds regression coverage for both catalogs.

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

* fix(presets): escape markup in preset_add error handlers

Copilot review on #3577 flagged that the malformed-URL fix stopped short:
`download_pack` now raises a clean `PresetError`, but the `preset_add`
handler rendered `{e}` unescaped. A catalog `download_url` like
`https://[not-an-ip]/x` is embedded verbatim in the message, so Rich
interprets `[not-an-ip]` as a markup tag and can raise a style/markup
exception while rendering the error — the CLI still crashes instead of
exiting cleanly.

Escape `str(e)` in the preset command handlers, matching the extension
handler at `extensions/_commands.py:657`, and hoist the `rich.markup`
import to module scope (dropping the two inline imports). Adds CLI-level
regression tests: a bracketed-host `download_url` exits cleanly, and the
compatibility/validation/error handlers escape markup-bearing messages.
Both tests fail on the pre-fix handler (test-the-test verified).

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-21 08:33:45 -05:00
Manfred Riem
75d37389c8 chore: release 0.13.1, begin 0.13.2.dev0 development (#3610)
* chore: bump version to 0.13.1

* chore: begin 0.13.2.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-21 08:30:10 -05:00
Andrew Chen
6d77b4a099 fix(integrations): catch OverflowError on a priority: .inf in add/remove (#3589)
IntegrationCatalog.add_catalog and remove_catalog re-validate the
existing catalog entries' priorities inline, separately from the base
loader. Both did `int(raw_priority)` under `except (TypeError,
ValueError)`, so a `priority: .inf` (float('inf')) raised OverflowError:
add_catalog leaked a raw traceback instead of IntegrationValidationError,
and remove_catalog crashed while building the display order.

Add OverflowError to both handlers, matching the base loader (#3525) and
the workflow/step loaders (#3526). add_catalog now raises
IntegrationValidationError; remove_catalog falls back to positional order
like the other non-integer priorities.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 08:28:27 -05:00
Ali jawwad
57cc518d63 fix(workflows): reject bool / .inf catalog priority in workflow & step catalog loaders (#3526)
* fix(workflows): reject bool/.inf catalog priority in workflow & step catalog loaders

The WorkflowRegistry and StepRegistry catalog-config loaders coerced priority
with int() inside except (TypeError, ValueError), missing two guards the base
CatalogStackBase loader already has:
- bool is an int subclass, so 'priority: true' was silently coerced to 1;
- int(float('inf')) raises OverflowError (not caught), so 'priority: .inf'
  crashed with an uncaught traceback.
Add the explicit bool check and OverflowError to both loaders, and add
OverflowError to the two _coerce_priority helpers used by 'catalog add' (they
return 0 on an uncoercible existing priority instead of crashing).

Parametrized tests on both TestWorkflowCatalog and TestStepCatalog reject
priority true/false/.inf (fail before: bool coerced to 1 / inf OverflowError).

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

* test(workflows): cover add_catalog() OverflowError fallback for existing priority: .inf

The workflow/step catalog priority guards added OverflowError to _coerce_priority
(the 'catalog add' fallback), but the tests only exercised get_active_catalogs().
Add tests that prewrite an existing 'priority: .inf' entry and call add_catalog()
for both WorkflowCatalog and StepCatalog, asserting the command succeeds and the
new entry gets a valid priority (inf coerced to 0, +1). Fails before: int(inf)
OverflowError crashed add_catalog.

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-17 15:10:41 -05:00
Ali jawwad
eb2252a1cb fix(catalogs): 'priority: .inf' yields a clean validation error instead of crashing (#3525)
* fix(catalogs): priority: .inf yields a clean validation error, not OverflowError

_load_catalog_config coerces a catalog entry's priority with int() inside
except (TypeError, ValueError). int(float('inf')) raises OverflowError, which is
not in that tuple, so a YAML 'priority: .inf' escaped as an uncaught traceback
instead of the intended 'expected integer' validation error (the bool-is-int
case is already guarded just above). Add OverflowError to the except tuple.

Test mirrors the existing rejects_boolean_priority test with priority: .inf
(fails before: OverflowError; passes after: ValidationError naming the config).

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

* fix(presets): priority: .inf in a preset catalog config yields a clean error

The PresetCatalog._load_catalog_config priority parser has its own loader
(separate from CatalogStackBase) that caught only TypeError/ValueError, so a
YAML 'priority: .inf' escaped as an uncaught OverflowError from int(float('inf')).
Add OverflowError to the except tuple (the bool-is-int case is already guarded
just above), matching catalogs.py.

Test mirrors rejects_boolean_priority with priority: .inf (fails before:
OverflowError; passes after: PresetValidationError).

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-17 15:10:05 -05:00
Ali jawwad
2df0394cb2 docs(integrations): document the 'integration list --catalog' flag (#3530)
* docs(integrations): document the 'integration list --catalog' flag

'specify integration list' accepts a --catalog flag (integrations/_query_commands.py:
typer.Option(False, "--catalog", ...)) that browses the full built-in +
community catalog, but the Integrations reference documented no options for the
list command. Add an option table for it, matching the style used by the sibling
'integration search' and 'integration catalog add' sections.

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

* docs(integrations): clarify that default 'integration list' shows only built-ins

The --catalog row implied the default list already includes the full installed
set; in fact 'integration list' iterates INTEGRATION_REGISTRY (built-ins) and
marks installed status, so a community integration that is not built in only
appears with --catalog. Reword the option and the intro sentence to say the
default shows the built-in integrations and --catalog adds community ones.

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-17 15:09:28 -05:00
Noor ul ain
3d2901eb75 fix(workflows): fail fan-in loudly on a non-string wait_for entry (#3579)
`FanInStep.execute` already guards a non-list `wait_for` (#3482), and the
engine's load-time validation rejects non-string entries. But the engine does
not auto-validate step config, so on an unvalidated run `execute` iterated the
list's *elements* raw:

- An unhashable entry (a list/dict from a YAML indentation slip like
  `wait_for: [[a, b]]`) crashed the whole run at `context.steps.get(entry, ...)`
  with a raw `TypeError: cannot use 'list' as a dict key`.
- A hashable-but-non-string entry (`wait_for: [123]`) silently joined an empty
  `{}` and still reported COMPLETED — the exact "silent empty result +
  COMPLETED" wiring bug the whole-list guard and the engine's fan-in
  validation both exist to prevent.

Extend the execute() guard to reject any non-string entry with the engine's
"entries must be step-id strings" phrasing, mirroring the sibling non-list
guard right above it. Adds regression coverage for unhashable and
hashable-non-string entries.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:08:43 -05:00
Noor ul ain
c1e5cfa0aa fix(workflows): fail fan-out loudly on a truthy non-mapping step template (#3537)
* fix(workflows): fail fan-out loudly on a truthy non-mapping step template

A fan-out step whose `step:` is a truthy scalar or list (an authoring mistake) passed execute and reached the engine, which calls template.get("id", ...) in _run_fan_out — raising AttributeError and taking down the whole run. validate already rejects a non-mapping step, but the engine does not auto-validate, so an unvalidated run crashed.

Guard execute to FAIL the step (with a clear error and normalized empty output) instead, mirroring the existing non-list items guard and the switch non-dict cases guard. Add the matching test_execute_non_dict_step_fails_loudly covering the execute-path guard (validate was already covered).

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

* fix(workflows): reject explicit fan-out `step: null` in validate()

The runtime guard in execute() rejects a truthy non-mapping step, but
`config.get("step", {})` only substitutes the `{}` default for an *absent*
key — an explicit `step: null` reaches the guard as None and FAILS the step.
validate() previously exempted None (`step is not None and ...`), so such a
workflow passed validation and then failed during execution.

Align validate() with the runtime guard: a present-but-non-mapping `step`
(including `None`) is an authoring mistake and is now rejected up front.
Extend the validate and execute regression cases to cover None.

Addresses Copilot review feedback on #3537.

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-17 15:03:13 -05:00
Noor ul ain
b139bd0393 fix(workflows): reject a non-string prompt in prompt-step validate() (#3582)
`PromptStep.execute` str()-coerces `config['prompt']` and dispatches the
result to the integration CLI as the model's instructions. But its `validate`
only checked that `prompt` was *present*, not that it was a string — the exact
parity gap the sibling `ShellStep` closes for `run`.

So a YAML authoring slip like `prompt: [review, this]` or `prompt:` (null)
passed validation, then `execute` sent the Python repr (`"['review', 'this']"`,
`"None"`) to the LLM verbatim — silently wrong instructions with no error and a
COMPLETED status. The engine does not auto-validate step config
(`load_workflow` explicitly defers validation), so validation is the only place
this surfaces before dispatch.

Extend `validate` to reject any non-string `prompt` with the shell-step's
phrasing ("'prompt' must be a string, got <type>"), mirroring the shell `run`
and command `input`/`options` type checks. A `{{ ... }}` expression is still a
str, so it stays valid. Adds regression coverage for non-string prompts
(null/list/int/dict) and confirms an expression prompt still validates.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:01:19 -05:00
Ali jawwad
f75f5f836b fix(workflows): route 'workflow status --json' errors to stderr (#3520)
* fix(workflows): route 'workflow status --json' errors to stderr

The workflow_status run_id error paths (FileNotFoundError -> 'Run not found',
ValueError -> invalid run) used the stdout console and fired before the
json_output branch, so 'specify workflow status <bad-id> --json' wrote a
Rich-rendered error to stdout and corrupted the JSON stream a consumer would
json.loads(). Route both through _error_console(json_output) so they go to
stderr under --json, matching the sibling 'workflow run'/'workflow resume'
commands (which use the identical RunState.load try/except) and the documented
stdout-purity contract.

Test asserts the not-found error appears on stderr and stdout stays empty under
--json (fails before: the error was on stdout).

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

* test(workflows): cover the ValueError handler in workflow status --json purity

The stderr-routing fix reroutes both the FileNotFoundError and ValueError run_id
handlers, but the test only exercised FileNotFoundError — a regression of the
ValueError path back to stdout would have gone uncaught. Add a ValueError case
(RunState.load raising) asserting the same stderr-only / empty-stdout behavior.

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-17 14:16:19 -05:00
Ali jawwad
c864fc7447 fix(integrations): Forge dispatches hyphenated /speckit-<cmd> invocations (#3529)
Forge installs its slash-commands with hyphenated names (speckit-foo-bar, via
format_forge_command_name and the injected frontmatter name), but
ForgeIntegration inherited MarkdownIntegration.build_command_invocation, which
builds the dotted /speckit.<cmd>. So 'workflow'/command dispatch invoked
/speckit.plan while the registered command is /speckit-plan — a name Forge never
registered.

Override build_command_invocation to reuse format_forge_command_name, producing
/speckit-<name> (with '.'-to-'-' for extension commands), mirroring the skills
agents' hyphenated invocation.

Tests assert Forge 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-17 14:12:36 -05:00
Manfred Riem
848e41bc92 chore: release 0.13.0, begin 0.13.1.dev0 development (#3588)
* chore: bump version to 0.13.0

* chore: begin 0.13.1.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-17 14:06:08 -05:00
127 changed files with 17439 additions and 473 deletions

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

@@ -1,10 +1,30 @@
{
"entries": {
"actions/checkout@v6.0.3": {
"repo": "actions/checkout",
"version": "v6.0.3",
"sha": "df4cb1c069e1874edd31b4311f1884172cec0e10"
},
"actions/download-artifact@v8.0.1": {
"repo": "actions/download-artifact",
"version": "v8.0.1",
"sha": "3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c"
},
"actions/github-script@v9.0.0": {
"repo": "actions/github-script",
"version": "v9.0.0",
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
},
"actions/setup-node@v6.4.0": {
"repo": "actions/setup-node",
"version": "v6.4.0",
"sha": "48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e"
},
"actions/upload-artifact@v7.0.1": {
"repo": "actions/upload-artifact",
"version": "v7.0.1",
"sha": "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"
},
"github/gh-aw-actions/setup@v0.79.8": {
"repo": "github/gh-aw-actions/setup",
"version": "v0.79.8",

View File

@@ -0,0 +1,115 @@
"""Check that committed security audit requirements are up to date."""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
COMMITTED_REQUIREMENTS = REPO_ROOT / ".github" / "security-audit-requirements.txt"
DEPENDENCY_INPUTS = ("pyproject.toml", ".github/security-audit-requirements.txt")
def _dependency_diff_refs() -> tuple[str, str]:
base_ref = os.environ.get("DEPENDENCY_DIFF_BASE", "").strip()
head_ref = os.environ.get("DEPENDENCY_DIFF_HEAD", "").strip() or "HEAD"
if base_ref and not set(base_ref) <= {"0"}:
return base_ref, head_ref
# Fallback when no usable base is supplied (push with an all-zero
# ``github.event.before``, manual dispatch, etc.). ``HEAD^`` fails on a
# shallow checkout or a single-commit repo; that ``git diff`` error is
# caught by the caller and deliberately treated as "inputs changed" so the
# audit runs anyway — failing safe (audit) rather than skipping silently.
return "HEAD^", "HEAD"
def _dependency_inputs_changed() -> bool:
base_ref, head_ref = _dependency_diff_refs()
try:
result = subprocess.run(
[
"git",
"diff",
"--name-only",
base_ref,
head_ref,
"--",
*DEPENDENCY_INPUTS,
],
check=True,
cwd=REPO_ROOT,
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
text=True,
)
except subprocess.CalledProcessError as exc:
print(
"Could not determine changed dependency inputs; checking requirements.",
file=sys.stderr,
)
if exc.stderr:
print(exc.stderr.strip(), file=sys.stderr)
return True
changed_inputs = [line for line in result.stdout.splitlines() if line]
if not changed_inputs:
print("Dependency audit inputs unchanged; sync check skipped.")
return False
print(f"Dependency audit inputs changed: {', '.join(changed_inputs)}")
return True
def main() -> int:
if not _dependency_inputs_changed():
return 0
generated_requirements_env = os.environ.get("GENERATED_REQUIREMENTS", "").strip()
if not generated_requirements_env:
print(
"GENERATED_REQUIREMENTS must be set to the temporary output file path.",
file=sys.stderr,
)
return 1
generated_requirements = Path(generated_requirements_env)
generated_requirements.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(
[
"uv",
"pip",
"compile",
"pyproject.toml",
"--extra",
"test",
"--universal",
"--upgrade",
"--generate-hashes",
"--quiet",
"--no-header",
"--output-file",
str(generated_requirements),
],
check=True,
cwd=REPO_ROOT,
)
committed = COMMITTED_REQUIREMENTS.read_text(encoding="utf-8")
generated = generated_requirements.read_text(encoding="utf-8")
if committed == generated:
return 0
print(
"Regenerate .github/security-audit-requirements.txt with the documented "
"uv pip compile command.",
file=sys.stderr,
)
return 1
if __name__ == "__main__":
raise SystemExit(main())

253
.github/security-audit-requirements.txt vendored Normal file
View File

@@ -0,0 +1,253 @@
annotated-doc==0.0.4 \
--hash=sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320 \
--hash=sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4
# via typer
click==8.4.2 \
--hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \
--hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76
# via specify-cli (pyproject.toml)
colorama==0.4.6 ; sys_platform == 'win32' \
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
# via
# click
# pytest
# typer
coverage==7.15.2 \
--hash=sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2 \
--hash=sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e \
--hash=sha256:094dd37f3ef7b2da8b068b583d1f4c40f91c65197e16c52a71962d5d537fc5db \
--hash=sha256:09f5c6ec5901f667bd97dd140b5b9a2586b10efec66f46fb1e6d8135f8b95bdf \
--hash=sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c \
--hash=sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8 \
--hash=sha256:1268ac8fb9ddcd783d3948dbabaf80a5d53bfdaa0575e873e2139a692f797443 \
--hash=sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a \
--hash=sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145 \
--hash=sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9 \
--hash=sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2 \
--hash=sha256:1d16e3a7104ea84f03e614611b3edbf6fb6892554b3ab0fe7fbb3f2b2ef04376 \
--hash=sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138 \
--hash=sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578 \
--hash=sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c \
--hash=sha256:29c052f7c83ccfcc5c577eaae025d2e4a9bb80daf03c0ac31c996e83b000ce88 \
--hash=sha256:2f1ec6f304b156669cfde653b4e9a953f5de87e247ea02ac599bce0ab2744036 \
--hash=sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c \
--hash=sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071 \
--hash=sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a \
--hash=sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d \
--hash=sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b \
--hash=sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a \
--hash=sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b \
--hash=sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050 \
--hash=sha256:44826758cfe73fcd0e6af5deb4ba6d5417cc1d13df3acb35c93484a11160f846 \
--hash=sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d \
--hash=sha256:48ccc6395958eda89093ecdc35644c86f23a8b23a7f4d44958812b721aad67c1 \
--hash=sha256:4d3361879d736f469f45723c11ea1a5bbdaf1f6928f0e632c940378b5aa9b660 \
--hash=sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40 \
--hash=sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026 \
--hash=sha256:67d7602480a47bdf5b675635403625553ebaa70d5a62a657c035149fd401cea0 \
--hash=sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b \
--hash=sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0 \
--hash=sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa \
--hash=sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d \
--hash=sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658 \
--hash=sha256:728a33676d4c3f0db977990a4bd421dcaa3be3e53b5b6273036fff6666008e89 \
--hash=sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072 \
--hash=sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199 \
--hash=sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446 \
--hash=sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743 \
--hash=sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7 \
--hash=sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1 \
--hash=sha256:7e8f27131dc7cd53de2c137dd207b3720919320b3c20d499dc30aa9ee6173287 \
--hash=sha256:81f382c5a94b434ec1f6da607edb904c76d7212e618cd4d1bc9f97bed4120ef5 \
--hash=sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be \
--hash=sha256:8bb9f4b4279187560796a4cdaca3b0a93dd97e48ee667df005f4ed9a97403688 \
--hash=sha256:8c726b232659cbd2ae57ade46509eb068c9bd7a06df9fcbff6fe484870006934 \
--hash=sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7 \
--hash=sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440 \
--hash=sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984 \
--hash=sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc \
--hash=sha256:9b5bd92ff1ec22e535eab0de75fa6db021992791f461a2aceb7822c625a1187d \
--hash=sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098 \
--hash=sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6 \
--hash=sha256:9f4432898c4bf2fba0435bbe35dd4437d7264565e5a88a21f5b49d8662a6b629 \
--hash=sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b \
--hash=sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee \
--hash=sha256:a29ec5305a7335aacee2d799e3422e91e1c8a12474986e2b3b07e315c91be82f \
--hash=sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1 \
--hash=sha256:a4c46b247b5d4b78f613bd89fea926d32b25c6cc61a50bd1e99ba310348f3dad \
--hash=sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3 \
--hash=sha256:a63b9e190711134d581c4d703df5df09851b1acf99792c7aacbbe9f41f0283c9 \
--hash=sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3 \
--hash=sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a \
--hash=sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296 \
--hash=sha256:affd532502d34c0472d0cdb181325c89f1d2c44992fef0c17e88e7b1576259a1 \
--hash=sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd \
--hash=sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73 \
--hash=sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f \
--hash=sha256:bbc808daf4f5cd567af8075ecc72d21c6dfef9a254709a621a84c217c935ebc0 \
--hash=sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6 \
--hash=sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5 \
--hash=sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1 \
--hash=sha256:c6a98d698f9e2c8008d0370ec7fc452ebfcc530002ae2d0061170d768b992589 \
--hash=sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688 \
--hash=sha256:cee0f89f4767a6057c8fbf168f8135f18be651300496086bd873e3189fed0487 \
--hash=sha256:d17d7512151fedfcc64c1821a8977fc9be0dbf495754669afcab7b57abc98ae9 \
--hash=sha256:d46e62cb35d91e6e2589fda6d28074426b0e276422b5d2ebef2c6b11dc60dbfd \
--hash=sha256:d50dd325e18ec25bfcc10cd7f99b04df1ab9ec76b0918c260e60817ad0643dee \
--hash=sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d \
--hash=sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d \
--hash=sha256:dfd3db045e95960ae3683059571e597fda7cc610106a8916f77c5839048c1deb \
--hash=sha256:e26ff680768b8095e8874aabe0e9d3a47a2a9f176a8340d05f8604c56457c23a \
--hash=sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328 \
--hash=sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635 \
--hash=sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188 \
--hash=sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c \
--hash=sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243 \
--hash=sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a
# via pytest-cov
iniconfig==2.3.0 \
--hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \
--hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
# via pytest
json5==0.15.0 \
--hash=sha256:56636a30c0e8a4665fe2179c0212f32eae3796dea89ea6f649b9436ecdb39618 \
--hash=sha256:7424d1f1eb1d56da6e3d70643f53619862b4ce81440bdb8ecfd6f875e5ba4a71
# via specify-cli (pyproject.toml)
markdown-it-py==4.2.0 \
--hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \
--hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a
# via rich
mdurl==0.1.2 \
--hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \
--hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba
# via markdown-it-py
packaging==26.2 \
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
--hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
# via
# specify-cli (pyproject.toml)
# pytest
pathspec==1.1.1 \
--hash=sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a \
--hash=sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189
# via specify-cli (pyproject.toml)
platformdirs==4.11.0 \
--hash=sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0 \
--hash=sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74
# via specify-cli (pyproject.toml)
pluggy==1.6.0 \
--hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
--hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
# via
# pytest
# pytest-cov
pygments==2.20.0 \
--hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
--hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
# via
# pytest
# rich
pytest==9.1.1 \
--hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \
--hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c
# via
# specify-cli (pyproject.toml)
# pytest-cov
pytest-cov==7.1.0 \
--hash=sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2 \
--hash=sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678
# via specify-cli (pyproject.toml)
pyyaml==6.0.3 \
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
--hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \
--hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \
--hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \
--hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \
--hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \
--hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \
--hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \
--hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \
--hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \
--hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \
--hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \
--hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \
--hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \
--hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \
--hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \
--hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \
--hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \
--hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \
--hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \
--hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \
--hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \
--hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \
--hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \
--hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \
--hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \
--hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \
--hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \
--hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \
--hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \
--hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \
--hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \
--hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \
--hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \
--hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \
--hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \
--hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \
--hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \
--hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \
--hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \
--hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \
--hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \
--hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \
--hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \
--hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \
--hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \
--hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \
--hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \
--hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \
--hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \
--hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \
--hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \
--hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \
--hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \
--hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \
--hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \
--hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \
--hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \
--hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \
--hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \
--hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \
--hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \
--hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \
--hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \
--hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \
--hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \
--hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \
--hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \
--hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \
--hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \
--hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \
--hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \
--hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0
# via specify-cli (pyproject.toml)
readchar==4.2.2 \
--hash=sha256:92daf7e42c52b0787e6c75d01ecfb9a94f4ceff3764958b570c1dddedd47b200 \
--hash=sha256:e3b270fe16fc90c50ac79107700330a133dd4c63d22939f5b03b4f24564d5dd8
# via specify-cli (pyproject.toml)
rich==15.0.0 \
--hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb \
--hash=sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36
# via
# specify-cli (pyproject.toml)
# typer
shellingham==1.5.4 \
--hash=sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686 \
--hash=sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de
# via typer
typer==0.27.0 \
--hash=sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5 \
--hash=sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1
# via specify-cli (pyproject.toml)

1746
.github/workflows/add-community-bundle.lock.yml generated vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,288 @@
---
description: "Process community bundle submission issues - validate, add to catalog, and open a PR for maintainer review"
emoji: "📦"
on:
issues:
types: [labeled]
names: [bundle-submission]
skip-bots: [github-actions, copilot, dependabot]
tools:
edit:
bash: ["echo", "grep", "sort", "python3", "jq", "date"]
github:
toolsets: [issues, repos]
min-integrity: none
web-fetch:
permissions:
contents: read
issues: read
checkout:
fetch-depth: 0
safe-outputs:
noop:
report-as-issue: false
create-pull-request:
title-prefix: "[bundle] "
labels: [bundle-submission, automated]
draft: true
max: 1
allowed-files:
- bundles/catalog.community.json
- docs/community/bundles.md
protected-files:
policy: blocked
exclude:
- README.md
- CHANGELOG.md
add-comment:
max: 2
add-labels:
allowed: [bundle-submission, validation-passed, validation-failed, needs-info]
max: 3
remove-labels:
allowed: [validation-passed, validation-failed, needs-info]
---
# Add Community Bundle from Issue Submission
You are a catalog maintenance agent for the Spec Kit project. Process community
bundle submission issues and create draft pull requests that add or update
entries in the community bundle catalog.
Community bundles are untrusted. Validate metadata and distribution evidence,
but do not claim to audit, endorse, or support bundle code or the components it
installs. Never register a submitted companion catalog automatically.
## Triggering Conditions
This workflow is triggered by an `issues: labeled` event and is gated to the
`bundle-submission` label. Before processing, verify that the issue title starts
with `[Bundle]:`. If it does not, stop without commenting.
## Step 1 - Read and Parse the Issue
Read issue #${{ github.event.issue.number }} and extract these issue-form fields:
| Field | Issue Form ID | Required |
|-------|---------------|----------|
| Bundle ID | `bundle-id` | Yes |
| Bundle Name | `bundle-name` | Yes |
| Version | `version` | Yes |
| Role or Team | `role` | Yes |
| Description | `description` | Yes |
| Author | `author` | Yes |
| Repository URL | `repository` | Yes |
| Download URL | `download-url` | Yes |
| Documentation URL | `documentation` | Yes |
| License | `license` | Yes |
| Required Spec Kit Version | `speckit-version` | Yes |
| Integration Target | `integration` | No |
| Components Provided | `components-provided` | Yes |
| Required Component Catalogs | `required-catalogs` | Yes |
| Tags | `tags` | Yes |
| Key Features | `features` | Yes |
| Testing Details | `testing-details` | Yes |
| Example Usage | `example-usage` | Yes |
| Proposed Catalog Entry | `catalog-entry` | Yes |
Issue-form values appear beneath headings matching their labels.
## Step 2 - Validate the Submission
Run every check and collect all failures before deciding the outcome.
### 2a. Bundle ID and version
- The bundle ID must match
`^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$`.
- The version must be semantic version `X.Y.Z` with digits only and no `v`
prefix.
### 2b. Repository and documentation
- Restrict repository and documentation URLs to public GitHub URLs before
fetching them.
- Confirm the repository exists and contains `bundle.yml`, `README.md`, and a
license file (`LICENSE`, `LICENSE.md`, or `LICENSE.txt`).
- The documentation URL must resolve to a readable Markdown file that explains
the bundle's intended role, installed components, required catalogs, and
installation steps.
- Confirm the repository's `bundle.yml` matches the submitted bundle ID,
version, role, author, license, Spec Kit requirement, integration target, and
component summary.
### 2c. Release artifact
- The download URL must be an HTTPS GitHub release asset URL under the submitted
repository:
`https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>.zip`.
- Confirm the release exists, its tag corresponds to the submitted version
(`vX.Y.Z` or `X.Y.Z`), and the exact ZIP asset is attached to that release.
- Confirm the asset name is versioned and consistent with the submitted bundle
ID and version.
Do not fetch arbitrary user-provided URLs. Do not claim the artifact was
executed or audited; rely on the required submission attestations for build and
installation evidence.
### 2d. Catalog entry
Parse the proposed JSON and require one entry under the submitted bundle ID.
Confirm that:
- `id`, `name`, `version`, `role`, `description`, `author`, `license`,
`download_url`, and `repository` match the submission and manifest.
- `requires.speckit_version` matches the submission.
- `provides` contains non-negative integer counts for `extensions`, `presets`,
`steps`, and `workflows`, matching the manifest.
- `tags` contains 2-5 lowercase strings and matches the submitted tags.
- `verified` is the boolean value `false`. Community entries must never be
marked verified.
### 2e. Component resolution
- `Required Component Catalogs` must explicitly say `None` or list every
non-default extension, preset, workflow, and step catalog needed by the
bundle.
- Compare the manifest references, README, required-catalog field, testing
details, and example usage for consistency.
- If non-default catalogs are required, ensure each URL is HTTPS, the README
documents the corresponding `catalog add` command, and the testing details
say those catalogs were registered in the clean-project test.
- If the field says `None` but a component is not bundled and cannot be
installed from a default Spec Kit catalog, fail validation and ask the
submitter to list and document an install-allowed companion catalog.
The community bundle catalog itself remains discovery-only. Companion catalog
URLs are documentation and validation metadata, not catalogs this workflow
should add to Spec Kit.
### 2f. Checklists and testing evidence
- Confirm every required checkbox in Testing Checklist and Submission
Requirements is checked (`[x]`).
- Confirm Testing Details describe validation, build, artifact installation,
and clean-project testing.
- Confirm Example Usage includes artifact installation and, when applicable,
all required catalog setup commands.
### Validation outcome
If any check fails:
1. Comment once with every failed check and a specific correction.
2. Remove `validation-passed`.
3. Add `validation-failed`; add `needs-info` when submitter input is needed.
4. Stop without editing files or creating a pull request.
If all checks pass, remove `validation-failed` and `needs-info`, add
`validation-passed`, and continue.
## Step 3 - Determine Add or Update
Search `bundles/catalog.community.json` for the bundle ID.
- If absent, add a new entry.
- If present, update the existing entry in place.
Treat a submitted version lower than or equal to the existing catalog version
as a validation failure unless the issue clearly documents a metadata-only
correction at the same version.
## Step 4 - Update the Community Catalog
Edit `bundles/catalog.community.json`. Insert new entries alphabetically by
bundle ID. The entry shape is:
```json
{
"<bundle-id>": {
"name": "<bundle-name>",
"id": "<bundle-id>",
"version": "<version>",
"role": "<role>",
"description": "<description>",
"author": "<author>",
"license": "<license>",
"download_url": "<download-url>",
"repository": "<repository>",
"requires": {
"speckit_version": "<speckit-version>"
},
"provides": {
"extensions": 0,
"presets": 0,
"steps": 0,
"workflows": 0
},
"tags": ["<tag>"],
"verified": false
}
}
```
Use the validated proposed entry rather than inventing metadata. Keep
`verified: false`. Update the top-level `updated_at` to today's UTC date at
midnight and preserve the top-level `catalog_url`.
Validate the complete file:
```bash
python3 -c "import json; json.load(open('bundles/catalog.community.json')); print('Valid JSON')"
```
## Step 5 - Update Community Documentation
Add or update the bundle in `docs/community/bundles.md`. Keep rows alphabetical
by bundle name:
```text
| <Name> | <Description> | `<role>` | <component counts> | <None or documented> | [<repo-name>](<repository>) |
```
Before rendering the row, convert every user-derived display value to
single-line plain text: collapse CR/LF sequences to spaces, remove control
characters, and backslash-escape `\`, `|`, backticks, `*`, `_`, `[`, `]`, `<`,
and `>`. Use the validated HTTPS GitHub repository URL unchanged only as the
Markdown link destination.
Render component counts compactly, omitting zero-valued component types. Use
`None` when no companion catalogs are needed and `Documented` otherwise; the
repository README remains the source for the actual URLs.
## Step 6 - Create a Draft Pull Request
Create one draft pull request.
- New entry branch:
`community/${{ github.event.issue.number }}-add-<bundle-id>-bundle`
- Update branch:
`community/${{ github.event.issue.number }}-update-<bundle-id>-bundle`
- New title: `Add <Bundle Name> bundle to community catalog`
- Update title: `Update <Bundle Name> bundle to v<version>`
The commit and PR description must summarize the catalog and documentation
changes, list the validation results, include
`Closes #${{ github.event.issue.number }}`, and mention the submitter with
`cc @<issue-author>`.
End the commit message with this authorship trailer:
```text
Assisted-by: GitHub Copilot (model: <name-if-known>, autonomous)
```
## Important Rules
- Modify only `bundles/catalog.community.json` and
`docs/community/bundles.md`.
- Keep JSON entries sorted by ID and documentation rows sorted by name.
- Never set a community bundle's `verified` field to true.
- Never add, enable, or change the policy of a submitted catalog.
- Never describe validation as a security audit or endorsement.
- Use `Closes`, not `Fixes`, for the submission issue.

View File

@@ -9,11 +9,13 @@ jobs:
if: >
(github.event.action == 'opened' && (
contains(github.event.issue.labels.*.name, 'extension-submission') ||
contains(github.event.issue.labels.*.name, 'preset-submission')
contains(github.event.issue.labels.*.name, 'preset-submission') ||
contains(github.event.issue.labels.*.name, 'bundle-submission')
)) ||
(github.event.action == 'labeled' && (
github.event.label.name == 'extension-submission' ||
github.event.label.name == 'preset-submission'
github.event.label.name == 'preset-submission' ||
github.event.label.name == 'bundle-submission'
))
runs-on: ubuntu-latest
permissions:

78
.github/workflows/security.yml vendored Normal file
View File

@@ -0,0 +1,78 @@
name: Security Audit
permissions:
contents: read
on:
push:
branches: ["main"]
pull_request:
types: [opened, synchronize, reopened]
schedule:
- cron: "17 4 * * 1"
workflow_dispatch:
jobs:
dependency-audit:
name: Dependency audit
if: ${{ github.event_name != 'schedule' }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: Install uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.14"
- name: Check committed audit requirements are current
env:
DEPENDENCY_DIFF_BASE: ${{ github.event.pull_request.base.sha || github.event.before || '' }}
DEPENDENCY_DIFF_HEAD: ${{ github.sha }}
GENERATED_REQUIREMENTS: ${{ runner.temp }}/security-audit-requirements.txt
run: python .github/scripts/check_security_requirements.py
- name: Run pip-audit (committed requirements)
run: uvx --from pip-audit==2.10.0 pip-audit --disable-pip --require-hashes -r .github/security-audit-requirements.txt --progress-spinner off
dependency-audit-scheduled:
name: Dependency audit scheduled (${{ matrix.os }}, Python ${{ matrix.python-version }})
if: ${{ github.event_name == 'schedule' }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
python-version: ["3.11", "3.12", "3.13", "3.14"]
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: ${{ matrix.python-version }}
# The committed .github/security-audit-requirements.txt is generated with
# --universal (resolves across all interpreters/platforms) and is what
# push/PR/workflow_dispatch runs audit. The scheduled job instead compiles
# per matrix entry with --python-version so it can surface advisories in
# wheels that only resolve on a specific interpreter (e.g. 3.11-only) —
# coverage the universal file may not exercise. This broadening is
# intentional; non-scheduled runs trade that depth for determinism against
# the committed snapshot.
- name: Compile scheduled audit requirements
run: |
uv pip compile pyproject.toml --extra test --python-version "${{ matrix.python-version }}" --upgrade --generate-hashes --quiet --output-file "${{ runner.temp }}/spec-kit-audit-requirements.txt"
- name: Run pip-audit (scheduled live resolution)
run: uvx --from pip-audit==2.10.0 pip-audit --disable-pip --require-hashes -r "${{ runner.temp }}/spec-kit-audit-requirements.txt" --progress-spinner off

View File

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

View File

@@ -2,6 +2,101 @@
<!-- insert new changelog below this comment -->
## [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
- fix(integrations): escape Rich markup in --integration-options error messages (#3458)
- docs: document __SPECKIT_COMMAND_ token for portable cross-command references (#3503)
- [preset] Add Parallel Autonomous Run Governance preset to community catalog (#3614)
- docs(workflows): fix stale FanOutStep docstring claiming sequential-only execution (#3639)
- [bundle] Add SicarioSpec Security & Governance Bundle to community catalog (#3636)
- [preset] Update Autonomous Run Governance preset to v0.3.2 (#3615)
- fix(workflows): validate every redirect hop when fetching workflow/step catalogs (#3637)
- Add pipeline workflow to community catalog (#3338)
- [extension] Add Linear Weave extension to community catalog (#3609)
- docs: clarify hook priority validation semantics (#3594)
- fix(workflows): reject a non-string 'integration'/'model' in command & prompt steps (#3597)
- ci: add dependency audit workflow (#3138)
- Add Intake Review Governance preset to community catalog (#3613)
- fix(workflows): reject non-list input 'enum' instead of crashing (#3601)
- chore: release 0.13.2, begin 0.13.3.dev0 development (#3617)
## [0.13.2] - 2026-07-21
### Changed
- fix(workflows): reject a non-string 'command' in command-step (#3596)
- fix(workflows): fail gate step loudly on a malformed 'options' (#3595)
- fix(extensions): re-validate catalog URL after redirects (HTTPS parity/security) (#3524)
- Add community bundle submission automation (#3553)
- fix(presets): re-validate catalog URL after redirects (HTTPS parity/security) (#3523)
- feat(scripts): port create-new-feature, setup-plan and setup-tasks to Python (#3386)
- fix(agents): parse frontmatter on the --- delimiter line, not any --- substring (#3590)
- [bug-fix] Fix reinstall-overwrites-kept-config: preserve config on plain reinstall after --keep-config (#3449)
- feat: update Bob integration to skills-based layout for Bob 2.0 (#3415)
- Update OKF Knowledge Bundle Generator to v0.3.0 (#3608)
- Add Test Coverage Drift Control extension to community catalog (#3607)
- chore: align ruff lint scope (#3139)
- feat(workflows): WorkflowResolver standalone (PR 1) (#3557)
- fix(extensions,presets): surface clean error on malformed download URL (#3577)
- chore: release 0.13.1, begin 0.13.2.dev0 development (#3610)
## [0.13.1] - 2026-07-21
### Changed
- fix(integrations): catch OverflowError on a `priority: .inf` in add/remove (#3589)
- fix(workflows): reject bool / .inf catalog priority in workflow & step catalog loaders (#3526)
- fix(catalogs): 'priority: .inf' yields a clean validation error instead of crashing (#3525)
- docs(integrations): document the 'integration list --catalog' flag (#3530)
- fix(workflows): fail fan-in loudly on a non-string wait_for entry (#3579)
- fix(workflows): fail fan-out loudly on a truthy non-mapping step template (#3537)
- fix(workflows): reject a non-string prompt in prompt-step validate() (#3582)
- fix(workflows): route 'workflow status --json' errors to stderr (#3520)
- fix(integrations): Forge dispatches hyphenated /speckit-<cmd> invocations (#3529)
- chore: release 0.13.0, begin 0.13.1.dev0 development (#3588)
## [0.13.0] - 2026-07-17
### Changed
- fix(auth): Azure DevOps az-CLI token acquisition returns None on undecodable output (#3527)
- feat(extensions): add assess idea assessment pipeline extension (#3568)
- fix(bundle): surface a clean BundlerError on a malformed bundle download URL (#3586)
- Add OKF Knowledge Bundle Generator extension to community catalog (#3585)
- Update Autonomous Run Governance preset to v0.2.2 (#3584)
- docs: update extension guide PyPI upgrade guidance (#3578)
- fix(presets): raise PresetValidationError, not raw ValueError, on malformed catalog URL (#3576)
- chore(deps): bump github/codeql-action/init from 4.36.2 to 4.37.1 (#3571)
- docs: align README hero tagline and subtitle with docs/index.md (#3581)
- chore: release 0.12.18, begin 0.12.19.dev0 development (#3583)
## [0.12.18] - 2026-07-17
### Changed

View File

@@ -113,6 +113,27 @@ uv pip install -e ".[test]"
> `specify_cli` to this checkout's `src/`. This matches the gotcha documented in
> `AGENTS.md` (Common Pitfalls).
#### Security checks
```bash
uvx --from pip-audit==2.10.0 pip-audit --disable-pip --require-hashes -r .github/security-audit-requirements.txt --progress-spinner off
```
This command audits the committed hashed requirements snapshot. Pull request,
push, and manual CI runs use the same snapshot so their results stay
deterministic. If dependency metadata changes, refresh and commit the snapshot
before auditing it:
```bash
uv pip compile pyproject.toml --extra test --universal --upgrade --generate-hashes --quiet --no-header --output-file .github/security-audit-requirements.txt
```
The scheduled CI audit resolves the runtime and `test` extra dependency set
across the supported Python and OS matrix to catch newly published advisories.
Upstream package releases drift over time, so even an unrelated PR touching
`pyproject.toml` can fail the `dependency-audit` check until the committed file
is regenerated with the command above and re-committed.
#### Shell scripts
```bash

View File

@@ -0,0 +1,35 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-22T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/bundles/catalog.community.json",
"bundles": {
"sicario-spec": {
"name": "SicarioSpec Security & Governance Bundle",
"id": "sicario-spec",
"version": "0.5.1",
"role": "security-engineer",
"description": "Secure-by-default governance bundle for GitHub Spec Kit. Enforces data classification, threat modeling, and code-owned verification gates.",
"author": "SicarioSpec Contributors",
"license": "MIT",
"download_url": "https://github.com/dfirs1car1o/sicario-spec/releases/download/v0.5.1/sicario-spec-0.5.1.zip",
"repository": "https://github.com/dfirs1car1o/sicario-spec",
"requires": {
"speckit_version": ">=0.9.0"
},
"provides": {
"extensions": 1,
"presets": 11,
"steps": 0,
"workflows": 0
},
"tags": [
"security",
"governance",
"compliance",
"appsec",
"threat-modeling"
],
"verified": false
}
}
}

View File

@@ -5,7 +5,11 @@
Bundles compose existing Spec Kit components — extensions, presets, workflows, and steps — into a single role or team stack. They are useful when a user should be able to install a tested set of components together instead of following several separate install commands.
Accepted community bundle entries will be listed here once a community bundle catalog is available. To submit a bundle for review, file a [Bundle Submission](https://github.com/github/spec-kit/issues/new?template=bundle_submission.yml) issue.
Accepted community bundle entries are published in [`bundles/catalog.community.json`](https://github.com/github/spec-kit/blob/main/bundles/catalog.community.json) and listed below. The built-in community source is discovery-only: `specify bundle search` and `specify bundle info` can inspect entries, but installing by ID requires explicitly adding an install-allowed catalog. Explicit catalogs use a higher default precedence than the built-in community source. To submit a bundle for review, file a [Bundle Submission](https://github.com/github/spec-kit/issues/new?template=bundle_submission.yml) issue.
| Bundle | Purpose | Role or team | Provides | Required catalogs | URL |
|--------|---------|--------------|----------|-------------------|-----|
| SicarioSpec Security & Governance Bundle | Secure-by-default governance bundle for GitHub Spec Kit. Enforces data classification, threat modeling, and code-owned verification gates. | `security-engineer` | 1 extension, 11 presets | Documented | [sicario-spec](https://github.com/dfirs1car1o/sicario-spec) |
## What to Submit

View File

@@ -70,6 +70,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Jira Integration (Sync Engine) | Idempotent, drift-aware, fail-closed reconcile engine mirroring spec-kit specs into Jira (Epic per repo, Story per spec, Subtask per phase) | `integration` | Read+Write | [spec-kit-jira-sync](https://github.com/ashbrener/spec-kit-jira-sync) |
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
| Linear Integration | Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional). | `integration` | Read+Write | [spec-kit-linear-sync](https://github.com/ashbrener/spec-kit-linear-sync) |
| Linear Weave | Weave Spec Kit into Linear: pull requirements, mirror tasks.md into sub-issues, sync statuses | `integration` | Read+Write | [spec-kit-linear-weave](https://github.com/tonydwoodhouse/spec-kit-linear-weave) |
| LLM Wiki | LLM-maintained compounding project wiki: source ingestion, cited answers, and consistency linting | `docs` | Read+Write | [spec-kit-wiki](https://github.com/formin/spec-kit-wiki) |
| Loop Engineering | Engineer safe autonomous agent loops for spec-driven development: a maker/checker split, externalized loop state, and stay-the-engineer guardrails against comprehension debt and cognitive surrender | `process` | Read+Write | [spec-kit-loop](https://github.com/formin/spec-kit-loop) |
| MAQA — Multi-Agent & Quality Assurance | Coordinator → feature → QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins. Optional CI gate. | `process` | Read+Write | [spec-kit-maqa-ext](https://github.com/GenieRobot/spec-kit-maqa-ext) |
@@ -89,7 +90,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Multi-Repo Branch Sync | Creates the feature branch in affected sub-repositories and git submodules via plan/tasks hooks | `process` | Read+Write | [multi-repo-sync](https://github.com/fyloss/spec-kit-multi-repo-sync) |
| Multi-Sites Spec Kit | Multi-site aware specify command with per-site spec folders, auto-increment, and Drupal support | `process` | Read+Write | [spec-kit-multi-sites](https://github.com/teeyo/spec-kit-multi-sites) |
| .NET Framework to Modern .NET Migration | Orchestrate end-to-end .NET Framework to modern .NET migration across 7 phases, with SDD lifecycle integration | `process` | Read+Write | [spec-kit-fx-to-net](https://github.com/RogerBestMsft/spec-kit-FxToNet) |
| OKF Knowledge Bundle Generator | Generates and maintains an Open Knowledge Format (OKF v0.1) knowledge bundle from a source-code repository | `docs` | Read+Write | [speckit_ofk](https://github.com/alexcpn/speckit_ofk) |
| OKF Knowledge Bundle Generator | Generates and maintains an Open Knowledge Format (OKF v0.1) knowledge bundle from a source-code repository, mining git history for significance and rationale, and resolving open questions with the user | `docs` | Read+Write | [speckit_ofk](https://github.com/alexcpn/speckit_ofk) |
| Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) |
| Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) |
| Orchestration Task Context Management | Adds subagent work-unit orchestration to generated Spec Kit task files | `process` | Read+Write | [spec-kit-orchestration-task-context-management](https://github.com/benizzio/spec-kit-orchestration-task-context-management) |
@@ -149,6 +150,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Superspec | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) |
| Tasks to GitHub Project | Publish and synchronize Spec Kit tasks as cards on a GitHub Project (v2) kanban board, with priority and status sync between spec.md/tasks.md and the board. | `integration` | Read+Write | [spec-kit-tasks-to-project](https://github.com/mancioshell/spec-kit-tasks-to-project) |
| Team Assign | Assign tasks.md items to human engineers, split into subtasks, and generate a per-engineer workboard | `process` | Read+Write | [spec-kit-team-assign](https://github.com/tarunkumarbhati/spec-kit-team-assign) |
| Test Coverage Drift Control | Generate incremental coverage drift reports and planned remediation tasks after implementation | `code` | Read+Write | [spec-kit-test-coverage-drift-control](https://github.com/benizzio/spec-kit-test-coverage-drift-control) |
| Time Machine | Retroactively apply the full SDD workflow to existing codebases — analyse, spec, and ship feature-by-feature | `process` | Read+Write | [spec-kit-time-machine](https://github.com/teeyo/spec-kit-time-machine) |
| TinySpec | Lightweight single-file workflow for small tasks — skip the heavy multi-step SDD process | `process` | Read+Write | [spec-kit-tinyspec](https://github.com/Quratulain-bilal/spec-kit-tinyspec) |
| Token Budget | Reduces LLM token consumption in Spec Kit workflows: compact artifacts in-place, scope per-phase reading, suppress prose padding, and report token usage | `process` | Read+Write | [spec-kit-token-budget](https://github.com/tinesoft/spec-kit-token-budget) |

View File

@@ -11,7 +11,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Agent Parity Governance | Adds shared-guidance parity, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across a project's declared AI-agent instruction surfaces so agent guidance does not drift. | 6 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
| Architecture Governance | Adds secure software architecture, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence | 13 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
| Autonomous Run Governance | Adds permission-bounded, evidence-first governance for autonomous Spec Kit delivery with validated status, stop, resume, exact-head proof, closeout, and learner guidance. | 13 templates, 5 commands, 4 scripts | — | [spec-kit-preset-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-autonomous-run-governance) |
| Autonomous Run Governance | Adds permission-bounded, evidence-first governance for complete autonomous Spec Kit delivery, including validated status, stop, explicit resume, exact-head proof, post-merge closeout, retrospective learning, and an optional policy-driven intake-review gate before feature creation. | 13 templates, 5 commands, 4 scripts | — | [spec-kit-preset-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-autonomous-run-governance) |
| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) |
| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) |
| Command Density | Compacts the nine core Spec Kit command prompts while preserving scripts, handoffs, placeholders, hook output blocks, and rule structure | 9 commands | — | [spec-kit-preset-command-density](https://github.com/Xopoko/spec-kit-preset-command-density) |
@@ -19,10 +19,13 @@ 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) |
| Model Driven Engineering | Focuses on streamlined commands, app repository support, cross-spec support, and capability-aware project memory for model-driven engineering workflows | 6 templates, 11 commands | MDE extension | [spec-kit-preset-mde](https://github.com/AI-MDE/spec-kit-preset-mde) |
| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) |
| Parallel Autonomous Run Governance | Coordinates isolated autonomous Spec Kit campaigns with bounded concurrency, mixed agents, resumable consolidation, governed post-merge closeout, schema 1.2, and an optional current intake-review gate before worker scheduling. | 9 templates, 5 commands, 2 scripts | autonomous-run-governance >=0.3.2; optional: intake-review-governance >=0.1.0 | [spec-kit-preset-parallel-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance) |
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) |
| Security Governance | Adds memory-safe-language preference, language-specific secure coding profiles, audit-ready Spec-Kit run evidence, ASVS verification, SBOM/AI-SBOM supply-chain transparency, CRA awareness, and regulatory applicability screening for NIS2, CRA, EU AI Act, and DORA | 14 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |

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

@@ -120,10 +120,10 @@ generated metadata, then add the import and `_register()` call in
## 7. Run Lint / Basic Checks
CI enforces `ruff check src/` (see `.github/workflows/test.yml`), so run it locally before pushing:
CI enforces `ruff check src tests` (see `.github/workflows/test.yml`), so run it locally before pushing:
```bash
uvx ruff check src/
uvx ruff check src tests
```
You can also quickly sanity check importability:

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

@@ -221,12 +221,14 @@ Each hook entry supports the following fields:
| `command` | Extension command associated with the hook. |
| `enabled` | Whether the hook is active. Hooks with `enabled: false` are skipped. |
| `optional` | Whether the hook is optional. If `true`, the hook is presented with its `prompt` and can be skipped; if `false`, the hook is emitted as an automatic hook (includes `EXECUTE_COMMAND` markers). |
| `priority` | Priority metadata for the hook. Values must be integers >= 1; invalid values fall back to the default priority `10`. Current command templates surface hooks in their configured YAML order and do not sort them by `priority`. |
| `priority` | Priority metadata for the hook. Registered hook entries use integer values >= 1; entries installed from manifests default to `10` when no priority is declared. Current command templates surface hooks in their configured YAML order and do not sort them by `priority`. |
| `prompt` | Message shown when asking whether to run an optional hook. |
| `description` | Human-readable explanation of what the hook does. |
| `condition` | Optional expression evaluated by `HookExecutor` (using `config.<path>` or `env.<VAR>` with `is set`, `==`, or `!=`). Current command templates do not evaluate conditions and skip hooks with a non-empty condition. |
Hook event names identify when a hook is invoked. They generally use `before_<command>` or `after_<command>`, such as `before_implement`, `after_implement`, `before_tasks`, and `after_tasks`.
Extension manifests reject invalid hook priorities during installation. For existing `.specify/extensions.yml` entries, `HookExecutor.get_hooks_for_event()` sorts with `normalize_priority()`: missing values, booleans, non-numeric values rejected by `int()`, and values less than `1` fall back to `10`; numeric strings and finite floats are coerced with `int()`, while non-finite floats are unsupported and may fail instead of falling back.
`HookExecutor.get_hooks_for_event()` returns hooks ordered by `priority`, with lower values first. However, current command templates read hook lists directly and surface them in their configured YAML order rather than using priority ordering.
## FAQ

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` | |
@@ -22,7 +23,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Goose](https://goose-docs.ai/) | `goose` | Uses YAML recipe format in `.goose/recipes/` |
| [Grok Build](https://docs.x.ai/build/overview) | `grok` | Skills-based integration; installs skills into `.grok/skills` and invokes them as `/speckit-<command>` |
| [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes` | Skills-based integration; installs skills globally into `~/.hermes/skills/` |
| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent |
| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | Skills-based integration by default; installs skills as `speckit-<command>/SKILL.md` under `.bob/skills/` and invokes them as `/speckit-<command>`. Pass `--integration-options="--legacy-commands"` to scaffold the deprecated Bob 1.x layout (`.bob/commands/*.md`) instead; that flag will be removed in a future release. Existing legacy installs can migrate with `specify integration upgrade bob --integration-options="--skills"`, which converts them to the skills layout and removes the old command files. If preset overrides are installed, the migration is rejected with an actionable error (preset artifacts cannot yet be reconciled across a layout change) — remove the preset(s), migrate, then reinstall them. |
| [Junie](https://junie.jetbrains.com/) | `junie` | |
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | |
| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; installs into `.kimi-code/skills/`. `--migrate-legacy` moves old `.kimi/skills/` installs to the new paths |
@@ -48,7 +49,11 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
specify integration list
```
Shows all available integrations, which one is currently installed, and whether each requires a CLI tool or is IDE-based.
| Option | Description |
| ----------- | ----------------------------------------------------------------------------------------------------------------------- |
| `--catalog` | Also browse the catalog (built-in **and** community). Community integrations that are not built in are only shown here. |
Shows the built-in integrations, which one is currently installed, and whether each requires a CLI tool or is IDE-based.
When multiple integrations are installed, the list marks the default integration separately from the other installed integrations.
The list also shows whether each built-in integration is declared multi-install safe.

View File

@@ -91,8 +91,192 @@ specify workflow add <source>
| `--dev` | Install from a local workflow YAML file or directory |
| `--from <url>` | Install from a custom URL (`<source>` names the expected workflow ID) |
Installs a workflow from the catalog, a URL (HTTPS required), or a local file path.
Installs a workflow from the catalog, a URL (HTTPS required), a local YAML file, or a local directory containing `workflow.yml`.
## Workflow Overlays
Workflow overlays let a project extend or override an installed workflow without editing the installed `workflow.yml`. This keeps local customizations safe across `specify bundle update` or `specify workflow add` upgrades.
When `specify workflow run <workflow-id>` loads a workflow, the engine composes the base workflow with all enabled overlays for that workflow id. The result is validated like any other workflow definition.
### How Overlays Work
An overlay is a YAML file that declares a set of edit operations against the step list of a base workflow. Overlays use lower-wins precedence: higher priority numbers are applied first and lower numbers last. Equal-priority overlays are applied alphabetically by ID, with the last ID winning conflicts.
Project overlay files live at:
| Location | Purpose |
| --- | --- |
| `.specify/workflows/overlays/<id>/*.yml` | Project-local customizations |
### Overlay File Format
The recommended edit format uses the operation name as the key and the anchor step id as the value:
```yaml
id: "my-overlay"
extends: "speckit"
priority: 10
enabled: true
edits:
- insert_after: implement
step:
id: run-lint
type: shell
run: "ruff check src/"
- replace: review-spec
step:
id: review-spec
type: gate
message: "Review the generated spec (overlay override)."
options: [approve, reject]
on_reject: abort
```
The explicit form is also supported:
```yaml
edits:
- operation: insert_after
anchor: implement
step:
id: run-lint
type: shell
run: "ruff check src/"
```
#### Fields
| Field | Required | Description |
| --- | --- | --- |
| `id` | yes | Identifier for this overlay. Used in `specify workflow overlay *` commands. Must be lowercase letters, digits, and hyphens only; no dots, underscores, path separators, or `overlays`. |
| `extends` | yes | The workflow id this overlay applies to. Uses the same safe-id format as `id`; `overlays`, `runs`, and `steps` are reserved. |
| `priority` | no | Integer; defaults to `10`. Lower values have higher precedence and win conflicts. Missing or invalid values fall back to `10`. |
| `enabled` | no | Boolean. Defaults to `true`. Disabled overlays are ignored. |
| `edits` | yes | Non-empty list of edit operations. |
#### Edit Operations
| Operation | `step` required | Effect |
| --- | --- | --- |
| `insert_after` | yes | Insert `step` immediately after the anchor step. |
| `insert_before` | yes | Insert `step` immediately before the anchor step. |
| `replace` | yes | Replace the anchor step with `step`. |
| `remove` | no | Remove the anchor step from the list. |
The `anchor` is the `id` of a step in the base workflow. Anchors are resolved recursively inside `then`, `else`, `steps`, `cases.*`, and `default` blocks, so nested base steps can also be targeted. Fan-out templates (`step` inside a `fan-out` step) are **not** valid anchors.
Step ids must not contain `:` — that character is reserved for engine-generated nested ids.
### Overlay CLI Commands
#### Add a Project Overlay
```bash
specify workflow overlay add <path-to-overlay.yml> --priority <n>
```
Validates the overlay file and copies it to `.specify/workflows/overlays/<extends>/<id>.yml`. `--priority` defaults to `10` and overrides the `priority` field in the file.
#### List Overlays
```bash
specify workflow overlay list <workflow-id>
```
Shows all overlays for the workflow, ordered by resolver precedence. Disabled overlays are marked as disabled in the listing and are ignored during workflow resolution.
#### Change Priority
```bash
specify workflow overlay set-priority <workflow-id> <overlay-id> <n>
```
#### Enable or Disable
```bash
specify workflow overlay disable <workflow-id> <overlay-id>
specify workflow overlay enable <workflow-id> <overlay-id>
```
#### Remove
```bash
specify workflow overlay remove <workflow-id> <overlay-id>
```
Removes the project overlay file.
#### Inspect the Composed Workflow
```bash
specify workflow resolve <workflow-id>
```
Prints the layer stack (base + overlays) and the source attribution for each step after composition. Useful for debugging which overlay contributed or overrode a step.
### Example: Adding Automated Linting after Implementation
Given the built-in `speckit` workflow, create `project-overlay.yml`:
```yaml
id: "add-lint"
extends: "speckit"
priority: 10
edits:
- insert_after: implement
step:
id: run-lint
type: shell
run: "ruff check src/"
```
Install it:
```bash
specify workflow overlay add project-overlay.yml --priority 10
```
Run the workflow:
```bash
specify workflow run speckit -i spec="Build a kanban board"
```
The composed workflow will now run the full SDD cycle and execute `ruff check src/` automatically after the `implement` step.
### Example: Replacing a Gate
```yaml
id: "skip-plan-review"
extends: "speckit"
priority: 5
edits:
- replace: review-plan
step:
id: review-plan
type: command
command: speckit.plan
input:
args: "{{ inputs.spec }}"
```
Lower priority values have higher precedence. Change this overlay to `priority: 5` if it must win a conflict with the `add-lint` overlay above. It replaces the `review-plan` gate with a non-interactive command.
### Interaction with Bundles and Updates
`specify workflow add <local-directory>` installs `workflow.yml` from the local directory into `.specify/workflows/<id>/`.
When an installed workflow is refreshed or reinstalled, project overlays in `.specify/workflows/overlays/<id>/` are preserved because they live outside the installed workflow directory.
### Limitations
- Overlays operate on the step list only. They cannot change workflow metadata (name, description, inputs, `requires`) or expression logic.
- Fan-out templates cannot be used as anchors.
- An overlay that targets a step id that does not exist in the base workflow will raise a validation error when the workflow is resolved.
- Overlays cannot target steps added by other overlays.
- Overlays cannot add new inputs or change the input schema of the base workflow.
## Update Workflows
```bash

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

@@ -252,6 +252,7 @@ Use standard Markdown with special placeholders:
- `$ARGUMENTS`: User-provided arguments
- `{SCRIPT}`: Replaced with script path during registration
- `__SPECKIT_COMMAND_<NAME>__`: Replaced with the invocation of another command, rendered using the active integration's separator (see [Referencing other commands](#referencing-other-commands))
**Example**:
@@ -267,6 +268,40 @@ echo "Running with args: $args"
```
````
### Referencing other commands
A command body is a *template* that Spec Kit renders once per agent. Different agents invoke commands with different surface syntax — for example `/speckit.plan` (dot separator) or `/speckit-plan` (hyphen separator). Some agents also use different prefixes in skills mode (e.g. Kimi `/skill:speckit-plan`, Codex/ZCode `$speckit-plan`). So when you reference a sibling command from a body, **do not hard-code a literal invocation** like `/speckit.my-ext.prepare`. A literal is correct for exactly one agent and breaks on the rest.
Instead use the agent-neutral token `__SPECKIT_COMMAND_<NAME>__`. Spec Kit resolves it to a `/speckit<separator>...` invocation using the active integration's `invoke_separator` (and integrations may post-process that further in skills output).
Encode the command name in upper case, dropping the `speckit.` prefix and turning each dotted segment separator into an underscore:
| Command file | Token |
| --- | --- |
| `speckit.plan.md` | `__SPECKIT_COMMAND_PLAN__` |
| `speckit.bug.fix.md` | `__SPECKIT_COMMAND_BUG_FIX__` |
| `speckit.git.commit.md` | `__SPECKIT_COMMAND_GIT_COMMIT__` |
The resolver maps each underscore back to the active agent's separator, so use tokens to reference commands whose name segments are single words. (Command names are dotted segments like `git.commit`; the token scheme rebuilds those dots and does not carry hyphens within a segment.)
**Example** — a command body that points the user at the next step:
```markdown
Once the assessment exists, the next step is `__SPECKIT_COMMAND_BUG_FIX__ slug=<slug>`.
```
This renders as `/speckit.bug.fix slug=<slug>` for a slash-based agent, `/speckit-bug-fix slug=<slug>` for a skills-based agent, and so on — the author writes it once and it stays portable. The first-party `bug` and `git` extensions use this token exclusively; see `extensions/bug/commands/` for working examples.
> **Current limitation — skills mode.** Token resolution runs in the
> command-rendering path (`CommandRegistrar`), so it applies when an extension
> installs *command files*. It does **not** yet run when an extension is
> registered as *skills* for a skills-based agent: `_register_extension_skills`
> resolves placeholders and post-processes content but never calls
> `resolve_command_refs`, so a `__SPECKIT_COMMAND_<NAME>__` token reaches
> agents such as Codex, ZCode, and Kimi verbatim in that mode. Until that
> rendering step lands, prefer the token for command-file extensions and avoid
> relying on it inside skill bodies destined for skills-based agents.
### Script Path Rewriting
Extension commands use relative paths that get rewritten during registration:

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-17T00:00:00Z",
"updated_at": "2026-07-21T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -2029,6 +2029,40 @@
"created_at": "2026-06-01T00:00:00Z",
"updated_at": "2026-06-22T00:00:00Z"
},
"linear-weave": {
"name": "Linear Weave",
"id": "linear-weave",
"description": "Weave Spec Kit into Linear: pull requirements, mirror tasks.md into sub-issues, sync statuses.",
"author": "Tony Woodhouse",
"version": "1.0.0",
"download_url": "https://github.com/tonydwoodhouse/spec-kit-linear-weave/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/tonydwoodhouse/spec-kit-linear-weave",
"homepage": "https://github.com/tonydwoodhouse/spec-kit-linear-weave",
"documentation": "https://github.com/tonydwoodhouse/spec-kit-linear-weave#readme",
"changelog": "https://github.com/tonydwoodhouse/spec-kit-linear-weave/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "integration",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.13.0,<1.0.0",
"tools": [{ "name": "linear-mcp", "required": true }]
},
"provides": {
"commands": 5,
"hooks": 5
},
"tags": [
"linear",
"issue-tracking",
"integration",
"workflow"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-21T00:00:00Z",
"updated_at": "2026-07-21T00:00:00Z"
},
"loop": {
"name": "Loop Engineering",
"id": "loop",
@@ -2683,10 +2717,10 @@
"okf": {
"name": "OKF Knowledge Bundle Generator",
"id": "okf",
"description": "Generates and maintains an Open Knowledge Format (OKF v0.1) knowledge bundle from a source-code repository.",
"description": "Generates and maintains an Open Knowledge Format (OKF v0.1) knowledge bundle from a source-code repository, mining git history for significance and rationale, and resolving open questions with the user.",
"author": "Alex Punnen",
"version": "0.2.0",
"download_url": "https://github.com/alexcpn/speckit_ofk/archive/refs/tags/v0.2.0.zip",
"version": "0.3.0",
"download_url": "https://github.com/alexcpn/speckit_ofk/archive/refs/tags/v0.3.0.zip",
"repository": "https://github.com/alexcpn/speckit_ofk",
"homepage": "https://github.com/alexcpn/speckit_ofk",
"documentation": "https://github.com/alexcpn/speckit_ofk/blob/main/README.md",
@@ -2698,7 +2732,7 @@
"speckit_version": ">=0.12.0"
},
"provides": {
"commands": 3,
"commands": 4,
"hooks": 0
},
"tags": [
@@ -2712,7 +2746,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-07-17T00:00:00Z",
"updated_at": "2026-07-17T00:00:00Z"
"updated_at": "2026-07-21T00:00:00Z"
},
"onboard": {
"name": "Onboard",
@@ -4342,6 +4376,40 @@
"created_at": "2026-05-20T00:00:00Z",
"updated_at": "2026-05-20T00:00:00Z"
},
"test-coverage-drift-control": {
"name": "Test Coverage Drift Control",
"id": "test-coverage-drift-control",
"description": "Generate incremental coverage drift reports and planned remediation tasks after implementation",
"author": "Igor Benicio de Mesquita",
"version": "0.3.0",
"download_url": "https://github.com/benizzio/spec-kit-test-coverage-drift-control/archive/refs/tags/v0.3.0.zip",
"repository": "https://github.com/benizzio/spec-kit-test-coverage-drift-control",
"homepage": "https://github.com/benizzio/spec-kit-test-coverage-drift-control",
"documentation": "https://github.com/benizzio/spec-kit-test-coverage-drift-control#readme",
"changelog": "https://github.com/benizzio/spec-kit-test-coverage-drift-control/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "code",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.2.0"
},
"provides": {
"commands": 2,
"hooks": 1
},
"tags": [
"analysis",
"coverage",
"testing",
"quality",
"maintenance"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-21T00:00:00Z",
"updated_at": "2026-07-21T00:00:00Z"
},
"time-machine": {
"name": "Time Machine",
"id": "time-machine",

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

@@ -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",
@@ -177,11 +186,11 @@
"bob": {
"id": "bob",
"name": "IBM Bob",
"version": "1.0.0",
"description": "IBM Bob IDE integration",
"version": "2.0.0",
"description": "IBM Bob 2.0 IDE skills-based integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["ide", "ibm"]
"tags": ["ide", "ibm", "skills"]
},
"trae": {
"id": "trae",

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-17T00:00:00Z",
"updated_at": "2026-07-22T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
"presets": {
"a11y-governance": {
@@ -69,7 +69,7 @@
"name": "AIDE In-Place Migration",
"id": "aide-in-place",
"version": "1.0.0",
"description": "Adapts the AIDE workflow for in-place technology migrations (X Y pattern). Overrides vision, roadmap, progress, and work item commands with migration-specific guidance.",
"description": "Adapts the AIDE workflow for in-place technology migrations (X \u2192 Y pattern). Overrides vision, roadmap, progress, and work item commands with migration-specific guidance.",
"author": "mnriem",
"repository": "https://github.com/mnriem/spec-kit-presets",
"download_url": "https://github.com/mnriem/spec-kit-presets/releases/download/aide-in-place-v1.0.0/aide-in-place.zip",
@@ -134,13 +134,13 @@
"autonomous-run-governance": {
"name": "Autonomous Run Governance",
"id": "autonomous-run-governance",
"version": "0.2.2",
"description": "Adds permission-bounded, evidence-first governance for autonomous Spec Kit delivery with validated status, stop, resume, exact-head proof, closeout, and learner guidance.",
"version": "0.3.2",
"description": "Adds permission-bounded, evidence-first governance for complete autonomous Spec Kit delivery, including validated status, stop, explicit resume, exact-head proof, post-merge closeout, retrospective learning, and an optional policy-driven intake-review gate before feature creation.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/archive/refs/tags/v0.2.2.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/archive/refs/tags/v0.3.2.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/blob/v0.2.2/README.md",
"documentation": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/blob/v0.3.2/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.3"
@@ -155,10 +155,11 @@
"governance",
"evidence",
"permissions",
"resume"
"resume",
"intake-review"
],
"created_at": "2026-07-13T00:00:00Z",
"updated_at": "2026-07-17T00:00:00Z"
"updated_at": "2026-07-21T00:00:00Z"
},
"canon-core": {
"name": "Canon Core",
@@ -363,6 +364,64 @@
"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",
"version": "0.1.0",
"description": "Adds hash-bound review, repair, and status gates for single, series, and campaign intake files before interactive, autonomous, or parallel Spec Kit execution.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-intake-review-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/archive/refs/tags/v0.1.0.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-intake-review-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/blob/v0.1.0/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.3"
},
"provides": {
"templates": 8,
"commands": 3,
"scripts": 2
},
"tags": [
"intake",
"review",
"governance",
"quality-gate",
"autonomous"
],
"created_at": "2026-07-21T00:00:00Z",
"updated_at": "2026-07-21T00:00:00Z"
},
"isaqb-architecture-governance": {
"name": "iSAQB Architecture Governance",
"id": "isaqb-architecture-governance",
@@ -480,6 +539,36 @@
"created_at": "2026-04-09T00:00:00Z",
"updated_at": "2026-04-09T00:00:00Z"
},
"parallel-autonomous-run-governance": {
"name": "Parallel Autonomous Run Governance",
"id": "parallel-autonomous-run-governance",
"version": "0.2.3",
"description": "Coordinates isolated autonomous Spec Kit campaigns with bounded concurrency, mixed agents, resumable consolidation, governed post-merge closeout, schema 1.2, and an optional current intake-review gate before worker scheduling.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/archive/refs/tags/v0.2.3.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/blob/v0.2.3/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.3"
},
"provides": {
"templates": 9,
"commands": 5,
"scripts": 2
},
"tags": [
"parallel",
"autonomous",
"governance",
"orchestration",
"resume",
"intake-review"
],
"created_at": "2026-07-22T00:00:00Z",
"updated_at": "2026-07-22T00:00:00Z"
},
"pirate": {
"name": "Pirate Speak (Full)",
"id": "pirate",
@@ -509,7 +598,7 @@
"name": "Screenwriting",
"id": "screenwriting",
"version": "1.0.0",
"description": "Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents replace prose fiction conventions. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks.",
"description": "Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft \u2014 slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents replace prose fiction conventions. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks.",
"author": "Andreas Daumann",
"repository": "https://github.com/adaumann/speckit-preset-screenwriting",
"download_url": "https://github.com/adaumann/speckit-preset-screenwriting/archive/refs/tags/v1.0.0.zip",
@@ -624,7 +713,7 @@
"name": "Spec2Cloud",
"id": "spec2cloud",
"version": "1.1.0",
"description": "Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks implement deploy.",
"description": "Spec-driven workflow tuned for shipping to Azure: spec \u2192 plan \u2192 tasks \u2192 implement \u2192 deploy.",
"author": "Azure Samples",
"repository": "https://github.com/Azure-Samples/Spec2Cloud",
"download_url": "https://github.com/Azure-Samples/Spec2Cloud/releases/download/spec-kit-spec2cloud-v1.1.0/preset.zip",
@@ -652,7 +741,7 @@
"id": "test-first-governance",
"version": "1.3.0",
"description": "Governs TDD with coverage-complete BDD/ATDD Gherkin scenarios, explicit suite ownership, professional test reports, traceability, and risk-based quality gates.",
"author": "Zoltán Katona, PhD",
"author": "Zolt\u00e1n Katona, PhD",
"repository": "https://github.com/ka-zo/spec-kit-preset-test-first-governance",
"download_url": "https://github.com/ka-zo/spec-kit-preset-test-first-governance/archive/refs/tags/1.3.0.zip",
"homepage": "https://github.com/ka-zo/spec-kit-preset-test-first-governance",

View File

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

View File

@@ -90,6 +90,19 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then
exit 1
fi
MAX_FEATURE_NUMBER=9223372036854775807
is_feature_number_in_range() {
local value="$1"
local normalized="${value#"${value%%[!0]*}"}"
[ -n "$normalized" ] || normalized=0
[ ${#normalized} -lt ${#MAX_FEATURE_NUMBER} ] && return 0
[ ${#normalized} -gt ${#MAX_FEATURE_NUMBER} ] && return 1
# Equal-length digit strings must be compared without arithmetic overflow.
# shellcheck disable=SC2071
[[ "$normalized" < "$MAX_FEATURE_NUMBER" || "$normalized" == "$MAX_FEATURE_NUMBER" ]]
}
# Function to get highest number from specs directory
get_highest_from_specs() {
local specs_dir="$1"
@@ -102,9 +115,11 @@ get_highest_from_specs() {
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
number=$(echo "$dirname" | grep -Eo '^[0-9]+')
number=$((10#$number))
if [ "$number" -gt "$highest" ]; then
highest=$number
if is_feature_number_in_range "$number"; then
number=$((10#$number))
if [ "$number" -gt "$highest" ]; then
highest=$number
fi
fi
fi
done
@@ -119,6 +134,19 @@ clean_branch_name() {
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
}
# Quote a value for POSIX shell reuse, byte-identical to Python's shlex.quote
# so the persistence hints match the Python variant exactly (printf %q output
# differs between bash versions and from shlex.quote for spaces/metachars).
shell_quote() {
local value="$1" LC_ALL=C
if [[ "$value" =~ ^[A-Za-z0-9_@%+=:,./-]+$ ]]; then
printf '%s' "$value"
else
local q="'\"'\"'"
printf "'%s'" "${value//\'/$q}"
fi
}
# Resolve repository root using common.sh functions which prioritize .specify
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh"
@@ -202,9 +230,24 @@ if [ "$USE_TIMESTAMP" = true ]; then
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
else
if [ -n "$BRANCH_NUMBER" ] && [[ ! "$BRANCH_NUMBER" =~ ^[0-9]+$ ]]; then
echo "Error: --number must be an unsigned integer, got '$BRANCH_NUMBER'" >&2
exit 1
fi
# Bash arithmetic is signed 64-bit; reject digit strings that would wrap.
if [ -n "$BRANCH_NUMBER" ] && ! is_feature_number_in_range "$BRANCH_NUMBER"; then
echo "Error: --number must be between 0 and $MAX_FEATURE_NUMBER, got '$BRANCH_NUMBER'" >&2
exit 1
fi
# Determine branch number from existing feature directories
if [ -z "$BRANCH_NUMBER" ]; then
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
if [ "$HIGHEST" -eq "$MAX_FEATURE_NUMBER" ]; then
echo "Error: feature number must be between 0 and $MAX_FEATURE_NUMBER, got '9223372036854775808'" >&2
exit 1
fi
BRANCH_NUMBER=$((HIGHEST + 1))
fi
@@ -264,8 +307,8 @@ if [ "$DRY_RUN" != true ]; then
_persist_feature_json "$REPO_ROOT" "$FEATURE_DIR"
# Inform the user how to set feature state in their own shell
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
printf '# export SPECIFY_FEATURE_DIRECTORY=%q\n' "$FEATURE_DIR" >&2
printf '# To persist: export SPECIFY_FEATURE=%s\n' "$(shell_quote "$BRANCH_NAME")" >&2
printf '# export SPECIFY_FEATURE_DIRECTORY=%s\n' "$(shell_quote "$FEATURE_DIR")" >&2
fi
if $JSON_MODE; then
@@ -295,7 +338,7 @@ else
echo "SPEC_FILE: $SPEC_FILE"
echo "FEATURE_NUM: $FEATURE_NUM"
if [ "$DRY_RUN" != true ]; then
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
printf '# export SPECIFY_FEATURE_DIRECTORY=%q\n' "$FEATURE_DIR"
printf '# To persist in your shell: export SPECIFY_FEATURE=%s\n' "$(shell_quote "$BRANCH_NAME")"
printf '# export SPECIFY_FEATURE_DIRECTORY=%s\n' "$(shell_quote "$FEATURE_DIR")"
fi
fi

View File

@@ -29,13 +29,16 @@ function Find-SpecifyRoot {
# command against a member project from a monorepo root without cd.
#
# Precondition: $env:SPECIFY_INIT_DIR is set. Returns the validated project root,
# or writes an error and exits 1. Strict by design: the path must exist and
# or writes an error and exits 1 unless -ReturnNullOnError is set. Strict by
# design: the path must exist and
# contain .specify/, with no silent fallback. (An empty string is falsy, so the
# caller's `if ($env:SPECIFY_INIT_DIR)` guard treats empty as unset.)
#
# This is the single resolver: bundled extensions inherit it by sourcing core
# (e.g. the git extension's create-new-feature-branch) rather than duplicating it.
function Resolve-SpecifyInitDir {
param([switch]$ReturnNullOnError)
$initDir = $env:SPECIFY_INIT_DIR
# Normalize: relative paths resolve against the current directory.
if (-not [System.IO.Path]::IsPathRooted($initDir)) {
@@ -47,6 +50,7 @@ function Resolve-SpecifyInitDir {
# "not a Spec Kit project" error below.
if (-not $resolved -or -not (Test-Path -LiteralPath $resolved.Path -PathType Container)) {
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $($env:SPECIFY_INIT_DIR)")
if ($ReturnNullOnError) { return $null }
exit 1
}
# Resolve-Path echoes back any trailing separator from the input; trim it so
@@ -56,6 +60,7 @@ function Resolve-SpecifyInitDir {
$initRoot = [System.IO.Path]::TrimEndingDirectorySeparator($resolved.Path)
if (-not (Test-Path -LiteralPath (Join-Path $initRoot '.specify') -PathType Container)) {
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $initRoot")
if ($ReturnNullOnError) { return $null }
exit 1
}
return $initRoot
@@ -64,9 +69,11 @@ function Resolve-SpecifyInitDir {
# Get repository root, prioritizing .specify directory
# This prevents using a parent repository when spec-kit is initialized in a subdirectory
function Get-RepoRoot {
param([switch]$ReturnNullOnError)
# Explicit project override wins (see Resolve-SpecifyInitDir).
if ($env:SPECIFY_INIT_DIR) {
return (Resolve-SpecifyInitDir)
return (Resolve-SpecifyInitDir -ReturnNullOnError:$ReturnNullOnError)
}
# First, look for .specify directory (spec-kit's own marker)
@@ -147,10 +154,12 @@ function Get-FeaturePathsEnv {
# so pure path resolution never writes .specify/feature.json, which would
# dirty the working tree or overwrite a pinned value (issue #3025).
param(
[switch]$NoPersist
[switch]$NoPersist,
[switch]$ReturnNullOnError
)
$repoRoot = Get-RepoRoot
$repoRoot = Get-RepoRoot -ReturnNullOnError:$ReturnNullOnError
if (-not $repoRoot) { return $null }
$currentBranch = Get-CurrentBranch
# Resolve feature directory. Priority:
@@ -174,7 +183,8 @@ function Get-FeaturePathsEnv {
try {
$featureConfig = $featureJsonRaw | ConvertFrom-Json
} catch {
[Console]::Error.WriteLine("ERROR: Failed to parse .specify/feature.json: $_")
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory.")
if ($ReturnNullOnError) { return $null }
exit 1
}
if ($featureConfig.feature_directory) {
@@ -185,10 +195,12 @@ function Get-FeaturePathsEnv {
}
} else {
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory.")
if ($ReturnNullOnError) { return $null }
exit 1
}
} else {
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json.")
if ($ReturnNullOnError) { return $null }
exit 1
}
@@ -334,30 +346,64 @@ function Resolve-Template {
if (Test-Path $presetsDir) {
$registryFile = Join-Path $presetsDir '.registry'
$sortedPresets = @()
$registryParsed = $false
if (Test-Path $registryFile) {
try {
$registryData = Get-Content $registryFile -Raw | ConvertFrom-Json
$presets = $registryData.presets
if ($presets) {
$sortedPresets = $presets.PSObject.Properties |
if ($null -eq $registryData -or $registryData -isnot [PSCustomObject]) {
throw 'Registry root must be an object'
}
$presetsProperty = $registryData.PSObject.Properties['presets']
if ($presetsProperty) {
$presets = $presetsProperty.Value
if ($null -eq $presets -or $presets -isnot [PSCustomObject]) {
throw 'Registry presets must be an object'
}
$presetEntries = @($presets.PSObject.Properties)
$priorityFor = {
param($Entry)
if ($Entry.Value -is [PSCustomObject]) {
$priorityProperty = $Entry.Value.PSObject.Properties['priority']
if ($priorityProperty) { return $priorityProperty.Value }
}
return 10
}
if ($presetEntries.Count -gt 1) {
$allNumeric = $true
$allStrings = $true
foreach ($entry in $presetEntries) {
$priority = & $priorityFor $entry
if ($null -eq $priority -or $priority -isnot [ValueType]) {
$allNumeric = $false
}
if ($null -eq $priority -or $priority -isnot [string]) {
$allStrings = $false
}
}
if (-not $allNumeric -and -not $allStrings) {
throw 'Registry priorities are not mutually orderable'
}
}
$sortedPresets = $presetEntries |
Where-Object { $_.Value -is [PSCustomObject] } |
Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } |
Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } |
Sort-Object { & $priorityFor $_ } |
ForEach-Object { $_.Name }
}
$registryParsed = $true
} catch {
# Fallback: alphabetical directory order
$sortedPresets = @()
$registryParsed = $false
}
}
if ($sortedPresets.Count -gt 0) {
if ($registryParsed) {
foreach ($presetId in $sortedPresets) {
$candidate = Join-Path $presetsDir "$presetId/templates/$TemplateName.md"
if (Test-Path $candidate) { return $candidate }
}
} else {
# Fallback: alphabetical directory order
foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' }) {
foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
$candidate = Join-Path $preset.FullName "templates/$TemplateName.md"
if (Test-Path $candidate) { return $candidate }
}

View File

@@ -7,7 +7,7 @@ param(
[switch]$DryRun,
[string]$ShortName,
[Parameter()]
[long]$Number = 0,
[string]$Number = '',
[switch]$Timestamp,
[switch]$Help,
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
@@ -142,12 +142,13 @@ if ($ShortName) {
$branchSuffix = Get-BranchName -Description $featureDesc
}
# Warn if -Number and -Timestamp are both specified. Use ContainsKey (not
# `-ne 0`) so an explicit `-Number 0` is also detected, matching the bash twin's
# `[ -n "$BRANCH_NUMBER" ]` check.
if ($Timestamp -and $PSBoundParameters.ContainsKey('Number')) {
Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used"
$Number = 0
# Treat an explicit empty string as omitted, matching the bash and Python twins.
$hasNumber = $PSBoundParameters.ContainsKey('Number') -and $Number -ne ''
# Warn if -Number and -Timestamp are both specified.
if ($Timestamp -and $hasNumber) {
[Console]::Error.WriteLine("[specify] Warning: -Number is ignored when -Timestamp is used")
$Number = ''
}
# Determine branch prefix
@@ -158,11 +159,23 @@ if ($Timestamp) {
# Determine branch number from existing feature directories. Auto-detect only
# when -Number was not supplied; an explicit value (including 0) is honored,
# matching the bash twin's `[ -z "$BRANCH_NUMBER" ]` check.
if (-not $PSBoundParameters.ContainsKey('Number')) {
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
[long]$resolvedNumber = 0
if (-not $hasNumber) {
$highestNumber = Get-HighestNumberFromSpecs -SpecsDir $specsDir
if ($highestNumber -eq [long]::MaxValue) {
Write-Error "Error: feature number must be between 0 and $([long]::MaxValue), got '9223372036854775808'"
exit 1
}
$resolvedNumber = $highestNumber + 1
} elseif ($Number -notmatch '^[0-9]+$') {
Write-Error "Error: -Number must be an unsigned integer, got '$Number'"
exit 1
} elseif (-not [long]::TryParse($Number, [ref]$resolvedNumber)) {
Write-Error "Error: -Number must be between 0 and $([long]::MaxValue), got '$Number'"
exit 1
}
$featureNum = ('{0:000}' -f $Number)
$featureNum = ('{0:000}' -f $resolvedNumber)
$branchName = "$featureNum-$branchSuffix"
}
@@ -183,9 +196,9 @@ if ($branchName.Length -gt $maxBranchLength) {
$originalBranchName = $branchName
$branchName = "$featureNum-$truncatedSuffix"
Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit"
Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)"
Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)"
[Console]::Error.WriteLine("[specify] Warning: Branch name exceeded GitHub's 244-byte limit")
[Console]::Error.WriteLine("[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)")
[Console]::Error.WriteLine("[specify] Truncated to: $branchName ($($branchName.Length) bytes)")
}
$featureDir = Join-Path $specsDir $branchName
@@ -225,6 +238,13 @@ if (-not $DryRun) {
# Set environment variables for the current session
$env:SPECIFY_FEATURE = $branchName
$env:SPECIFY_FEATURE_DIRECTORY = $featureDir
$quotedBranchName = "'" + $branchName.Replace("'", "''") + "'"
$quotedFeatureDir = "'" + $featureDir.Replace("'", "''") + "'"
$featureAssignment = '$env:SPECIFY_FEATURE = ' + $quotedBranchName
$directoryAssignment = '$env:SPECIFY_FEATURE_DIRECTORY = ' + $quotedFeatureDir
[Console]::Error.WriteLine("# To persist: $featureAssignment")
[Console]::Error.WriteLine("# $directoryAssignment")
}
if ($Json) {
@@ -242,7 +262,7 @@ if ($Json) {
Write-Output "SPEC_FILE: $specFile"
Write-Output "FEATURE_NUM: $featureNum"
if (-not $DryRun) {
Write-Output "SPECIFY_FEATURE set to: $branchName"
Write-Output "SPECIFY_FEATURE_DIRECTORY set to: $featureDir"
Write-Output "# To persist in your shell: $featureAssignment"
Write-Output "# $directoryAssignment"
}
}

View File

@@ -4,7 +4,10 @@
[CmdletBinding()]
param(
[switch]$Json,
[switch]$Help
[switch]$Help,
# Capture extra positional arguments to match Bash/Python behavior.
[Parameter(ValueFromRemainingArguments = $true)]
[string[]]$RemainingArgs
)
$ErrorActionPreference = 'Stop'
@@ -21,7 +24,11 @@ if ($Help) {
. "$PSScriptRoot/common.ps1"
# Get all paths and variables from common functions
$paths = Get-FeaturePathsEnv
$paths = Get-FeaturePathsEnv -ReturnNullOnError
if (-not $paths) {
[Console]::Error.WriteLine("ERROR: Failed to resolve feature paths")
exit 1
}
# Ensure the feature directory exists
New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null

View File

@@ -3,21 +3,34 @@
[CmdletBinding()]
param(
[switch]$Json,
[switch]$Help
[switch]$Help,
[Parameter(ValueFromRemainingArguments = $true)]
[string[]]$RemainingArgs
)
$ErrorActionPreference = 'Stop'
# Help wins over unknown-argument validation to match the Bash/Python
# variants, which stop at --help and exit 0.
if ($Help) {
Write-Output "Usage: setup-tasks.ps1 [-Json] [-Help]"
exit 0
}
if ($RemainingArgs.Count -gt 0) {
[Console]::Error.WriteLine("ERROR: Unknown option '$($RemainingArgs[0])'")
exit 1
}
# Source common functions
. "$PSScriptRoot/common.ps1"
# Get feature paths
$paths = Get-FeaturePathsEnv
$paths = Get-FeaturePathsEnv -ReturnNullOnError
if (-not $paths) {
[Console]::Error.WriteLine("ERROR: Failed to resolve feature paths")
exit 1
}
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
@@ -45,8 +58,8 @@ if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' }
# Resolve tasks template through override stack
$tasksTemplate = Resolve-Template -TemplateName 'tasks-template' -RepoRoot $paths.REPO_ROOT
if (-not $tasksTemplate -or -not (Test-Path -LiteralPath $tasksTemplate -PathType Leaf)) {
$expectedCoreTemplate = Join-Path $paths.REPO_ROOT '.specify/templates/tasks-template.md'
[Console]::Error.WriteLine("ERROR: Tasks template not found for repository root: $($paths.REPO_ROOT)`nTemplate resolution order: overrides -> presets -> extensions -> core.`nExpected shared/core template location: $expectedCoreTemplate`nTo continue, verify whether 'tasks-template.md' is available in '.specify/templates/overrides/', preset templates, extension templates, or restore the shared/core templates (for example by re-running 'specify init') so that '.specify/templates/tasks-template.md' exists.")
[Console]::Error.WriteLine("ERROR: Could not resolve required tasks-template from the template override stack for $($paths.REPO_ROOT)")
[Console]::Error.WriteLine("Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template.")
exit 1
}
$tasksTemplate = (Resolve-Path -LiteralPath $tasksTemplate).Path

View File

@@ -84,7 +84,7 @@ def read_feature_json_feature_directory(repo_root: Path) -> str:
return ""
try:
data = json.loads(feature_json.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
except (OSError, UnicodeError, json.JSONDecodeError):
return ""
value = data.get("feature_directory") if isinstance(data, dict) else None
return value if isinstance(value, str) else ""
@@ -95,16 +95,17 @@ def _json_dump(data: dict[str, str]) -> str:
def persist_feature_json(repo_root: Path, feature_dir_value: str) -> None:
# Strip the repo root prefix lexically (no resolve()) to mirror the
# Bash/PowerShell helpers: with a symlinked <repo>/specs, resolve() would
# escape the repo and persist a machine-specific absolute path instead of
# the relative "specs/NNN-name" the other variants store.
value = feature_dir_value
try:
relative = Path(value)
if relative.is_absolute():
try:
value = relative.resolve().relative_to(repo_root.resolve()).as_posix()
except ValueError:
value = str(relative)
except OSError:
pass
relative = Path(value)
if relative.is_absolute():
try:
value = relative.relative_to(repo_root).as_posix()
except ValueError:
value = str(relative)
current = read_feature_json_feature_directory(repo_root)
if current == value:
@@ -112,9 +113,8 @@ def persist_feature_json(repo_root: Path, feature_dir_value: str) -> None:
specify_dir = repo_root / ".specify"
specify_dir.mkdir(parents=True, exist_ok=True)
(specify_dir / "feature.json").write_text(
_json_dump({"feature_directory": value}),
encoding="utf-8",
(specify_dir / "feature.json").write_bytes(
_json_dump({"feature_directory": value}).encode("utf-8")
)
@@ -182,6 +182,78 @@ def get_feature_paths(
)
def _sorted_preset_ids(presets_dir: Path) -> list[str]:
registry = presets_dir / ".registry"
if registry.is_file():
# Mirrors bash: any failure while reading or sorting the registry
# (invalid JSON, non-dict shapes, unorderable priority values) falls
# back to the directory scan below.
try:
data = json.loads(registry.read_text(encoding="utf-8"))
presets = data.get("presets", {})
return [
pid
for pid, meta in sorted(
presets.items(),
key=lambda kv: kv[1].get("priority", 10)
if isinstance(kv[1], dict)
else 10,
)
if isinstance(meta, dict) and meta.get("enabled", True) is not False
]
except Exception:
pass
try:
return sorted(
p.name
for p in presets_dir.iterdir()
if p.is_dir() and not p.name.startswith(".")
)
except OSError:
return []
def resolve_template(template_name: str, repo_root: Path) -> Path | None:
"""Resolve a template name to a file path using the priority stack.
Order (mirrors resolve_template in scripts/bash/common.sh):
1. .specify/templates/overrides/
2. .specify/presets/<preset-id>/templates/ (sorted by .registry priority)
3. .specify/extensions/<ext-id>/templates/ (hidden directories skipped)
4. .specify/templates/ (core)
"""
base = repo_root / ".specify" / "templates"
override = base / "overrides" / f"{template_name}.md"
if override.is_file():
return override
presets_dir = repo_root / ".specify" / "presets"
if presets_dir.is_dir():
for preset_id in _sorted_preset_ids(presets_dir):
candidate = presets_dir / preset_id / "templates" / f"{template_name}.md"
if candidate.is_file():
return candidate
ext_dir = repo_root / ".specify" / "extensions"
if ext_dir.is_dir():
try:
extensions = sorted(p for p in ext_dir.iterdir() if p.is_dir())
except OSError:
extensions = []
for ext in extensions:
if ext.name.startswith("."):
continue
candidate = ext / "templates" / f"{template_name}.md"
if candidate.is_file():
return candidate
core = base / f"{template_name}.md"
if core.is_file():
return core
return None
def get_invoke_separator(repo_root: Path) -> str:
integration_json = repo_root / ".specify" / "integration.json"
if not integration_json.is_file():

View File

@@ -0,0 +1,355 @@
#!/usr/bin/env python3
"""Create a new feature directory and spec file."""
from __future__ import annotations
import datetime
import json
import re
import shlex
import shutil
import sys
from dataclasses import dataclass
from pathlib import Path
try:
from common import get_repo_root, persist_feature_json, resolve_template
except ImportError: # pragma: no cover - direct execution from unusual cwd
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import get_repo_root, persist_feature_json, resolve_template
def _json_line(payload: object) -> str:
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
_STOP_WORDS = frozenset(
"""
i a an the to for of in on at by with from is are was were be been being
have has had do does did will would should could can may might must shall
this that these those my your our their want need add get set
""".split()
)
_MAX_BRANCH_LENGTH = 244
_MAX_FEATURE_NUMBER = 2**63 - 1
def _int64_from_digits(value: str) -> int | None:
normalized = value.lstrip("0") or "0"
maximum = str(_MAX_FEATURE_NUMBER)
if len(normalized) > len(maximum) or (
len(normalized) == len(maximum) and normalized > maximum
):
return None
return int(normalized, 10)
def _persistence_assignments(
branch_name: str, feature_dir: str, *, powershell: bool
) -> tuple[str, str]:
if powershell:
quoted_branch = "'" + branch_name.replace("'", "''") + "'"
quoted_dir = "'" + feature_dir.replace("'", "''") + "'"
return (
f"$env:SPECIFY_FEATURE = {quoted_branch}",
f"$env:SPECIFY_FEATURE_DIRECTORY = {quoted_dir}",
)
return (
f"export SPECIFY_FEATURE={shlex.quote(branch_name)}",
f"export SPECIFY_FEATURE_DIRECTORY={shlex.quote(feature_dir)}",
)
def _usage(argv0: str) -> str:
return (
f"Usage: {argv0} [--json] [--dry-run] [--allow-existing-branch] "
"[--short-name <name>] [--number N] [--timestamp] <feature_description>"
)
def _help_text(argv0: str) -> str:
return f"""{_usage(argv0)}
Options:
--json Output in JSON format
--dry-run Compute feature name and paths without creating directories or files
--allow-existing-branch Reuse an existing feature directory if it already exists
--short-name <name> Provide a custom short name (2-4 words) for the feature
--number N Specify branch number manually (overrides auto-detection)
--timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering
--help, -h Show this help message
Examples:
{argv0} 'Add user authentication system' --short-name 'user-auth'
{argv0} 'Implement OAuth2 integration for API' --number 5
{argv0} --timestamp --short-name 'user-auth' 'Add user authentication'
"""
@dataclass(frozen=True)
class Args:
json_mode: bool = False
dry_run: bool = False
allow_existing: bool = False
short_name: str = ""
branch_number: str = ""
use_timestamp: bool = False
description: str = ""
def _parse_args(argv: list[str], argv0: str) -> Args:
json_mode = False
dry_run = False
allow_existing = False
short_name = ""
branch_number = ""
use_timestamp = False
rest: list[str] = []
i = 0
while i < len(argv):
arg = argv[i]
if arg == "--json":
json_mode = True
elif arg == "--dry-run":
dry_run = True
elif arg == "--allow-existing-branch":
allow_existing = True
elif arg in {"--short-name", "--number"}:
if i + 1 >= len(argv) or argv[i + 1].startswith("--"):
print(f"Error: {arg} requires a value", file=sys.stderr)
raise SystemExit(1)
i += 1
if arg == "--short-name":
short_name = argv[i]
else:
branch_number = argv[i]
elif arg == "--timestamp":
use_timestamp = True
elif arg in {"--help", "-h"}:
sys.stdout.write(_help_text(argv0))
raise SystemExit(0)
else:
rest.append(arg)
i += 1
description = " ".join(rest).strip()
if not description:
if rest:
print(
"Error: Feature description cannot be empty or contain only whitespace",
file=sys.stderr,
)
else:
print(_usage(argv0), file=sys.stderr)
raise SystemExit(1)
return Args(
json_mode=json_mode,
dry_run=dry_run,
allow_existing=allow_existing,
short_name=short_name,
branch_number=branch_number,
use_timestamp=use_timestamp,
description=description,
)
def _clean_branch_name(name: str) -> str:
cleaned = re.sub(r"[^a-z0-9]", "-", name.lower())
cleaned = re.sub(r"-+", "-", cleaned)
return cleaned.strip("-")
def _generate_branch_name(description: str) -> str:
clean = re.sub(r"[^a-z0-9]", " ", description.lower())
meaningful: list[str] = []
for word in clean.split():
if word in _STOP_WORDS:
continue
if len(word) >= 3:
meaningful.append(word)
# Keep short words that appear as an uppercase acronym in the original,
# mirroring the bash twin's case-sensitive `grep -qw` check.
elif re.search(
rf"(?<![0-9A-Za-z_]){re.escape(word.upper())}(?![0-9A-Za-z_])",
description,
):
meaningful.append(word)
if meaningful:
max_words = 4 if len(meaningful) == 4 else 3
return "-".join(meaningful[:max_words])
cleaned = _clean_branch_name(description)
return "-".join([part for part in cleaned.split("-") if part][:3])
def _get_highest_from_specs(specs_dir: Path) -> int:
highest = 0
if not specs_dir.is_dir():
return highest
for entry in specs_dir.iterdir():
if not entry.is_dir():
continue
name = entry.name
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
if re.match(r"^[0-9]{3,}-", name) and not re.match(
r"^[0-9]{8}-[0-9]{6}-", name
):
number = _int64_from_digits(re.match(r"^[0-9]+", name).group())
if number is not None:
highest = max(highest, number)
return highest
def main(argv: list[str] | None = None) -> int:
argv0 = sys.argv[0]
args = _parse_args(list(argv if argv is not None else sys.argv[1:]), argv0)
repo_root = get_repo_root(Path(__file__))
specs_dir = repo_root / "specs"
if not args.dry_run:
specs_dir.mkdir(parents=True, exist_ok=True)
if args.short_name:
branch_suffix = _clean_branch_name(args.short_name)
else:
branch_suffix = _generate_branch_name(args.description)
branch_number = args.branch_number
if args.use_timestamp and branch_number:
print(
"[specify] Warning: --number is ignored when --timestamp is used",
file=sys.stderr,
)
branch_number = ""
if args.use_timestamp:
feature_num = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
else:
if branch_number:
# Mirrors bash: $((10#$BRANCH_NUMBER)) only accepts unsigned
# decimal digits, rejecting signs, whitespace, and other
# characters that int() would otherwise tolerate.
if not re.fullmatch(r"[0-9]+", branch_number):
print(
"Error: --number must be an unsigned integer, "
f"got '{branch_number}'",
file=sys.stderr,
)
return 1
number = _int64_from_digits(branch_number)
if number is None:
print(
"Error: --number must be between 0 and "
f"{_MAX_FEATURE_NUMBER}, got '{branch_number}'",
file=sys.stderr,
)
return 1
else:
number = _get_highest_from_specs(specs_dir) + 1
if number > _MAX_FEATURE_NUMBER:
rejected_number = branch_number or str(number)
number_label = "--number" if branch_number else "feature number"
print(
f"Error: {number_label} must be between 0 and "
f"{_MAX_FEATURE_NUMBER}, got '{rejected_number}'",
file=sys.stderr,
)
return 1
feature_num = f"{number:03d}"
max_suffix_length = _MAX_BRANCH_LENGTH - (len(feature_num) + 1)
if max_suffix_length <= 0:
print("Error: feature number is too long for a branch name", file=sys.stderr)
return 1
branch_name = f"{feature_num}-{branch_suffix}"
# GitHub enforces a 244-byte limit on branch names.
if len(branch_name) > _MAX_BRANCH_LENGTH:
truncated_suffix = re.sub(r"-$", "", branch_suffix[:max_suffix_length])
original_branch_name = branch_name
branch_name = f"{feature_num}-{truncated_suffix}"
print(
"[specify] Warning: Branch name exceeded GitHub's 244-byte limit",
file=sys.stderr,
)
print(
f"[specify] Original: {original_branch_name} "
f"({len(original_branch_name)} bytes)",
file=sys.stderr,
)
print(
f"[specify] Truncated to: {branch_name} ({len(branch_name)} bytes)",
file=sys.stderr,
)
feature_dir = specs_dir / branch_name
spec_file = feature_dir / "spec.md"
if not args.dry_run:
if feature_dir.is_dir() and not args.allow_existing:
if args.use_timestamp:
print(
f"Error: Feature directory '{feature_dir}' already exists. "
"Rerun to get a new timestamp or use a different --short-name.",
file=sys.stderr,
)
else:
print(
f"Error: Feature directory '{feature_dir}' already exists. "
"Please use a different feature name or specify a different "
"number with --number.",
file=sys.stderr,
)
return 1
feature_dir.mkdir(parents=True, exist_ok=True)
if not spec_file.is_file():
template = resolve_template("spec-template", repo_root)
if template is not None and template.is_file():
shutil.copy(template, spec_file)
else:
print(
"Warning: Spec template not found; created empty spec file",
file=sys.stderr,
)
spec_file.touch()
# Persist to .specify/feature.json so downstream commands can find the feature.
persist_feature_json(repo_root, f"specs/{branch_name}")
# Inform the user how to set feature state in their own shell.
feature_assignment, directory_assignment = _persistence_assignments(
branch_name,
str(feature_dir),
powershell=sys.platform == "win32",
)
print(f"# To persist: {feature_assignment}", file=sys.stderr)
print(f"# {directory_assignment}", file=sys.stderr)
if args.json_mode:
payload: dict[str, object] = {
"BRANCH_NAME": branch_name,
"SPEC_FILE": str(spec_file),
"FEATURE_NUM": feature_num,
}
if args.dry_run:
payload["DRY_RUN"] = True
sys.stdout.write(_json_line(payload))
else:
print(f"BRANCH_NAME: {branch_name}")
print(f"SPEC_FILE: {spec_file}")
print(f"FEATURE_NUM: {feature_num}")
if not args.dry_run:
print(f"# To persist in your shell: {feature_assignment}")
print(f"# {directory_assignment}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Setup implementation plan for a feature."""
from __future__ import annotations
import json
import shutil
import sys
from pathlib import Path
try:
from common import get_feature_paths, resolve_template
except ImportError: # pragma: no cover - direct execution from unusual cwd
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import get_feature_paths, resolve_template
def _json_line(payload: object) -> str:
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
def _help_text(argv0: str) -> str:
return f"""Usage: {argv0} [--json]
--json Output results in JSON format
--help Show this help message
"""
def main(argv: list[str] | None = None) -> int:
args = list(argv if argv is not None else sys.argv[1:])
json_mode = False
for arg in args:
if arg == "--json":
json_mode = True
elif arg in {"--help", "-h"}:
sys.stdout.write(_help_text(sys.argv[0]))
return 0
# Other arguments are accepted and silently ignored, matching setup-plan.sh.
try:
paths = get_feature_paths(script_file=Path(__file__))
except SystemExit as exc:
if exc.code == 0:
return 0
print("ERROR: Failed to resolve feature paths", file=sys.stderr)
return int(exc.code) if isinstance(exc.code, int) else 1
paths.feature_dir.mkdir(parents=True, exist_ok=True)
# Status messages go to stderr in JSON mode so stdout stays pure JSON.
status_stream = sys.stderr if json_mode else sys.stdout
if paths.impl_plan.is_file():
print(
f"Plan already exists at {paths.impl_plan}, skipping template copy",
file=status_stream,
)
else:
template = resolve_template("plan-template", paths.repo_root)
if template is not None and template.is_file():
shutil.copy(template, paths.impl_plan)
print(f"Copied plan template to {paths.impl_plan}", file=status_stream)
else:
print("Warning: Plan template not found", file=status_stream)
paths.impl_plan.touch()
if json_mode:
sys.stdout.write(
_json_line(
{
"FEATURE_SPEC": str(paths.feature_spec),
"IMPL_PLAN": str(paths.impl_plan),
"SPECS_DIR": str(paths.feature_dir),
"BRANCH": paths.current_branch,
}
)
)
else:
print(f"FEATURE_SPEC: {paths.feature_spec}")
print(f"IMPL_PLAN: {paths.impl_plan}")
print(f"SPECS_DIR: {paths.feature_dir}")
print(f"BRANCH: {paths.current_branch}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""Check tasks prerequisites and resolve the tasks template."""
from __future__ import annotations
import json
import sys
from pathlib import Path
try:
from common import (
FeaturePaths,
format_speckit_command,
get_feature_paths,
resolve_template,
)
except ImportError: # pragma: no cover - direct execution from unusual cwd
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import (
FeaturePaths,
format_speckit_command,
get_feature_paths,
resolve_template,
)
def _json_line(payload: object) -> str:
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
def _help_text(argv0: str) -> str:
return f"""Usage: {argv0} [--json]
--json Output results in JSON format
--help Show this help message
"""
def _dir_has_entries(path: Path) -> bool:
try:
return path.is_dir() and any(path.iterdir())
except OSError:
return False
def _available_docs(paths: FeaturePaths) -> list[str]:
docs: list[str] = []
if paths.research.is_file():
docs.append("research.md")
if paths.data_model.is_file():
docs.append("data-model.md")
if _dir_has_entries(paths.contracts_dir):
docs.append("contracts/")
if paths.quickstart.is_file():
docs.append("quickstart.md")
return docs
def _check_file(path: Path, description: str) -> None:
marker = "" if path.is_file() else ""
print(f" {marker} {description}")
def _check_dir(path: Path, description: str) -> None:
marker = "" if _dir_has_entries(path) else ""
print(f" {marker} {description}")
def main(argv: list[str] | None = None) -> int:
json_mode = False
for arg in list(argv if argv is not None else sys.argv[1:]):
if arg == "--json":
json_mode = True
elif arg in {"--help", "-h"}:
sys.stdout.write(_help_text(sys.argv[0]))
return 0
else:
print(f"ERROR: Unknown option '{arg}'", file=sys.stderr)
return 1
try:
paths = get_feature_paths(script_file=Path(__file__))
except SystemExit as exc:
if exc.code == 0:
return 0
print("ERROR: Failed to resolve feature paths", file=sys.stderr)
return int(exc.code) if isinstance(exc.code, int) else 1
if not paths.impl_plan.is_file():
print(f"ERROR: plan.md not found in {paths.feature_dir}", file=sys.stderr)
print(
f"Run {format_speckit_command('plan', paths.repo_root)} first to create the implementation plan.",
file=sys.stderr,
)
return 1
if not paths.feature_spec.is_file():
print(f"ERROR: spec.md not found in {paths.feature_dir}", file=sys.stderr)
print(
f"Run {format_speckit_command('specify', paths.repo_root)} first to create the feature structure.",
file=sys.stderr,
)
return 1
docs = _available_docs(paths)
tasks_template = resolve_template("tasks-template", paths.repo_root)
if tasks_template is None or not tasks_template.is_file():
print(
"ERROR: Could not resolve required tasks-template from the template "
f"override stack for {paths.repo_root}",
file=sys.stderr,
)
print(
"Template 'tasks-template' was not found in any supported location "
"(overrides, presets, extensions, or shared core). Add an override at "
".specify/templates/overrides/tasks-template.md, or run 'specify init' "
"/ reinstall shared infra to restore the core "
".specify/templates/tasks-template.md template.",
file=sys.stderr,
)
return 1
if json_mode:
sys.stdout.write(
_json_line(
{
"FEATURE_DIR": str(paths.feature_dir),
"AVAILABLE_DOCS": docs,
"TASKS_TEMPLATE": str(tasks_template),
}
)
)
else:
print(f"FEATURE_DIR: {paths.feature_dir}")
print(f"TASKS_TEMPLATE: {tasks_template}")
print("AVAILABLE_DOCS:")
_check_file(paths.research, "research.md")
_check_file(paths.data_model, "data-model.md")
_check_dir(paths.contracts_dir, "contracts/")
_check_file(paths.quickstart, "quickstart.md")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -140,10 +140,9 @@ def _install_shared_infra(
"""Install shared infrastructure files into *project_path*.
Copies ``.specify/scripts/<variant>/`` and ``.specify/templates/`` from
the bundled core_pack or source checkout, where ``<variant>`` is
``bash`` when *script_type* is ``"sh"``, ``python`` when it is ``"py"``,
and ``powershell`` when it is ``"ps"``. Tracks all installed files in
``speckit.manifest.json``.
the bundled core_pack or source checkout. ``sh`` installs Bash, ``ps``
installs PowerShell, and ``py`` installs Python plus the platform shell
fallback. Tracks all installed files in ``speckit.manifest.json``.
Shared scripts and page templates are processed to resolve
``__SPECKIT_COMMAND_<NAME>__`` placeholders using *invoke_separator*

View File

@@ -18,6 +18,7 @@ ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset({"devin", "grok", "trae", "zed"}
CONDITIONAL_SLASH_AGENTS: frozenset[str] = frozenset(
{
"agy",
"bob",
"claude",
"copilot",
"cursor-agent",

View File

@@ -7,7 +7,6 @@ command files into agent-specific directories in the correct format.
"""
import os
import platform
import re
from copy import deepcopy
from pathlib import Path
@@ -114,13 +113,24 @@ class CommandRegistrar:
if not content.startswith("---"):
return {}, content
# Find second ---
end_marker = content.find("---", 3)
if end_marker == -1:
# The closing delimiter is a line that is exactly ``---`` (a YAML
# document separator), not any ``---`` substring. Scanning with
# ``content.find("---", 3)`` stops at the first ``---`` *anywhere* —
# including one embedded in a frontmatter value (e.g. a description like
# "Separate sections with ---") or inside an indented literal block —
# which truncates the frontmatter and spills the remainder into the
# body. Match on line boundaries instead, mirroring the line-anchored
# scan in ``VibeIntegration._inject_frontmatter_flag``.
lines = content.splitlines(keepends=True)
end_line = next(
(i for i in range(1, len(lines)) if lines[i].rstrip() == "---"),
None,
)
if end_line is None:
return {}, content
frontmatter_str = content[3:end_marker].strip()
body = content[end_marker + 3 :].strip()
frontmatter_str = "".join(lines[1:end_line]).strip()
body = "".join(lines[end_line + 1 :]).strip()
try:
frontmatter = yaml.safe_load(frontmatter_str) or {}
@@ -475,26 +485,19 @@ class CommandRegistrar:
init_opts = {}
script_variant = init_opts.get("script")
if script_variant not in {"sh", "ps"}:
fallback_order = []
default_variant = (
"ps" if platform.system().lower().startswith("win") else "sh"
if scripts:
from specify_cli.integrations.base import IntegrationBase
script_variant = IntegrationBase.select_script_variant(
script_variant, scripts
)
secondary_variant = "sh" if default_variant == "ps" else "ps"
if default_variant in scripts:
fallback_order.append(default_variant)
if secondary_variant in scripts:
fallback_order.append(secondary_variant)
for key in scripts:
if key not in fallback_order:
fallback_order.append(key)
script_variant = fallback_order[0] if fallback_order else None
script_command = scripts.get(script_variant) if script_variant else None
if script_command:
if script_variant == "py":
script_command = IntegrationBase.build_python_invocation(
script_command, project_root
)
script_command = script_command.replace("{ARGS}", "$ARGUMENTS")
body = body.replace("{SCRIPT}", script_command)
@@ -637,6 +640,37 @@ class CommandRegistrar:
is_cline_ext = agent_name == "cline" and source_id != "core"
source_root = source_dir.resolve()
# Resolve the command-reference separator for the file THIS registrar
# is about to write. The separator must match the *output layout* the
# registrar produces for this agent — not the project's persisted
# ``ai_skills`` flag, and not unrelated sibling directories on disk. A
# skill scaffold ("/SKILL.md") uses the skills separator; any
# command-layout output (".md", ".agent.md", ".toml", …) uses the
# command separator.
#
# This holds for the *active* agent too. Dual-layout agents (Bob,
# Copilot) write their skills via their own setup()/skills path, so
# ``register_commands`` only ever emits their command-layout files.
# Deriving the separator from ``ai_skills`` would render such a
# ``.bob/commands/*.md`` (or ``.github/agents/*.agent.md``) file with
# ``/speckit-*`` whenever that agent is active in skills mode — even
# though a command-layout file must use ``/speckit.*``. Deriving it
# from the agent's static output config avoids that mismatch and stays
# correct when a stale ``.bob/skills`` directory coexists with
# ``.bob/commands``.
_sep = agent_config.get("invoke_separator", ".")
try:
from specify_cli.integrations import get_integration # noqa: PLC0415
_integ = get_integration(agent_name)
if _integ is not None:
registrar_writes_skills = (
agent_config.get("extension") == "/SKILL.md"
)
_sep = _integ.invoke_separator_for_mode(registrar_writes_skills)
except Exception:
pass
for cmd_info in commands:
cmd_name = cmd_info["name"]
aliases = cmd_info.get("aliases", [])
@@ -709,13 +743,18 @@ class CommandRegistrar:
)
# Resolve __SPECKIT_COMMAND_*__ tokens using the agent's invoke separator.
# The separator is sourced from agent_config (populated by _build_agent_configs,
# which propagates each integration's invoke_separator class attribute).
# For dual-layout agents (e.g. Bob) the separator differs between the
# skills and command layouts, so a single static AGENT_CONFIGS value is
# insufficient. ``_sep`` (resolved above) is derived from the *output
# layout* this registrar writes — a "/SKILL.md" scaffold uses the skills
# separator, any command-layout file uses the command separator — not
# the project's persisted ai_skills state. Single-layout agents fall back
# to the static AGENT_CONFIGS value unchanged (invoke_separator_for_mode
# default).
# Deferred import of IntegrationBase avoids a circular import at module load
# (base.py itself imports CommandRegistrar lazily).
from specify_cli.integrations.base import IntegrationBase # noqa: PLC0415
_sep = agent_config.get("invoke_separator", ".")
body = IntegrationBase.resolve_command_refs(body, _sep)
output_name = self._compute_output_name(agent_name, cmd_name, agent_config)

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

@@ -43,7 +43,7 @@ class Scope(str, Enum):
BUILTIN_DEFAULT_STACK: tuple[dict[str, Any], ...] = (
{"id": "default", "url": "builtin://default", "priority": 1,
"install_policy": InstallPolicy.INSTALL_ALLOWED.value},
{"id": "community", "url": "builtin://community", "priority": 2,
{"id": "community", "url": "builtin://community", "priority": 20,
"install_policy": InstallPolicy.DISCOVERY_ONLY.value},
)
@@ -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

@@ -122,6 +122,11 @@ 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())

View File

@@ -15,25 +15,26 @@ from pathlib import Path
from urllib.parse import ParseResult, urlparse
from urllib.request import url2pathname
from ..._assets import _locate_core_pack, _repo_root
from .. import BundlerError
from ..lib.yamlio import loads_json
from ..models.catalog import CatalogSource
from ..models.manifest import ComponentRef
# Built-in catalog payloads ship empty by default; a host distribution can
# replace these with curated content. Keeping them here makes ``search``/``info``
# work fully offline against the default stack.
COMMUNITY_CATALOG_URL = (
"https://raw.githubusercontent.com/github/spec-kit/main/"
"bundles/catalog.community.json"
)
# The default catalog is reserved for first-party bundles. The community
# catalog is loaded from the repository online and from the packaged snapshot
# offline so discovery remains useful without network access.
_BUILTIN_CATALOGS: dict[str, dict] = {
"builtin://default": {
"schema_version": "1.0",
"catalog_url": "builtin://default",
"bundles": {},
},
"builtin://community": {
"schema_version": "1.0",
"catalog_url": "builtin://community",
"bundles": {},
},
}
HTTP_TIMEOUT_SECONDS = 10
@@ -95,6 +96,18 @@ def _validate_remote_url(source_id: str, url: str) -> None:
)
def _load_packaged_community_catalog() -> dict:
core_pack = _locate_core_pack()
path = (
core_pack / "bundles" / "catalog.community.json"
if core_pack is not None
else _repo_root() / "bundles" / "catalog.community.json"
)
if not path.is_file():
raise BundlerError(f"Bundled community catalog not found: {path}")
return loads_json(path.read_text(encoding="utf-8"), origin=str(path))
def make_catalog_fetcher(*, allow_network: bool = True):
"""Return a fetcher callable suitable for :class:`CatalogStack`.
@@ -108,6 +121,10 @@ def make_catalog_fetcher(*, allow_network: bool = True):
scheme = parsed.scheme.lower()
if scheme == "builtin":
if url == "builtin://community":
if allow_network:
return _http_get_json(source.id, COMMUNITY_CATALOG_URL)
return _load_packaged_community_catalog()
payload = _BUILTIN_CATALOGS.get(url)
if payload is None:
raise BundlerError(f"Unknown built-in catalog '{url}'.")

View File

@@ -149,7 +149,10 @@ class CatalogStackBase:
)
try:
priority = int(raw_priority)
except (TypeError, ValueError):
except (TypeError, ValueError, OverflowError):
# OverflowError: int(float("inf")) — a YAML ``priority: .inf``
# would otherwise escape as an uncaught traceback instead of the
# clean validation error.
raise self._validation_error(
f"Invalid catalog config {config_path}: "
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "

View File

@@ -86,7 +86,7 @@ def register(app: typer.Typer) -> None:
help="Name for your new project directory (optional if using --here, or use '.' for current directory)",
),
script_type: str = typer.Option(
None, "--script", help="Script type to use: sh or ps"
None, "--script", help="Script type to use: sh, ps, or py"
),
ignore_agent_tools: bool = typer.Option(
False,
@@ -458,6 +458,7 @@ def register(app: typer.Typer) -> None:
script_type=selected_script,
raw_options=integration_options,
parsed_options=integration_parsed_options or None,
project_root=project_path,
)
_write_integration_json(
project_path,
@@ -478,7 +479,7 @@ def register(app: typer.Typer) -> None:
tracker=tracker,
force=force,
invoke_separator=resolved_integration.effective_invoke_separator(
integration_parsed_options
integration_parsed_options, project_root=project_path
),
)
tracker.complete(
@@ -532,10 +533,8 @@ def register(app: typer.Typer) -> None:
"feature_numbering": "sequential",
"speckit_version": get_speckit_version(),
}
from ..integrations.base import SkillsIntegration as _SkillsPersist
if isinstance(resolved_integration, _SkillsPersist) or getattr(
resolved_integration, "_skills_mode", False
if resolved_integration.is_skills_mode(
integration_parsed_options or None, project_root=project_path
):
init_opts["ai_skills"] = True
save_init_options(project_path, init_opts)
@@ -683,11 +682,9 @@ def register(app: typer.Typer) -> None:
steps_lines.append("1. You're already in the project directory!")
step_num = 2
from ..integrations.base import SkillsIntegration as _SkillsInt
_is_skills_integration = isinstance(
resolved_integration, _SkillsInt
) or getattr(resolved_integration, "_skills_mode", False)
_is_skills_integration = resolved_integration.is_skills_mode(
integration_parsed_options or None, project_root=project_path
)
codex_skill_mode = selected_ai == "codex" and _is_skills_integration
zcode_skill_mode = selected_ai == "zcode" and _is_skills_integration
@@ -703,6 +700,8 @@ 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
or zcode_skill_mode
@@ -715,6 +714,7 @@ def register(app: typer.Typer) -> None:
or devin_skill_mode
or zed_skill_mode
or grok_skill_mode
or bob_skill_mode
)
if codex_skill_mode:
@@ -752,6 +752,11 @@ def register(app: typer.Typer) -> None:
f"{step_num}. Start Grok Build in this project directory; spec-kit skills were installed to [cyan].grok/skills[/cyan]"
)
step_num += 1
if bob_skill_mode:
steps_lines.append(
f"{step_num}. Start Bob in this project directory; spec-kit skills were installed to [cyan].bob/skills[/cyan]"
)
step_num += 1
usage_label = "skills" if native_skill_mode else "slash commands"
from .._invocation_style import (
@@ -772,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

@@ -9,11 +9,13 @@ without bloating the core framework.
from __future__ import annotations
import copy
import errno
import hashlib
import json
import os
import re
import shutil
import stat
import tempfile
import zipfile
from dataclasses import dataclass
@@ -101,6 +103,51 @@ def _load_core_command_names() -> frozenset[str]:
CORE_COMMAND_NAMES = _load_core_command_names()
def _fsync_fd(fd: int) -> None:
"""Sync a file descriptor, raising on real storage errors."""
try:
os.fsync(fd)
except AttributeError:
return
except NotImplementedError:
return
except OSError as exc:
if exc.errno in {errno.ENOTSUP, errno.EOPNOTSUPP, errno.EINVAL, errno.EBADF}:
return
raise
def _fsync_directory(path: Path) -> None:
"""Sync a directory when the platform supports it."""
if not path.exists():
return
if os.name == "nt":
return
try:
dir_fd = os.open(str(path), os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
except (AttributeError, NotImplementedError):
return
except OSError as exc:
if exc.errno in {errno.ENOTSUP, errno.EOPNOTSUPP, errno.EINVAL, errno.EBADF}:
return
try:
dir_fd = os.open(str(path), os.O_RDONLY)
except (AttributeError, NotImplementedError):
return
except OSError as exc2:
if exc2.errno in {errno.ENOTSUP, errno.EOPNOTSUPP, errno.EINVAL, errno.EBADF}:
return
raise
try:
_fsync_fd(dir_fd)
finally:
try:
os.close(dir_fd)
except OSError:
# Cleanup after an fsync failure should not mask the original error.
pass
class ExtensionError(Exception):
"""Base exception for extension-related errors."""
@@ -136,7 +183,7 @@ def normalize_priority(value: Any, default: int = DEFAULT_HOOK_PRIORITY) -> int:
return default
try:
priority = int(value)
except (TypeError, ValueError):
except (TypeError, ValueError, OverflowError):
return default
return priority if priority >= 1 else default
@@ -699,6 +746,55 @@ class ExtensionManager:
self.extensions_dir = project_root / ".specify" / "extensions"
self.registry = ExtensionRegistry(self.extensions_dir)
def _rescue_staging_dir(self, extension_id: str) -> Path:
"""Fixed-length staging directory path for a preserved-config rescue.
The extension ID can be arbitrarily long (manifest validation caps only
the character set, not the length), so embedding it verbatim in a single
path component could push the ``.rescue-staging-<id>`` directory past a
filesystem's per-component byte limit and make every reinstall after
``--keep-config`` fail with ``ENAMETOOLONG`` even though the extension
installs fine at ``dest_dir``. Hash the ID to a fixed-length suffix so
the component length is bounded regardless of ID length.
"""
digest = hashlib.sha256(extension_id.encode("utf-8")).hexdigest()[:16]
return self.extensions_dir / f".rescue-staging-{digest}"
@staticmethod
def _has_keep_config_marker(directory: Path) -> bool:
"""Return True when *directory* contains a valid ``.keep-config`` marker.
The marker is a regular (non-symlink) file written by
``remove(..., keep_config=True)`` to record explicit provenance. Its
content is intentionally empty — only presence matters, not content.
The symlink guard prevents a crafted symlink from fooling the check.
"""
marker = directory / ".keep-config"
return marker.is_file() and not marker.is_symlink()
@staticmethod
def _is_legacy_keep_config_leftover(directory: Path) -> bool:
"""Return True for the pre-marker ``remove(..., keep_config=True)`` layout.
Older CLI releases preserved only top-level config files and removed every
other entry, but they did not write ``.keep-config``. Recognize that exact
config-only leftover so upgrades still preserve user config, while
excluding partially-failed installs that still contain copied payload such
as ``extension.yml`` or command directories.
"""
if not directory.is_dir() or directory.is_symlink():
return False
has_config = False
for entry in directory.iterdir():
if entry.name.endswith(("-config.yml", "-config.local.yml")) and (
entry.is_file() or entry.is_symlink()
):
has_config = True
continue
return False
return has_config
@staticmethod
def _collect_manifest_command_names(manifest: ExtensionManifest) -> Dict[str, str]:
"""Collect command and alias names declared by a manifest.
@@ -1428,12 +1524,421 @@ class ExtensionManager:
backup_config_dir.unlink()
did_remove = self.remove(manifest.id)
# Load and validate .extensionignore BEFORE reading/creating the rescue
# staging directory (and thus before deleting dest_dir). The loader can
# raise ValidationError (invalid UTF-8) or OSError; doing it first means
# such a failure aborts while the kept config is still authoritative in
# its documented location, rather than leaving a freshly published
# staging copy that a later retry (after the user edits the kept config)
# would reload and use to overwrite the newer bytes. Any staging left by
# an earlier destructive attempt is intentionally left intact here.
ignore_fn = self._load_extensionignore(source_dir)
# Rescue any config files left behind by a prior `remove --keep-config`.
# When an extension is removed with --keep-config, it is no longer in
# the registry but its config files remain in dest_dir. A subsequent
# plain (non-force) install would delete that directory unconditionally,
# silently discarding the preserved config. We read those files into
# memory and also write a durable staging copy outside dest_dir so
# that a partial rmtree, failed copytree, or partial restore cannot
# permanently discard the user's original bytes on a retry. The
# staging dir is removed only after every config has been successfully
# restored.
stranded_configs: dict[str, tuple[bytes, int]] = {}
rescue_staging_dir = self._rescue_staging_dir(manifest.id)
# A staging directory is trusted only when this completion marker is
# present. The marker is written after every staged file is complete
# and removed before the non-atomic cleanup, so a crash mid-staging or
# mid-cleanup can never leave a partial directory that a retry mistakes
# for a complete durable backup.
rescue_complete_marker = rescue_staging_dir / ".rescue-complete"
staging_is_complete = (
rescue_staging_dir.is_dir()
and not rescue_staging_dir.is_symlink()
and rescue_complete_marker.is_file()
and not rescue_complete_marker.is_symlink()
)
if staging_is_complete and not self.registry.is_installed(manifest.id):
# A previous install attempt staged the configs but never
# completed cleanly. Reload from the durable backup so the
# original bytes are used on retry rather than whatever
# mixture of packaged defaults and partial restores remains
# on disk. Only load non-symlinked files whose names match
# the two recognised config suffixes so a tampered staging
# directory cannot inject arbitrary files.
#
# A complete staging directory proves only that staging finished,
# not that dest_dir was ever modified: a crash after staging was
# synced but before the rmtree below leaves the live kept config
# intact. If the user then edits that live config before retrying,
# blindly preferring the staged bytes would silently overwrite the
# newer config. The staged and live copies are indistinguishable
# in provenance from disk alone (a genuine post-crash edit vs. a
# packaged default written by a partially-completed copytree), so
# when a live config disagrees with its staged copy we must not
# silently pick either — preserve both and abort, letting the user
# resolve it. dest_dir is still untouched here, so raising is safe.
def _recognized_config_names(
directory: Path, *, follow_symlinks: bool = True
) -> set[str]:
names: set[str] = set()
if not directory.is_dir():
return names
for entry in directory.iterdir():
if not entry.name.endswith(
("-config.yml", "-config.local.yml")
):
continue
if follow_symlinks:
if entry.is_file() and not entry.is_symlink():
names.add(entry.name)
else:
# Include symlinks without following them so that
# live-only symlinked configs are detected and
# preserved rather than silently deleted.
if entry.is_file() or entry.is_symlink():
names.add(entry.name)
return names
conflicting: set[str] = set()
staged_names = _recognized_config_names(rescue_staging_dir)
live_names = _recognized_config_names(
dest_dir, follow_symlinks=False
)
def _matches_source_config_baseline(config_name: str) -> bool:
source_file = source_dir / config_name
live_file = dest_dir / config_name
if source_file.is_symlink() or live_file.is_symlink():
return False
if not source_file.is_file() or not live_file.is_file():
return False
try:
source_stat = source_file.stat()
source_bytes = source_file.read_bytes()
live_stat = live_file.stat()
live_bytes = live_file.read_bytes()
except OSError:
return False
return live_bytes == source_bytes and stat.S_IMODE(
live_stat.st_mode
) == stat.S_IMODE(source_stat.st_mode)
# A live-only config created after the interrupted attempt is not
# enumerated by staging, so without this it would be silently
# deleted by the rmtree below and its bytes lost. Live-only files
# that still match the current package baseline are safe: they were
# copied by the interrupted install and can be recreated on retry.
# Only truly divergent live-only configs are conflicts.
live_only = live_names - staged_names
conflicting.update(
name
for name in live_only
if not _matches_source_config_baseline(name)
)
# Load original permission bits from the sidecar JSON written by
# the staging step. Staged files are kept at mode 0o600 so that
# rmtree always succeeds on Windows, so staged_stat.st_mode would
# always be 0o600 and must not be used for mode comparisons or
# restoration; the sidecar records the true original mode.
rescue_modes_file = rescue_staging_dir / ".rescue-modes.json"
_staged_modes: dict[str, int] = {}
if rescue_modes_file.is_file() and not rescue_modes_file.is_symlink():
try:
_loaded_modes = json.loads(rescue_modes_file.read_bytes())
except (OSError, ValueError):
# Ignore unreadable/invalid sidecar metadata and fall back
# to each staged file's mode for compatibility.
pass
else:
# json.loads() succeeds for any valid JSON document, so a
# sidecar containing e.g. `[]` or a string would otherwise
# crash later at _staged_modes.get() or stat.S_IMODE().
# Accept only a mapping of string filenames to integer modes
# (bool is rejected despite subclassing int); anything else
# falls back to each staged file's own mode.
if isinstance(_loaded_modes, dict) and all(
isinstance(name, str)
and isinstance(recorded_mode, int)
and not isinstance(recorded_mode, bool)
for name, recorded_mode in _loaded_modes.items()
):
_staged_modes = _loaded_modes
for staged_name in sorted(staged_names):
staged_file = rescue_staging_dir / staged_name
staged_stat = staged_file.stat()
staged_bytes = staged_file.read_bytes()
# Prefer the sidecar-recorded mode; fall back to the staged
# file's own mode for backwards-compat with staging dirs
# written before the sidecar was introduced.
staged_mode = _staged_modes.get(
staged_name, stat.S_IMODE(staged_stat.st_mode)
)
live_file = dest_dir / staged_name
if live_file.is_symlink():
# A user may have replaced the live config with a symlink
# after the interrupted attempt. It cannot be compared by
# bytes/mode against the staged copy, and the rmtree below
# would silently delete this newer choice and restore the
# older staged file. Treat any live symlink as a conflict so
# both are preserved and the user resolves it.
conflicting.add(staged_name)
elif live_file.is_file():
# A live config that cannot be read or stat'ed must not be
# treated as non-conflicting: the rmtree below would delete
# it and restore the stale staged copy. Abort while dest_dir
# is untouched so no newer or permission-restricted config is
# lost. Divergence also includes permission-only edits (for
# example tightening a secret-bearing config from 0644 to
# 0600), which byte equality alone would miss and then revert.
try:
live_stat = live_file.stat()
live_bytes = live_file.read_bytes()
except OSError:
conflicting.add(staged_name)
else:
if live_bytes != staged_bytes or stat.S_IMODE(
live_stat.st_mode
) != staged_mode:
conflicting.add(staged_name)
stranded_configs[staged_name] = (staged_bytes, staged_mode)
if conflicting:
# Split into two cases for accurate user guidance: files that
# exist in both locations but have diverged, and files that
# exist only in the live directory with no rescue-backup copy.
both_diverged = conflicting - live_only
live_only_conflict = conflicting & live_only
msg_parts: list[str] = [
f"Preserved extension config conflict for '{manifest.id}':"
]
if both_diverged:
names = ", ".join(sorted(both_diverged))
msg_parts.append(
f"The current config(s) ({names}) in {dest_dir} differ"
f" from their rescued backup in {rescue_staging_dir}."
" Both copies have been preserved."
)
if live_only_conflict:
names = ", ".join(sorted(live_only_conflict))
msg_parts.append(
f"The config(s) ({names}) exist only in {dest_dir}"
f" with no counterpart in the rescued backup at"
f" {rescue_staging_dir}."
)
msg_parts.append(
f"Reconcile {dest_dir} and {rescue_staging_dir} to the"
f" desired final state, delete {rescue_staging_dir},"
" then reinstall."
)
raise ValidationError(" ".join(msg_parts))
elif (
dest_dir.exists()
and not self.registry.is_installed(manifest.id)
and (
self._has_keep_config_marker(dest_dir)
or self._is_legacy_keep_config_leftover(dest_dir)
)
):
for cfg_file in (
list(dest_dir.glob("*-config.yml"))
+ list(dest_dir.glob("*-config.local.yml"))
):
if cfg_file.is_symlink():
# `remove --keep-config` preserves a symlinked config
# because Path.is_file() follows symlinks. Its bytes cannot
# be safely rescued (the target may live outside dest_dir),
# and the rmtree below would delete the link and silently
# discard the kept configuration. Reject the reinstall while
# dest_dir is untouched so the user resolves it rather than
# losing the linked config.
raise ValidationError(
"Preserved extension config for "
f"'{manifest.id}' is a symlink ({cfg_file.name}) in "
f"{dest_dir}, which cannot be safely rescued during "
"reinstall. Resolve manually — replace the symlink with "
"a regular file or remove it — then reinstall."
)
if cfg_file.is_file():
stranded_configs[cfg_file.name] = (
cfg_file.read_bytes(),
cfg_file.stat().st_mode,
)
if stranded_configs and not staging_is_complete:
# Write a durable backup outside dest_dir before any
# destructive operation so the original bytes survive a
# crash or partial failure at any later step. The staging
# dir is cleaned up only after every restore succeeds.
#
# Any pre-existing staging dir here lacks the completion marker
# (staging_is_complete is False), so it is a stale partial from an
# interrupted attempt — remove it first for a clean write.
if rescue_staging_dir.is_symlink():
rescue_staging_dir.unlink()
elif rescue_staging_dir.is_dir():
shutil.rmtree(rescue_staging_dir)
elif rescue_staging_dir.exists():
rescue_staging_dir.unlink()
try:
rescue_staging_dir.mkdir(parents=True, exist_ok=True)
for filename, (content, mode) in stranded_configs.items():
staged = rescue_staging_dir / filename
# Create the staging file with mode 0600 before writing so
# the preserved bytes are never transiently readable by other
# local users, even on a umask that would produce 0644.
# O_BINARY (0 on POSIX) is required so Windows does not open
# the descriptor in text mode and translate the preserved
# bytes' "\n" into "\r\n" as they are written.
fd = os.open(
str(staged),
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0),
0o600,
)
try:
# os.write() may write fewer bytes than requested, so
# loop until the whole buffer is on disk — a truncated
# "durable" backup would be trusted over the intact
# config on a retry and cause silent data loss.
view = memoryview(content)
written = 0
while written < len(view):
written += os.write(fd, view[written:])
# Do NOT chmod the staged file: setting a read-only
# mode (e.g. 0o444) makes the file undeletable on
# Windows and causes shutil.rmtree to fail during
# cleanup. Original modes are recorded separately in
# .rescue-modes.json so they can be reapplied when the
# config is actually restored.
_fsync_fd(fd)
finally:
os.close(fd)
# Persist the original permission bits in a sidecar JSON file
# so a retry can correctly reapply them even though the staged
# files themselves are kept at their creation mode (0o600).
rescue_modes_file = rescue_staging_dir / ".rescue-modes.json"
modes_payload = json.dumps(
{
filename: stat.S_IMODE(mode)
for filename, (_, mode) in stranded_configs.items()
},
sort_keys=True,
).encode()
modes_fd = os.open(
str(rescue_modes_file),
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0),
0o600,
)
try:
view = memoryview(modes_payload)
written = 0
while written < len(view):
written += os.write(modes_fd, view[written:])
_fsync_fd(modes_fd)
finally:
os.close(modes_fd)
# Flush the staging directory metadata before publishing the
# completion marker so a crash cannot leave a visible marker with
# only a subset of staged files.
_fsync_directory(rescue_staging_dir)
# Write the completion marker only after every staged file is
# fully written so a retry trusts staging only when it is whole.
marker_fd = os.open(
str(rescue_complete_marker),
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
0o600,
)
try:
_fsync_fd(marker_fd)
finally:
os.close(marker_fd)
_fsync_directory(rescue_staging_dir)
_fsync_directory(rescue_staging_dir.parent)
except BaseException:
# Durable staging failed (or was interrupted). Continuing with
# only the in-memory copy would reintroduce the permanent-loss
# path this staging exists to close: the rmtree below could
# delete the originals and a later restore failure would leave
# no on-disk copy. dest_dir is still untouched here, so clean
# up the partial staging dir and abort the install instead of
# proceeding destructively.
shutil.rmtree(rescue_staging_dir, ignore_errors=True)
raise
# Install extension (dest_dir computed above during self-install guard)
if dest_dir.exists():
shutil.rmtree(dest_dir)
ignore_fn = self._load_extensionignore(source_dir)
shutil.copytree(source_dir, dest_dir, ignore=ignore_fn)
def _restore_stranded_config_file(
target: Path, content: bytes, preserved_mode: int
) -> None:
tmp_path: Path | None = None
try:
# A short fixed prefix, not f".{target.name}.": the preserved
# config filename may itself already be near the filesystem's
# per-component byte limit, and NamedTemporaryFile appends a
# random suffix to the prefix — reusing the full name would push
# the temp file past the limit and raise ENAMETOOLONG on every
# retry. tempfile already guarantees collision avoidance.
with tempfile.NamedTemporaryFile(
mode="wb",
dir=target.parent,
prefix=".cfg-restore.",
delete=False,
) as tmp:
tmp_path = Path(tmp.name)
tmp.write(content)
tmp.flush()
_fsync_fd(tmp.fileno())
try:
tmp_path.chmod(stat.S_IMODE(preserved_mode))
except (NotImplementedError, OSError):
pass # Best-effort; chmod may not be supported on all platforms.
os.replace(tmp_path, target)
try:
target_fd = os.open(str(target), os.O_RDONLY)
except (AttributeError, OSError, NotImplementedError):
target_fd = None
try:
if target_fd is not None:
_fsync_fd(target_fd)
finally:
if target_fd is not None:
try:
os.close(target_fd)
except OSError:
pass # best-effort close during cleanup; ignore errors
_fsync_directory(target.parent)
except BaseException:
if tmp_path is not None and tmp_path.exists():
tmp_path.unlink()
raise
try:
shutil.copytree(source_dir, dest_dir, ignore=ignore_fn)
except BaseException:
# copytree failed — dest_dir may be absent or only partially
# created. Write the rescued configs back now so they are not
# permanently lost even though the install did not complete.
if stranded_configs:
dest_dir.mkdir(parents=True, exist_ok=True)
for filename, (content, mode) in stranded_configs.items():
target = dest_dir / filename
_restore_stranded_config_file(target, content, mode)
raise
# Restore stranded configs rescued before the rmtree above.
for filename, (content, mode) in stranded_configs.items():
target = dest_dir / filename
_restore_stranded_config_file(target, content, mode)
# NOTE: the durable staging backup is intentionally NOT cleaned up
# here. Command/skill/hook registration and the final registry.add()
# below can still fail; if we discarded the backup and provenance now,
# such a failure would leave the extension unregistered with no durable
# rescue copy, so the next plain retry would skip rescue and overwrite
# the restored user config with packaged defaults. Cleanup is deferred
# until after registry.add() succeeds (see post-commit cleanup below).
# Register commands with AI agents
registered_commands = {}
@@ -1496,6 +2001,24 @@ class ExtensionManager:
},
)
# Post-commit cleanup: the registry now records this extension as
# installed, so the rescue guard (`not self.registry.is_installed`)
# will never misread a leftover staging dir on a future run. The
# durable backup has therefore served its purpose and can be removed
# best-effort — a cleanup failure must not fail an install that has
# already committed successfully.
if rescue_staging_dir.is_dir() and not rescue_staging_dir.is_symlink():
# Remove the completion marker before the non-atomic rmtree so a
# crash mid-cleanup cannot leave a staging dir that a retry would
# wrongly trust as a complete durable backup.
try:
rescue_complete_marker.unlink(missing_ok=True)
_fsync_directory(rescue_staging_dir)
shutil.rmtree(rescue_staging_dir)
_fsync_directory(rescue_staging_dir.parent)
except OSError:
pass # Best-effort; install already committed to the registry.
return manifest
def install_from_zip(
@@ -1612,6 +2135,12 @@ class ExtensionManager:
shutil.rmtree(child)
else:
child.unlink()
# Write a provenance marker so install_from_directory can
# distinguish this --keep-config leftover from a directory left
# by a partially-failed install (which must not have its
# packaged default configs treated as user-preserved data).
# Content is intentionally empty — only presence matters.
(extension_dir / ".keep-config").write_text("")
else:
# Backup config files before deleting
if extension_dir.exists():
@@ -2073,14 +2602,23 @@ class ExtensionCatalog(CatalogStackBase):
url: str,
timeout: int = 10,
extra_headers: Optional[Dict[str, str]] = None,
redirect_validator=None,
):
"""Open a URL with provider-based auth, trying each configured provider.
Delegates to :func:`specify_cli.authentication.http.open_url`.
*redirect_validator*, when provided, is invoked as ``(old_url, new_url)``
before EACH redirect hop so an HTTPS host guarantee can be enforced on
every intermediate URL, not just the terminal one.
"""
from specify_cli.authentication.http import open_url
return open_url(url, timeout, extra_headers=extra_headers)
return open_url(
url,
timeout,
extra_headers=extra_headers,
redirect_validator=redirect_validator,
)
def _resolve_github_release_asset_api_url(
self,
@@ -2304,7 +2842,24 @@ class ExtensionCatalog(CatalogStackBase):
# Fetch from network
try:
with self._open_url(entry.url, timeout=10) as response:
# Validate EVERY redirect hop, not just the terminal URL. _open_url
# follows redirects; _StripAuthOnRedirect drops auth on an HTTPS->HTTP
# downgrade AND whenever the redirect leaves the configured trusted
# hosts, but the payload itself is still fetched and trusted, and it
# supplies each extension's download_url + sha256 (so a redirected
# payload defeats sha256 verification). A terminal-only check also
# misses an https -> http -> attacker-https chain. redirect_validator
# runs before each hop; the final geturl() check is kept as a
# belt-and-braces guard. Mirrors bundler/services/adapters.py.
def _validate_redirect(_old_url: str, new_url: str) -> None:
self._validate_catalog_url(new_url)
with self._open_url(
entry.url, timeout=10, redirect_validator=_validate_redirect
) as response:
final_url = response.geturl()
if final_url != entry.url:
self._validate_catalog_url(final_url)
catalog_data = json.loads(response.read())
self._validate_catalog_payload(catalog_data, entry.url)
@@ -2481,7 +3036,18 @@ class ExtensionCatalog(CatalogStackBase):
try:
import urllib.error
with self._open_url(catalog_url, timeout=10) as response:
# Same redirect hardening as _fetch_single_catalog: validate every
# redirect hop AND the final URL so this legacy single-catalog path
# is not vulnerable to an HTTPS->HTTP redirected payload either.
def _validate_redirect(_old_url: str, new_url: str) -> None:
self._validate_catalog_url(new_url)
with self._open_url(
catalog_url, timeout=10, redirect_validator=_validate_redirect
) as response:
final_url = response.geturl()
if final_url != catalog_url:
self._validate_catalog_url(final_url)
catalog_data = json.loads(response.read())
# Validate catalog structure. Reuses the same helper as
@@ -2631,8 +3197,20 @@ class ExtensionCatalog(CatalogStackBase):
# Validate download URL requires HTTPS (prevent man-in-the-middle attacks)
from urllib.parse import urlparse
parsed = urlparse(download_url)
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
# A malformed authority (e.g. an unterminated IPv6 bracket
# "https://[::1") makes urlparse / hostname access raise ValueError.
# The download_url comes from catalog payload data, so surface a clean
# ExtensionError rather than leaking a raw ValueError past the command
# handler (which only catches ExtensionError). Mirrors catalogs (#3435)
# and workflows/catalog.py (#3484).
try:
parsed = urlparse(download_url)
hostname = parsed.hostname
except ValueError:
raise ExtensionError(
f"Extension download URL is malformed: {download_url}"
) from None
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
raise ExtensionError(
f"Extension download URL must use HTTPS: {download_url}"
@@ -3030,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:
@@ -3040,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

@@ -46,6 +46,7 @@ def with_integration_setting(
script_type: str | None = None,
raw_options: str | None = None,
parsed_options: dict[str, Any] | None = None,
project_root: Any = None,
) -> dict[str, dict[str, Any]]:
"""Return integration settings with *key* updated."""
settings = integration_settings(state)
@@ -63,7 +64,9 @@ def with_integration_setting(
elif raw_options is not None:
current.pop("parsed_options", None)
current["invoke_separator"] = integration.effective_invoke_separator(parsed_options)
current["invoke_separator"] = integration.effective_invoke_separator(
parsed_options, project_root
)
settings[key] = current
return settings
@@ -73,10 +76,11 @@ def invoke_separator_for_integration(
state: dict[str, Any],
key: str,
parsed_options: dict[str, Any] | None = None,
project_root: Any = None,
) -> str:
"""Resolve the invocation separator for stored/default integration state."""
if parsed_options is not None:
return integration.effective_invoke_separator(parsed_options)
return integration.effective_invoke_separator(parsed_options, project_root)
setting = integration_setting(state, key)
stored_separator = setting.get("invoke_separator")
@@ -85,6 +89,6 @@ def invoke_separator_for_integration(
stored_parsed = setting.get("parsed_options")
if isinstance(stored_parsed, dict):
return integration.effective_invoke_separator(stored_parsed)
return integration.effective_invoke_separator(stored_parsed, project_root)
return integration.effective_invoke_separator(None)
return integration.effective_invoke_separator(None, project_root)

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

@@ -6,6 +6,7 @@ from pathlib import Path
from typing import Any, Callable
import typer
from rich.markup import escape
from .._agent_config import SCRIPT_TYPE_CHOICES
from .._console import console
@@ -206,7 +207,7 @@ def _parse_integration_options(integration: Any, raw_options: str) -> dict[str,
while i < len(tokens):
token = tokens[i]
if not token.startswith("-"):
console.print(f"[red]Error:[/red] Unexpected integration option value '{token}'.")
console.print(f"[red]Error:[/red] Unexpected integration option value '{escape(token)}'.")
if allowed:
console.print(f"Allowed options: {allowed}")
raise typer.Exit(1)
@@ -217,7 +218,7 @@ def _parse_integration_options(integration: Any, raw_options: str) -> dict[str,
name, value = name.split("=", 1)
opt = declared.get(name)
if not opt:
console.print(f"[red]Error:[/red] Unknown integration option '{token}'.")
console.print(f"[red]Error:[/red] Unknown integration option '{escape(token)}'.")
if allowed:
console.print(f"Allowed options: {allowed}")
raise typer.Exit(1)
@@ -272,24 +273,19 @@ def _update_init_options_for_integration(
load_init_options,
save_init_options,
)
from .base import SkillsIntegration
opts = load_init_options(project_root)
opts["integration"] = integration.key
opts["ai"] = integration.key
opts["speckit_version"] = _get_speckit_version()
if script_type:
opts["script"] = script_type
# Skills mode is either intrinsic (SkillsIntegration), set on the instance
# during setup() (_skills_mode), or requested via parsed options (e.g.
# Copilot's --skills, persisted as parsed_options["skills"]). The latter is
# the only signal available on the `use` path, where no setup() runs and a
# fresh integration instance has _skills_mode == False (issue #3550).
skills_mode = (
isinstance(integration, SkillsIntegration)
or getattr(integration, "_skills_mode", False)
or bool((parsed_options or {}).get("skills"))
)
if skills_mode:
# Whether skills mode is active is owned by each integration via the
# ``is_skills_mode`` hook (base default honors ``--skills``;
# SkillsIntegration returns True; skills-first integrations with a legacy
# opt-out such as Bob override it). This keeps shared code free of
# ``isinstance`` / ``_skills_mode`` probing. Passing parsed_options lets it
# work on the ``use``/``install`` path where no setup() runs (issue #3550).
if integration.is_skills_mode(parsed_options, project_root=project_root):
opts["ai_skills"] = True
else:
opts.pop("ai_skills", None)
@@ -325,6 +321,7 @@ def _set_default_integration(
script_type=resolved_script,
raw_options=raw_options,
parsed_options=parsed_options,
project_root=project_root,
)
if refresh_templates:
@@ -333,7 +330,8 @@ def _set_default_integration(
project_root,
resolved_script,
invoke_separator=_invoke_separator_for_integration(
integration, {"integration_settings": settings}, key, parsed_options
integration, {"integration_settings": settings}, key, parsed_options,
project_root=project_root,
),
force=refresh_templates_force,
refresh_managed=True,

View File

@@ -38,7 +38,7 @@ from ._helpers import (
@integration_app.command("install")
def integration_install(
key: str = typer.Argument(help="Integration key to install (e.g. claude, copilot)"),
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
script: str | None = typer.Option(None, "--script", help="Script type: sh, ps, or py (default: from init-options.json or platform default)"),
force: bool = typer.Option(False, "--force", help="Allow multi-install when integrations are not declared safe"),
integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
):
@@ -127,7 +127,8 @@ def integration_install(
project_root,
selected_script,
invoke_separator=_invoke_separator_for_integration(
infra_integration, current, infra_key, infra_parsed
infra_integration, current, infra_key, infra_parsed,
project_root=project_root,
),
)
if os.name != "nt":
@@ -155,10 +156,16 @@ def integration_install(
script_type=selected_script,
raw_options=raw_options,
parsed_options=parsed_options,
project_root=project_root,
)
_write_integration_json(project_root, new_default, new_installed, settings)
if new_default == integration.key:
_update_init_options_for_integration(project_root, integration, script_type=selected_script)
_update_init_options_for_integration(
project_root,
integration,
script_type=selected_script,
parsed_options=parsed_options,
)
else:
_refresh_init_options_speckit_version(project_root)

View File

@@ -1,8 +1,9 @@
"""specify integration switch / upgrade command handlers."""
from __future__ import annotations
import json
import os
from pathlib import PurePath
from pathlib import Path, PurePath
import typer
@@ -40,10 +41,97 @@ from ._helpers import (
)
def _manifest_tracks_skill_layout(manifest) -> bool:
"""Return True when *manifest* tracks any skills-layout artifact.
A skill scaffold is written as ``.../speckit-<name>/SKILL.md``, so a
manifest whose tracked files include a ``/SKILL.md`` key is in the skills
layout; otherwise it is in the command layout. Used by ``upgrade`` to
detect a dual-mode agent (e.g. Bob) flipping between the legacy commands
layout and the skills layout so orphaned extension artifacts from the old
layout can be reconciled.
"""
return any(str(rel).endswith("/SKILL.md") for rel in manifest.files)
class _PresetRegistryUnreadableError(Exception):
"""Raised when an existing preset registry cannot be read or parsed.
Distinct from a *genuinely absent* registry (no presets installed): an
unreadable registry means we cannot verify whether preset overrides would
be orphaned by a layout change, so the migration must be rejected rather
than proceeding on a false "no presets" assumption.
"""
def _installed_presets_affecting_agent(project_root, agent_key: str) -> list[str]:
"""Return IDs of installed presets with artifacts registered for *agent_key*.
Presets register command overrides for every detected agent and mirror
skills for the active skills agent, tracking the result in each preset's
``registered_commands`` / ``registered_skills`` metadata. There is no
agent-scoped preset re-registration mechanism, so a command↔skills *layout
change* cannot reconcile those artifacts (see ``integration_upgrade``).
Callers use this to detect the unsafe case and reject the migration rather
than silently orphaning preset files / leaving stale registry entries.
Fails **closed**: a genuinely absent registry (no presets ever installed)
returns an empty list, but if the registry file exists and cannot be read
or parsed (e.g. a permission error or corruption) this raises
:class:`_PresetRegistryUnreadableError`. Reporting "no presets" in that
case would let a ``--force`` layout-changing upgrade delete
preset-overridden files while their registry state can't be reconciled —
the exact inconsistency the guard exists to prevent.
"""
from ..presets import PresetRegistry
registry_path = (
Path(project_root) / ".specify" / "presets" / PresetRegistry.REGISTRY_FILE
)
# Genuinely absent registry → no presets installed → safe to proceed.
if not registry_path.exists():
return []
# The registry exists: any failure to read or parse it must surface as an
# error, not be swallowed into an empty ("no presets") result.
try:
data = json.loads(registry_path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
raise _PresetRegistryUnreadableError(str(exc)) from exc
if not isinstance(data, dict) or not isinstance(data.get("presets", {}), dict):
raise _PresetRegistryUnreadableError(
"preset registry structure is malformed"
)
affected: list[str] = []
for preset_id, meta in data.get("presets", {}).items():
# A malformed entry means we cannot verify whether this preset owns
# artifacts for the agent, so fail closed rather than skip it.
if not isinstance(meta, dict):
raise _PresetRegistryUnreadableError(
f"preset '{preset_id}' entry is malformed"
)
registered_commands = meta.get("registered_commands", {})
if not isinstance(registered_commands, dict):
raise _PresetRegistryUnreadableError(
f"preset '{preset_id}' registered_commands is malformed"
)
registered_skills = meta.get("registered_skills", [])
if not isinstance(registered_skills, (list, tuple)):
raise _PresetRegistryUnreadableError(
f"preset '{preset_id}' registered_skills is malformed"
)
has_commands = bool(registered_commands.get(agent_key))
has_skills = bool(registered_skills)
if has_commands or has_skills:
affected.append(preset_id)
return affected
@integration_app.command("switch")
def integration_switch(
target: str = typer.Argument(help="Integration key to switch to"),
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
script: str | None = typer.Option(None, "--script", help="Script type: sh, ps, or py (default: from init-options.json or platform default)"),
force: bool = typer.Option(False, "--force", help="Force removal of modified files during uninstall of the previous integration"),
refresh_shared_infra: bool = typer.Option(False, "--refresh-shared-infra", help="Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved)"),
integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the target integration'),
@@ -236,7 +324,8 @@ def integration_switch(
force=refresh_shared_infra,
refresh_managed=True,
invoke_separator=_invoke_separator_for_integration(
target_integration, current, target, parsed_options
target_integration, current, target, parsed_options,
project_root=project_root,
),
refresh_hint=(
"To overwrite customizations, re-run with "
@@ -336,7 +425,7 @@ def integration_switch(
def integration_upgrade(
key: str | None = typer.Argument(None, help="Integration key to upgrade (default: current integration)"),
force: bool = typer.Option(False, "--force", help="Force upgrade even if files are modified"),
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
script: str | None = typer.Option(None, "--script", help="Script type: sh, ps, or py (default: from init-options.json or platform default)"),
integration_options: str | None = typer.Option(None, "--integration-options", help="Options for the integration"),
):
"""Upgrade an integration by reinstalling with diff-aware file handling.
@@ -398,6 +487,56 @@ def integration_upgrade(
integration, current, key, integration_options
)
# Guard: reject a command↔skills layout change while preset overrides are
# installed for this agent (review #3415). A dual-mode agent (e.g. Bob)
# can flip layout across an upgrade (``--skills`` / ``--legacy-commands``).
# Extension artifacts are reconciled after the flip (see below), but preset
# artifacts cannot be: there is no agent-scoped preset re-registration
# anywhere in the CLI, so migrating would delete a preset's old-layout
# files without recreating them in the new layout and leave the preset
# registry claiming artifacts that no longer exist. Detect the intended
# layout (``is_skills_mode`` reflects the resolved flags/disk state, so a
# plain same-layout upgrade is unaffected) and bail out *before* any
# mutation with an actionable error so the project is never left in a
# half-migrated, inconsistent state.
if _manifest_tracks_skill_layout(old_manifest) != integration.is_skills_mode(
parsed_options, project_root
):
try:
affected_presets = _installed_presets_affecting_agent(project_root, key)
except _PresetRegistryUnreadableError as exc:
console.print(
f"[red]Error:[/red] Cannot change '{key}' command layout: the "
f"preset registry could not be read to verify installed presets."
)
console.print(f"[dim]Details:[/dim] {_cli_error_detail(exc)}")
console.print(
"A layout change cannot reconcile preset artifacts, so the "
"migration is refused while the preset registry state is "
"unknown. Fix or restore "
"[cyan].specify/presets/.registry[/cyan] and retry."
)
raise typer.Exit(1)
if affected_presets:
preset_list = ", ".join(sorted(affected_presets))
console.print(
f"[red]Error:[/red] Cannot change '{key}' command layout while "
f"preset override(s) are installed: [bold]{preset_list}[/bold]."
)
console.print(
"Preset artifacts cannot yet be reconciled across a command↔skills "
"layout change, so the migration would orphan their files and leave "
"the preset registry inconsistent."
)
console.print(
"Remove the preset(s), run the upgrade, then reinstall them:\n"
f" [cyan]specify preset remove <id>[/cyan]\n"
f" [cyan]specify integration upgrade {key} "
f"--integration-options \"...\"[/cyan]\n"
f" [cyan]specify preset add <id>[/cyan]"
)
raise typer.Exit(1)
# Ensure shared infrastructure is up to date; --force overwrites existing files.
infra_integration = integration
infra_key = key
@@ -415,7 +554,8 @@ def integration_upgrade(
selected_script,
force=force,
invoke_separator=_invoke_separator_for_integration(
infra_integration, current, infra_key, infra_parsed
infra_integration, current, infra_key, infra_parsed,
project_root=project_root,
),
)
if os.name != "nt":
@@ -441,6 +581,7 @@ def integration_upgrade(
script_type=selected_script,
raw_options=raw_options,
parsed_options=parsed_options,
project_root=project_root,
)
if installed_key == key:
try:
@@ -448,7 +589,8 @@ def integration_upgrade(
project_root,
selected_script,
invoke_separator=_invoke_separator_for_integration(
integration, {"integration_settings": settings}, key, parsed_options
integration, {"integration_settings": settings}, key, parsed_options,
project_root=project_root,
),
force=force,
refresh_managed=True,
@@ -463,7 +605,12 @@ def integration_upgrade(
new_manifest.save()
_write_integration_json(project_root, installed_key, installed_keys, settings)
if installed_key == key:
_update_init_options_for_integration(project_root, integration, script_type=selected_script)
_update_init_options_for_integration(
project_root,
integration,
script_type=selected_script,
parsed_options=parsed_options,
)
else:
_refresh_init_options_speckit_version(project_root)
except Exception as exc:
@@ -487,7 +634,15 @@ def integration_upgrade(
if stale_keys:
stale_manifest = IntegrationManifest(key, project_root, version="stale-cleanup")
stale_manifest._files = {k: old_files[k] for k in stale_keys}
stale_removed, _ = stale_manifest.uninstall(project_root, force=True)
# remove_manifest=False: this throwaway manifest shares ``key`` with the
# real one just saved above (new_manifest.save()). Letting uninstall()
# delete ``{key}.manifest.json`` would wipe the freshly-written manifest
# whenever an upgrade shrinks the tracked file set (e.g. Bob migrating
# from the legacy commands layout to skills), leaving the integration
# untracked and un-upgradeable.
stale_removed, _ = stale_manifest.uninstall(
project_root, force=True, remove_manifest=False
)
if stale_removed:
console.print(f" Removed {len(stale_removed)} stale file(s) from previous install")
@@ -497,6 +652,55 @@ def integration_upgrade(
# Done after the upgrade has fully settled (Phase 2 included) and outside
# the try/except above so this best-effort step cannot affect upgrade
# success.
#
# Layout-change reconciliation: a dual-mode agent (e.g. Bob) can flip
# between the legacy commands layout and the skills layout across an
# upgrade (``upgrade bob --integration-options "--skills"`` / reverse
# ``--legacy-commands``). Phase 2 above only removes stale files tracked by
# the *integration* manifest (core commands); extension artifacts are
# tracked separately in the extension registry, so the old layout's
# extension command/skill files would otherwise linger as orphans. When the
# layout actually changed, first unregister the agent's extension artifacts
# (removing old-layout files and clearing per-agent registry entries) so the
# re-registration below recreates them in the new layout. ``upgrade``s that
# don't change layout skip this to avoid needless remove/re-add churn.
#
# Only the *active* integration is reconciled this way (``installed_key ==
# key``). ``ExtensionManager.unregister_agent_artifacts`` treats the
# per-extension ``registered_skills`` list as belonging to the passed agent
# and, when that agent's skills directory is absent, falls back to scanning
# every agent's skills directory — so running it for a *secondary*
# (non-active) agent could delete or untrack the *active* agent's extension
# skills. The subsequent re-registration cannot repair that because
# extension skill rendering is intentionally scoped to the active agent
# (#2948). Extension skills only ever exist for the active agent, so
# skipping the unregister for a secondary agent orphans nothing new: a
# secondary agent only has extension *command* files, which the
# re-registration below rewrites in place regardless of layout.
#
# Known limitation: preset command/skill artifacts are NOT reconciled on a
# layout change. There is no agent-scoped preset re-registration mechanism
# anywhere in the CLI — ``use`` / ``switch`` / ``upgrade`` never reconcile
# presets for any agent (presets are only (un)registered at preset
# install/remove time). Rather than silently orphan them, the guard near
# the top of this function rejects a layout-changing upgrade while preset
# overrides are installed, so control only reaches here (with a changed
# layout) when no preset artifacts are at stake. Full preset reconciliation
# would require a new cross-cutting PresetManager subsystem affecting every
# dual-layout agent, which is out of scope for this Bob migration.
if (
installed_key == key
and _manifest_tracks_skill_layout(old_manifest)
!= _manifest_tracks_skill_layout(new_manifest)
):
_unregister_extensions_for_agent(
project_root,
key,
continuing=(
"The integration layout changed, but old-layout extension "
"artifacts may need manual cleanup."
),
)
_register_extensions_for_agent(
project_root,
key,

View File

@@ -14,6 +14,7 @@ Provides:
from __future__ import annotations
import os
import platform
import re
import shlex
import shutil
@@ -160,17 +161,66 @@ class IntegrationBase(ABC):
return []
def effective_invoke_separator(
self, parsed_options: dict[str, Any] | None = None
self,
parsed_options: dict[str, Any] | None = None,
project_root: Path | None = None,
) -> str:
"""Return the invoke separator for the given options.
Subclasses whose separator depends on runtime options (e.g.
Copilot in ``--skills`` mode) should override this method.
The default implementation ignores *parsed_options* and returns
the class-level ``invoke_separator``.
The default implementation ignores *parsed_options* and
*project_root* and returns the class-level ``invoke_separator``.
"""
return self.invoke_separator
def invoke_separator_for_mode(self, skills_enabled: bool) -> str:
"""Command-ref separator given the project's *resolved* skills state.
Registration paths (extension / preset command rendering) have no CLI
``parsed_options`` — only the persisted ``ai_skills`` flag — so they
resolve the command-reference separator through this hook rather than
the static ``AGENT_CONFIGS[key]["invoke_separator"]`` value, which
cannot represent an agent whose separator differs between its skills
and command layouts.
The default is mode-independent and returns exactly what
``_build_agent_configs`` would place in ``AGENT_CONFIGS`` (the
``registrar_config`` override if present, else the class-level
``invoke_separator``), so single-layout agents are unaffected.
Dual-mode agents whose separator depends on the layout (e.g. Bob:
``-`` for skills, ``.`` for legacy commands) override this.
"""
cfg = self.registrar_config or {}
return cfg.get("invoke_separator", self.invoke_separator)
def is_skills_mode(
self,
parsed_options: dict[str, Any] | None = None,
project_root: Path | None = None,
) -> bool:
"""Return whether this integration scaffolds skills for these options.
This is the single, well-defined hook the shared init/install/upgrade
machinery consults to decide whether to persist ``ai_skills=True`` and
render skill invocations. It replaces ad-hoc ``isinstance`` /
``getattr(self, "_skills_mode", ...)`` probing so an integration's
internal representation never has to leak into shared dispatch code.
*project_root* is optional context for the ``use`` / ``switch`` /
``upgrade`` path, where no ``setup()`` runs and *parsed_options* may be
empty: dual-mode integrations can consult the already-installed
on-disk layout to avoid silently migrating an existing project to a
different mode. The default ignores it.
The default (command-first integrations, e.g. Copilot's default
layout) is skills mode only when ``--skills`` was requested.
``SkillsIntegration`` overrides this to return ``True`` by default;
skills-first integrations that expose a legacy opt-out (e.g. Bob)
override it to honor their own flag.
"""
return bool((parsed_options or {}).get("skills"))
def build_exec_args(
self,
prompt: str,
@@ -619,6 +669,46 @@ class IntegrationBase(ABC):
return name
return sys.executable or "python3"
@staticmethod
def build_python_invocation(
script_command: str, project_root: Path | None = None
) -> str:
"""Build a Python script command for the current platform shell."""
interpreter = IntegrationBase.resolve_python_interpreter(project_root)
if os.name == "nt" and not re.fullmatch(r"[A-Za-z0-9_./:\\-]+", interpreter):
quoted_interpreter = interpreter.replace("'", "''")
interpreter = f"& '{quoted_interpreter}'"
elif os.name != "nt":
interpreter = shlex.quote(interpreter)
return f"{interpreter} {script_command}"
@staticmethod
def select_script_variant(
requested: object, script_commands: dict[str, str]
) -> str:
"""Select the requested variant or a runnable platform fallback."""
if isinstance(requested, str) and requested in script_commands:
return requested
platform_variant = (
"ps" if platform.system().lower().startswith("win") else "sh"
)
secondary_variant = "sh" if platform_variant == "ps" else "ps"
fallbacks = (
(platform_variant, "py")
if requested == "py"
else (platform_variant, secondary_variant, "py")
)
for candidate in fallbacks:
if candidate in script_commands:
return candidate
available = ", ".join(sorted(script_commands)) or "none"
raise ValueError(
"No runnable script variant for this platform: "
f"requested {requested!r}; available: {available}"
)
@staticmethod
def _interpreter_runs(path: str) -> bool:
"""Return True when *path* executes as a Python interpreter.
@@ -653,7 +743,8 @@ class IntegrationBase(ABC):
"""Process a raw command template into agent-ready content.
Performs the same transformations as the release script:
1. Extract ``scripts.<script_type>`` value from YAML frontmatter
1. Select ``scripts.<script_type>`` from YAML frontmatter, falling
back to a runnable platform shell or Python variant when unavailable
2. Replace ``{SCRIPT}`` with the extracted script command
3. Strip ``scripts:`` section from frontmatter
4. Replace ``{ARGS}`` and ``$ARGUMENTS`` with *arg_placeholder*
@@ -662,37 +753,46 @@ class IntegrationBase(ABC):
7. Replace ``__SPECKIT_COMMAND_<NAME>__`` with invocation strings
"""
# 1. Extract script command from frontmatter
script_command = ""
script_pattern = re.compile(
rf"^\s*{re.escape(script_type)}:\s*(.+)$", re.MULTILINE
)
script_commands: dict[str, str] = {}
script_pattern = re.compile(r"^\s*([A-Za-z0-9_-]+):\s*(.+)$")
# Find the scripts: block
in_frontmatter = False
in_scripts = False
for line in content.splitlines():
if line.strip() == "scripts:":
if line == "---":
if in_frontmatter:
break
in_frontmatter = True
continue
if not in_frontmatter:
continue
if line == "scripts:":
in_scripts = True
continue
if in_scripts and line and not line[0].isspace():
in_scripts = False
break
if in_scripts:
m = script_pattern.match(line)
if m:
script_command = m.group(1).strip()
break
script_commands[m.group(1)] = m.group(2).strip()
selected_script_type = (
IntegrationBase.select_script_variant(script_type, script_commands)
if script_commands
else ""
)
script_command = script_commands.get(selected_script_type, "")
# 2. Replace {SCRIPT}
if script_command:
# For the Python script type, prefix the resolved interpreter so
# the command is portable (``.py`` files are not directly
# executable on Windows).
if script_type == "py":
interpreter = IntegrationBase.resolve_python_interpreter(project_root)
# Quote the interpreter if it contains whitespace (e.g. an
# absolute ``sys.executable`` path under Windows
# ``Program Files``) so it isn't split into multiple args.
if any(ch.isspace() for ch in interpreter):
interpreter = f'"{interpreter}"'
script_command = f"{interpreter} {script_command}"
if selected_script_type == "py":
script_command = IntegrationBase.build_python_invocation(
script_command, project_root
)
content = content.replace("{SCRIPT}", script_command)
# 3. Strip scripts: section from frontmatter
@@ -1376,6 +1476,14 @@ class SkillsIntegration(IntegrationBase):
invoke_separator = "-"
def is_skills_mode(
self,
parsed_options: dict[str, Any] | None = None,
project_root: Path | None = None,
) -> bool:
"""Skills-native integrations scaffold skills unconditionally."""
return True
def build_exec_args(
self,
prompt: str,

View File

@@ -1,10 +1,140 @@
"""IBM Bob integration."""
"""IBM Bob integration.
from ..base import MarkdownIntegration
Bob 2.0 uses the ``.bob/skills/speckit-<name>/SKILL.md`` layout by default.
The legacy ``.bob/commands/*.md`` layout (Bob 1.x) remains available as an
opt-in via ``--integration-options "--legacy-commands"``.
Bob is a *dual-mode* integration: whether it scaffolds skills or commands is
a per-project **configuration** decision (the ``--legacy-commands`` option,
persisted as ``ai_skills`` in init-options), not a property of the class.
It therefore extends :class:`IntegrationBase` (like Copilot, the other
dual-mode agent) and resolves the mode through the ``is_skills_mode`` hook,
delegating the actual scaffolding to a per-layout helper.
Deprecation cycle:
This release: Skills layout is the default; legacy ``.bob/commands/`` is
opt-in via ``--legacy-commands``.
Next cycle: ``--legacy-commands`` flag removed.
"""
from __future__ import annotations
import warnings
from pathlib import Path
from typing import Any
import typer
from ..base import (
IntegrationBase,
IntegrationOption,
MarkdownIntegration,
SkillsIntegration,
)
from ..manifest import IntegrationManifest
class BobIntegration(MarkdownIntegration):
def _validate_mode_options(parsed_options: dict[str, Any] | None) -> None:
"""Reject ``--skills`` and ``--legacy-commands`` used together.
The two flags select opposite layouts, so combining them is ambiguous.
Fail fast with the same clean exit-1 UX as other bad-option paths rather
than silently letting one win.
"""
opts = parsed_options or {}
if opts.get("skills") and opts.get("legacy_commands"):
from ..._console import console
console.print(
"[red]Error:[/red] --skills and --legacy-commands are mutually "
"exclusive; pass only one."
)
raise typer.Exit(1)
def _warn_legacy_commands_deprecated() -> None:
warnings.warn(
"Bob legacy commands mode (.bob/commands/) is deprecated and will be "
"removed in a future Spec Kit release. Omit --legacy-commands to use "
"the default skills layout (.bob/skills/).",
UserWarning,
stacklevel=3,
)
class _BobSkillsHelper(SkillsIntegration):
"""Default-mode helper: ``.bob/skills/speckit-<name>/SKILL.md``.
Not registered in the integration registry — used only as a delegate by
:class:`BobIntegration` for skills-mode ``setup()``.
"""
key = "bob"
config = {
"name": "IBM Bob",
"folder": ".bob/",
"commands_subdir": "skills",
"install_url": None,
"requires_cli": False,
}
registrar_config = {
"dir": ".bob/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
def post_process_skill_content(self, content: str) -> str:
"""Bob skills are intent-activated; no slash-command note is needed."""
return content
class _BobMarkdownHelper(MarkdownIntegration):
"""Legacy-mode helper: ``.bob/commands/speckit.<name>.md`` (Bob 1.x).
Not registered in the integration registry — used only as a delegate by
:class:`BobIntegration` when ``--legacy-commands`` is passed. Declares
``invoke_separator="."`` so command-reference tokens render as Bob 1.x
``/speckit.<name>`` invocations.
"""
key = "bob"
invoke_separator = "."
config = {
"name": "IBM Bob",
"folder": ".bob/",
"commands_subdir": "commands",
"install_url": None,
"requires_cli": False,
}
registrar_config = {
"dir": ".bob/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
"invoke_separator": ".",
}
class BobIntegration(IntegrationBase):
"""Integration for IBM Bob IDE (dual-mode; skills by default).
Whether a project uses the skills or the legacy commands layout is a
configuration choice resolved by :meth:`is_skills_mode`, not the class
hierarchy. ``setup()`` delegates to the matching helper.
``registrar_config`` mirrors the *commands* layout (``extension: ".md"``,
``dir: ".bob/commands"``) — the same pattern Copilot uses — so that
``CommandRegistrar.AGENT_CONFIGS["bob"]`` drives extension/preset
registration into ``.bob/commands/`` for legacy-mode projects, while
skills-mode projects have that command registration transparently skipped
(``skills_mode_active`` becomes ``True`` because ``ai_skills=True`` and
``extension != "/SKILL.md"``) and receive extension skills instead.
``invoke_separator = "-"`` matches the default (skills) layout.
"""
key = "bob"
invoke_separator = "-"
config = {
"name": "IBM Bob",
"folder": ".bob/",
@@ -18,3 +148,136 @@ class BobIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
@classmethod
def options(cls) -> list[IntegrationOption]:
return [
IntegrationOption(
"--skills",
is_flag=True,
default=False,
help=(
"Force the default skills layout (.bob/skills/), overriding "
"on-disk auto-detection. Use this to migrate a legacy "
"commands install to skills, e.g. "
"`integration upgrade bob --integration-options \"--skills\"`"
),
),
IntegrationOption(
"--legacy-commands",
is_flag=True,
default=False,
help=(
"Scaffold commands as legacy .bob/commands/*.md files "
"(Bob 1.x layout, deprecated) instead of the default "
"skills layout"
),
),
]
def is_skills_mode(
self,
parsed_options: dict[str, Any] | None = None,
project_root: Path | None = None,
) -> bool:
"""Bob is skills-first; ``--legacy-commands`` opts out.
Precedence:
1. Explicit ``--skills`` wins — it *forces* skills mode regardless of
what is already on disk. This is the supported migration / opt-in
path: ``integration upgrade bob --integration-options "--skills"``
converts a legacy commands install to the skills layout (setup()
scaffolds ``.bob/skills`` and the upgrade's stale-file pass removes
the old ``.bob/commands`` files).
2. Explicit ``--legacy-commands`` opts out to the Bob 1.x layout.
3. Otherwise, when a *project_root* is supplied, the layout is inferred
from **managed Spec Kit artifacts** (see below).
4. A fresh project (no managed artifacts, no flags) defaults to skills.
The disk-detection fallback exists because on ``use`` / ``switch`` /
``upgrade`` (without an explicit ``--skills`` / ``--legacy-commands``)
*parsed_options* is typically empty: no flag was passed, and existing
Bob 1.x installs never persisted a ``legacy_commands`` option to
recover. This is independent of whether ``setup()`` runs — ``upgrade``
*does* call :meth:`setup` (see ``_migrate_commands.integration_upgrade``),
but it passes those same empty *parsed_options*, so without disk
detection the mode would resolve to the skills default. Defaulting to
skills there would rewrite such a project's ``ai_skills`` flag to
``True`` even though it still only contains a command layout, silently
switching its extension / command-reference handling. So the layout is
inferred from managed Spec Kit artifacts, not the mere presence of a
``.bob/skills/`` directory: a user may keep unrelated Bob 2 skills in
``.bob/skills/`` while their Spec Kit commands still live in
``.bob/commands/speckit.*.md``. We therefore treat the project as
legacy (command) mode only when managed Spec Kit command files exist
and no managed Spec Kit skills (``speckit-*`` skill dirs) do. Passing
``--skills`` overrides this so users are never trapped in legacy mode.
"""
opts = parsed_options or {}
_validate_mode_options(opts)
if opts.get("skills", False):
return True
if opts.get("legacy_commands", False):
return False
if project_root is not None:
bob_dir = Path(project_root) / ".bob"
has_managed_skills = any((bob_dir / "skills").glob("speckit-*"))
has_managed_commands = any((bob_dir / "commands").glob("speckit.*.md"))
if has_managed_commands and not has_managed_skills:
return False
return True
def effective_invoke_separator(
self,
parsed_options: dict[str, Any] | None = None,
project_root: Path | None = None,
) -> str:
"""``"."`` for the legacy commands layout, ``"-"`` for skills.
*project_root* lets the ``use`` / ``switch`` / ``upgrade`` path — which
refreshes shared infrastructure *before* persisting init-options —
detect an already-installed legacy layout, so core command references
are rendered with the correct separator instead of defaulting to the
skills ``-``.
"""
return "-" if self.is_skills_mode(parsed_options, project_root) else "."
def invoke_separator_for_mode(self, skills_enabled: bool) -> str:
"""Resolve the command-ref separator from a project's persisted mode.
Skills projects render ``/speckit-<cmd>``; legacy command projects
render Bob 1.x ``/speckit.<cmd>``. Extension/preset registration
consults this (via the persisted ``ai_skills`` flag) so both layouts
get the correct separator despite sharing one static ``AGENT_CONFIGS``
entry.
"""
return "-" if skills_enabled else "."
def post_process_skill_content(self, content: str) -> str:
"""Bob skills are intent-activated; no slash-command note is injected.
Preset/extension skill generators call this on the *registered*
``BobIntegration`` instance, not on :class:`_BobSkillsHelper`, so the
no-op must be repeated here (delegating to the helper) — otherwise
those paths would inherit ``IntegrationBase``'s default and inject
``/speckit-*`` hook guidance that core Bob skills intentionally omit.
"""
return _BobSkillsHelper().post_process_skill_content(content)
def setup(
self,
project_root: Path,
manifest: IntegrationManifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
parsed_options = parsed_options or {}
if self.is_skills_mode(parsed_options, project_root):
return _BobSkillsHelper().setup(
project_root, manifest, parsed_options, **opts
)
_warn_legacy_commands_deprecated()
return MarkdownIntegration.setup(
_BobMarkdownHelper(), project_root, manifest, parsed_options, **opts
)

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:
@@ -429,7 +449,8 @@ class IntegrationCatalog(CatalogStackBase):
)
try:
normalized_priority = int(raw_priority)
except (TypeError, ValueError):
except (TypeError, ValueError, OverflowError):
# OverflowError: int(float("inf")) — a ``priority: .inf``.
raise IntegrationValidationError(
f"Invalid catalog entry at index {idx} in {config_path}: "
f"'priority' must be an integer, got "
@@ -537,7 +558,8 @@ class IntegrationCatalog(CatalogStackBase):
else:
try:
priority = int(raw_priority)
except (TypeError, ValueError):
except (TypeError, ValueError, OverflowError):
# OverflowError: int(float("inf")) — a ``priority: .inf``.
priority = yaml_idx + 1
priority_pairs.append((priority, yaml_idx))
if not priority_pairs:

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)

View File

@@ -122,7 +122,9 @@ class CopilotIntegration(IntegrationBase):
_skills_mode: bool = False
def effective_invoke_separator(
self, parsed_options: dict[str, Any] | None = None
self,
parsed_options: dict[str, Any] | None = None,
project_root: Path | None = None,
) -> str:
"""Return ``"-"`` when skills mode is requested, ``"."`` otherwise."""
if parsed_options and parsed_options.get("skills"):
@@ -131,6 +133,33 @@ class CopilotIntegration(IntegrationBase):
return "-"
return self.invoke_separator
def is_skills_mode(
self,
parsed_options: dict[str, Any] | None = None,
project_root: Path | None = None,
) -> bool:
"""Copilot is skills mode when ``--skills`` was requested.
On the init path ``setup()`` has already recorded the choice in
``self._skills_mode``; on the ``use``/``install`` path (where no
``setup()`` runs) the signal comes from *parsed_options* (#3550), which
round-trips because ``--skills`` is persisted in the stored options.
"""
if parsed_options and parsed_options.get("skills"):
return True
return self._skills_mode
def invoke_separator_for_mode(self, skills_enabled: bool) -> str:
"""Skills projects render ``/speckit-<cmd>``; default markdown ``.``.
Copilot is dual-layout, so — like Bob — the command-reference
separator depends on the persisted ``ai_skills`` state rather than a
single static value. This keeps preset/extension command refs in a
Copilot skills project consistent with ``build_command_invocation``
(which emits ``/speckit-<stem>``).
"""
return "-" if skills_enabled else self.invoke_separator
@classmethod
def options(cls) -> list[IntegrationOption]:
return [

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

@@ -91,6 +91,18 @@ class ForgeIntegration(MarkdownIntegration):
}
invoke_separator = "-"
def build_command_invocation(self, command_name: str, args: str = "") -> str:
"""Forge 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 Forge
never registered. Reuse the same hyphenation as the installed frontmatter
``name`` (see ``format_forge_command_name``), mirroring the skills agents.
"""
invocation = "/" + format_forge_command_name(command_name)
if args:
invocation = f"{invocation} {args}"
return invocation
def setup(
self,
project_root: Path,

View File

@@ -327,12 +327,18 @@ class IntegrationManifest:
project_root: Path | None = None,
*,
force: bool = False,
remove_manifest: bool = True,
) -> tuple[list[Path], list[Path]]:
"""Remove tracked files whose hash still matches.
Parameters:
project_root: Override for the project root.
force: If ``True``, remove files even if modified.
project_root: Override for the project root.
force: If ``True``, remove files even if modified.
remove_manifest: If ``True`` (default), also delete this
integration's ``{key}.manifest.json``. Set ``False`` for
*partial* cleanups (e.g. the upgrade stale-file pass, which
builds a throwaway manifest over a subset of files) so the
real, freshly-saved manifest for the same key is not destroyed.
Returns:
``(removed, skipped)`` — absolute paths.
@@ -393,7 +399,7 @@ class IntegrationManifest:
# Remove the manifest file itself
manifest = root / ".specify" / "integrations" / f"{self.key}.manifest.json"
if manifest.exists():
if remove_manifest and manifest.exists():
manifest.unlink()
parent = manifest.parent
while parent != root:

View File

@@ -1171,7 +1171,7 @@ class PresetManager:
selected_ai, fm, body, self.project_root
)
body = self._resolve_skill_command_refs(
body, registrar, selected_ai
body, registrar, selected_ai, self.project_root
)
from ..integrations import get_integration
integration = get_integration(selected_ai) if isinstance(selected_ai, str) else None
@@ -1252,7 +1252,10 @@ class PresetManager:
@staticmethod
def _resolve_skill_command_refs(
body: str, registrar: "CommandRegistrar", selected_ai: str
body: str,
registrar: "CommandRegistrar",
selected_ai: str,
project_root: "Path | None" = None,
) -> str:
"""Render ``__SPECKIT_COMMAND_*__`` tokens in a skill body as invocations.
@@ -1261,10 +1264,30 @@ class PresetManager:
slash-command invocation — ``/speckit-<cmd>`` for a ``-`` separator,
``/speckit.<cmd>`` for ``.`` — the same rendering the command layer
applies via ``CommandRegistrar.register_commands()``.
For dual-layout agents (e.g. Bob) the separator depends on the
project's persisted skills state, so — when *project_root* is provided
— the separator is resolved from the integration via
``invoke_separator_for_mode`` rather than the single static
``AGENT_CONFIGS`` value.
"""
separator = registrar.AGENT_CONFIGS.get(selected_ai, {}).get(
"invoke_separator", "."
)
separator = None
if project_root is not None and isinstance(selected_ai, str):
try:
from .. import load_init_options
from ..integrations import get_integration
integration = get_integration(selected_ai)
if integration is not None:
separator = integration.invoke_separator_for_mode(
is_ai_skills_enabled(load_init_options(project_root))
)
except Exception:
separator = None
if separator is None:
separator = registrar.AGENT_CONFIGS.get(selected_ai, {}).get(
"invoke_separator", "."
)
return IntegrationBase.resolve_command_refs(body, separator)
def _build_extension_skill_restore_index(self) -> Dict[str, Dict[str, Any]]:
@@ -1445,7 +1468,7 @@ class PresetManager:
body = registrar.resolve_skill_placeholders(
selected_ai, frontmatter, body, self.project_root
)
body = self._resolve_skill_command_refs(body, registrar, selected_ai)
body = self._resolve_skill_command_refs(body, registrar, selected_ai, self.project_root)
for target_skill_name in target_skill_names:
skill_subdir = skills_dir / target_skill_name
@@ -1540,7 +1563,7 @@ class PresetManager:
selected_ai, frontmatter, body, self.project_root
)
body = self._resolve_skill_command_refs(
body, registrar, selected_ai
body, registrar, selected_ai, self.project_root
)
original_desc = frontmatter.get("description", "")
@@ -1592,7 +1615,7 @@ class PresetManager:
selected_ai, frontmatter, body, self.project_root
)
body = self._resolve_skill_command_refs(
body, registrar, selected_ai
body, registrar, selected_ai, self.project_root
)
command_name = extension_restore["command_name"]
@@ -2108,13 +2131,22 @@ class PresetCatalog:
url: str,
timeout: int = 10,
extra_headers: Optional[Dict[str, str]] = None,
redirect_validator=None,
):
"""Open a URL with provider-based auth, trying each configured provider.
Delegates to :func:`specify_cli.authentication.http.open_url`.
*redirect_validator*, when provided, is invoked as ``(old_url, new_url)``
before EACH redirect hop, so an HTTPS host guarantee can be enforced on
every intermediate URL, not just the terminal one.
"""
from specify_cli.authentication.http import open_url
return open_url(url, timeout, extra_headers=extra_headers)
return open_url(
url,
timeout,
extra_headers=extra_headers,
redirect_validator=redirect_validator,
)
def _resolve_github_release_asset_api_url(
self,
@@ -2235,7 +2267,10 @@ class PresetCatalog:
)
try:
priority = int(raw_priority)
except (TypeError, ValueError):
except (TypeError, ValueError, OverflowError):
# OverflowError: int(float("inf")) — a YAML ``priority: .inf``
# would otherwise escape as an uncaught traceback instead of the
# clean validation error (mirrors catalogs.py).
raise PresetValidationError(
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
f"expected integer, got {raw_priority!r}"
@@ -2401,7 +2436,21 @@ class PresetCatalog:
pass
try:
with self._open_url(entry.url, timeout=10) as response:
# Validate EVERY redirect hop (not just the terminal URL): an
# https -> http -> attacker-controlled-https chain would pass a
# final-URL-only check while the insecure intermediate hop lets a
# network attacker rewrite the next redirect. redirect_validator runs
# before each hop; the final geturl() check is retained as a
# belt-and-braces guard. Mirrors bundler/services/adapters.py.
def _validate_redirect(_old_url: str, new_url: str) -> None:
self._validate_catalog_url(new_url)
with self._open_url(
entry.url, timeout=10, redirect_validator=_validate_redirect
) as response:
final_url = response.geturl()
if final_url != entry.url:
self._validate_catalog_url(final_url)
catalog_data = json.loads(response.read())
self._validate_catalog_payload(catalog_data, entry.url)
@@ -2552,7 +2601,18 @@ class PresetCatalog:
pass
try:
with self._open_url(catalog_url, timeout=10) as response:
# Same redirect hardening as _fetch_single_catalog: validate every
# redirect hop AND the final URL so this legacy single-catalog path
# is not vulnerable to an HTTPS->HTTP redirected payload either.
def _validate_redirect(_old_url: str, new_url: str) -> None:
self._validate_catalog_url(new_url)
with self._open_url(
catalog_url, timeout=10, redirect_validator=_validate_redirect
) as response:
final_url = response.geturl()
if final_url != catalog_url:
self._validate_catalog_url(final_url)
catalog_data = json.loads(response.read())
# Validate catalog structure. Reuses the same helper as
@@ -2717,8 +2777,20 @@ class PresetCatalog:
from urllib.parse import urlparse
parsed = urlparse(download_url)
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
# A malformed authority (e.g. an unterminated IPv6 bracket
# "https://[::1") makes urlparse / hostname access raise ValueError.
# The download_url comes from catalog payload data, so surface a clean
# PresetError rather than leaking a raw ValueError past the command
# handler (which only catches PresetError). Mirrors catalogs (#3435)
# and workflows/catalog.py (#3484).
try:
parsed = urlparse(download_url)
hostname = parsed.hostname
except ValueError:
raise PresetError(
f"Preset download URL is malformed: {download_url}"
) from None
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (
parsed.scheme == "http" and is_localhost
):

View File

@@ -13,6 +13,7 @@ from pathlib import Path
import typer
import yaml
from rich.markup import escape as _escape_markup
from .._console import console
@@ -107,8 +108,6 @@ def preset_add(
try:
_parsed = _urlparse(from_url)
except ValueError:
from rich.markup import escape as _escape_markup
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}")
raise typer.Exit(1)
@@ -141,9 +140,7 @@ def preset_add(
)
raise typer.Exit(1)
from rich.markup import escape as _esc
console.print(f"Installing preset from [cyan]{_esc(from_url)}[/cyan]...")
console.print(f"Installing preset from [cyan]{_escape_markup(from_url)}[/cyan]...")
import urllib.error
import tempfile
import shutil
@@ -183,7 +180,7 @@ def preset_add(
except TypeError:
output.write(response.read())
except urllib.error.URLError as e:
console.print(f"[red]Error:[/red] Failed to download: {e}")
console.print(f"[red]Error:[/red] Failed to download: {_escape_markup(str(e))}")
raise typer.Exit(1)
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
@@ -240,13 +237,13 @@ def preset_add(
raise typer.Exit(1)
except PresetCompatibilityError as e:
console.print(f"[red]Compatibility Error:[/red] {e}")
console.print(f"[red]Compatibility Error:[/red] {_escape_markup(str(e))}")
raise typer.Exit(1)
except PresetValidationError as e:
console.print(f"[red]Validation Error:[/red] {e}")
console.print(f"[red]Validation Error:[/red] {_escape_markup(str(e))}")
raise typer.Exit(1)
except PresetError as e:
console.print(f"[red]Error:[/red] {e}")
console.print(f"[red]Error:[/red] {_escape_markup(str(e))}")
raise typer.Exit(1)
@@ -288,7 +285,7 @@ def preset_search(
try:
results = catalog.search(query=query, tag=tag, author=author)
except PresetError as e:
console.print(f"[red]Error:[/red] {e}")
console.print(f"[red]Error:[/red] {_escape_markup(str(e))}")
raise typer.Exit(1)
if not results:
@@ -582,7 +579,7 @@ def preset_catalog_list():
try:
active_catalogs = catalog.get_active_catalogs()
except PresetValidationError as e:
console.print(f"[red]Error:[/red] {e}")
console.print(f"[red]Error:[/red] {_escape_markup(str(e))}")
raise typer.Exit(1)
console.print("\n[bold cyan]Active Preset Catalogs:[/bold cyan]\n")
@@ -647,7 +644,7 @@ def preset_catalog_add(
try:
tmp_catalog._validate_catalog_url(url)
except PresetValidationError as e:
console.print(f"[red]Error:[/red] {e}")
console.print(f"[red]Error:[/red] {_escape_markup(str(e))}")
raise typer.Exit(1)
config_path = specify_dir / "preset-catalogs.yml"
@@ -658,7 +655,7 @@ def preset_catalog_add(
config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
except Exception as e:
config_label = _display_project_path(project_root, config_path)
console.print(f"[red]Error:[/red] Failed to read {config_label}: {e}")
console.print(f"[red]Error:[/red] Failed to read {_escape_markup(str(config_label))}: {_escape_markup(str(e))}")
raise typer.Exit(1)
else:
config = {}

View File

@@ -402,8 +402,13 @@ def install_shared_infra(
# Track every shared path the current bundle produces so we can detect
# manifest entries the core no longer ships (stale-script cleanup, #3076).
seen_rels: set[str] = set()
scripts_scanned = False
variant_dir = {"sh": "bash", "py": "python"}.get(script_type, "powershell")
scanned_variant_dirs: set[str] = set()
shell_variant = "powershell" if os.name == "nt" else "bash"
variant_dirs = (
("python", shell_variant)
if script_type == "py"
else ("bash" if script_type == "sh" else "powershell",)
)
def _decide_overwrite(rel: str, dst: Path) -> tuple[bool, str | None]:
"""Return (write, bucket) where bucket is 'skip', 'preserved', or None."""
@@ -458,69 +463,69 @@ def install_shared_infra(
if scripts_src.is_dir():
dest_scripts = project_path / ".specify" / "scripts"
if _ensure_or_bucket_dir(dest_scripts):
variant_src = scripts_src / variant_dir
if variant_src.is_dir():
for variant_dir in variant_dirs:
variant_src = scripts_src / variant_dir
if not variant_src.is_dir():
continue
dest_variant = dest_scripts / variant_dir
if _ensure_or_bucket_dir(dest_variant):
for src_path in variant_src.rglob("*"):
if not src_path.is_file():
continue
# Python bytecode caches are local artifacts, not
# workflow scripts — never install them.
if "__pycache__" in src_path.parts:
continue
# Mark scanned only once a real source file is seen. An
# empty (or symlink-skipped) variant keeps this False, so
# stale-cleanup is skipped — otherwise it would treat every
# tracked script as obsolete and delete it. (The safety
# hinge is this flag, not ``seen_rels``, which also holds
# template paths populated later.)
scripts_scanned = True
if not _ensure_or_bucket_dir(dest_variant):
continue
for src_path in variant_src.rglob("*"):
if not src_path.is_file():
continue
# Python bytecode caches are local artifacts, not
# workflow scripts — never install them.
if "__pycache__" in src_path.parts:
continue
# Mark scanned only once a real source file is seen. An
# empty (or symlink-skipped) variant stays untracked, so
# stale-cleanup cannot treat its managed scripts as obsolete.
scanned_variant_dirs.add(variant_dir)
rel_path = src_path.relative_to(variant_src)
dst_path = dest_variant / rel_path
rel = dst_path.relative_to(project_path).as_posix()
seen_rels.add(rel)
if not _safe_dest_or_bucket(dst_path, rel, parent_must_exist=False):
continue
write, bucket = _decide_overwrite(rel, dst_path)
if not write:
if bucket == "preserved":
preserved_user_files.append(rel)
else:
skipped_files.append(rel)
# Record the existing-on-disk file in the manifest so a
# fresh manifest run against an already-populated
# ``.specify/`` tree does not silently drop it (#2107).
# ``prior_hashes`` is the function-scope snapshot taken
# at entry, so this membership check is O(1) and avoids
# the repeated ``dict(self._files)`` copy that
# ``manifest.files`` performs on every access.
if dst_path.is_file() and rel not in prior_hashes:
try:
manifest.record_existing(rel, recovered=True)
except (OSError, ValueError) as exc:
# Tolerate races / permission issues / non-file
# collisions so one weird path does not abort
# the whole install.
console.print(
f"[yellow]⚠[/yellow] could not record {rel} in manifest: {exc}"
)
continue
rel_path = src_path.relative_to(variant_src)
dst_path = dest_variant / rel_path
rel = dst_path.relative_to(project_path).as_posix()
seen_rels.add(rel)
if not _safe_dest_or_bucket(dst_path, rel, parent_must_exist=False):
continue
write, bucket = _decide_overwrite(rel, dst_path)
if not write:
if bucket == "preserved":
preserved_user_files.append(rel)
else:
skipped_files.append(rel)
# Record the existing-on-disk file in the manifest so a
# fresh manifest run against an already-populated
# ``.specify/`` tree does not silently drop it (#2107).
# ``prior_hashes`` is the function-scope snapshot taken
# at entry, so this membership check is O(1) and avoids
# the repeated ``dict(self._files)`` copy that
# ``manifest.files`` performs on every access.
if dst_path.is_file() and rel not in prior_hashes:
try:
manifest.record_existing(rel, recovered=True)
except (OSError, ValueError) as exc:
# Tolerate races / permission issues / non-file
# collisions so one weird path does not abort
# the whole install.
console.print(
f"[yellow]⚠[/yellow] could not record {rel} in manifest: {exc}"
)
continue
if not _ensure_or_bucket_dir(dst_path.parent):
continue
content = src_path.read_text(encoding="utf-8")
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
content = _resolve_dynamic_command_refs(content, invoke_separator)
planned_copies.append(
(
dst_path,
rel,
content.encode("utf-8"),
src_path.stat().st_mode & 0o777,
)
if not _ensure_or_bucket_dir(dst_path.parent):
continue
content = src_path.read_text(encoding="utf-8")
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
content = _resolve_dynamic_command_refs(content, invoke_separator)
planned_copies.append(
(
dst_path,
rel,
content.encode("utf-8"),
src_path.stat().st_mode & 0o777,
)
)
templates_src = shared_templates_source(core_pack=core_pack, repo_root=repo_root)
if templates_src.is_dir():
@@ -618,14 +623,16 @@ def install_shared_infra(
# agent-context extension. Left behind, such an orphan can crash when it
# sources a refreshed ``common.sh`` (#3076). Only run when the script source
# was actually scanned (so a missing/empty source never triggers mass
# deletion), scoped to the active variant, and only for *managed* copies —
# deletion), scoped to the selected variants, and only for *managed* copies —
# a user-customized file (hash diverges), a symlink, or a recovered entry is
# preserved by ``_is_managed``.
if scripts_scanned:
if scanned_variant_dirs:
stale_removed: list[str] = []
script_prefix = f".specify/scripts/{variant_dir}/"
script_prefixes = tuple(
f".specify/scripts/{variant_dir}/" for variant_dir in scanned_variant_dirs
)
for rel in list(prior_hashes):
if rel in seen_rels or not rel.startswith(script_prefix):
if rel in seen_rels or not rel.startswith(script_prefixes):
continue
# Guard corrupted/hand-edited manifest keys BEFORE any filesystem
# access: absolute, ``..``, or (on Windows) drive-relative keys such

View File

@@ -49,6 +49,13 @@ workflow_step_catalog_app = typer.Typer(
)
workflow_step_app.add_typer(workflow_step_catalog_app, name="catalog")
workflow_overlay_app = typer.Typer(
name="overlay",
help="Manage workflow overlays",
add_completion=False,
)
workflow_app.add_typer(workflow_overlay_app, name="overlay")
def _error_console(json_output: bool):
"""Console for error text: stderr under ``--json`` so the JSON stdout
@@ -192,6 +199,10 @@ def _reject_unsafe_workflow_storage(project_root: Path) -> None:
project_root / ".specify" / "workflows" / "runs",
".specify/workflows/runs",
)
_reject_unsafe_dir(
project_root / ".specify" / "workflows" / "overlays",
".specify/workflows/overlays",
)
def _scan_for_workflow_owner(parts: tuple[str, ...]) -> int | None:
@@ -366,7 +377,7 @@ def _resolve_installed_workflow_ownership(
_WORKFLOW_ID_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
_RESERVED_WORKFLOW_IDS: frozenset[str] = frozenset({"runs", "steps"})
_RESERVED_WORKFLOW_IDS: frozenset[str] = frozenset({"overlays", "runs", "steps"})
def _reject_insecure_download_redirect(old_url: str, new_url: str) -> None:
@@ -1262,14 +1273,18 @@ def workflow_status(
engine = WorkflowEngine(project_root)
if run_id:
# Route errors to stderr under --json so the stdout JSON stream stays
# parseable (mirrors `workflow run`/`workflow resume`); both handlers
# fire before the json_output branch below.
err = _error_console(json_output)
try:
from .engine import RunState
state = RunState.load(run_id, project_root)
except FileNotFoundError:
console.print(f"[red]Error:[/red] Run not found: {run_id}")
err.print(f"[red]Error:[/red] Run not found: {run_id}")
raise typer.Exit(1)
except ValueError as exc:
console.print(f"[red]Error:[/red] {_escape_markup(str(exc))}")
err.print(f"[red]Error:[/red] {_escape_markup(str(exc))}")
raise typer.Exit(1)
if json_output:
@@ -1540,7 +1555,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():
@@ -1666,8 +1681,8 @@ def workflow_add(
except OSError as cleanup_exc:
console.print(
"[yellow]Warning:[/yellow] Could not remove temporary "
f"download file {_escape_markup(str(tmp_path))}: "
f"{_escape_markup(str(cleanup_exc))}"
f"workflow download file: {_escape_markup(str(cleanup_exc))} "
f"(path: {_escape_markup(str(tmp_path))})"
)
console.print(f"[red]Error:[/red] Failed to download workflow: {_escape_markup(str(exc))}")
raise typer.Exit(1)
@@ -1691,15 +1706,15 @@ def workflow_add(
except OSError as exc:
console.print(
"[yellow]Warning:[/yellow] Could not remove temporary "
f"download file {_escape_markup(str(tmp_path))}: "
f"{_escape_markup(str(exc))}"
f"workflow download file: {_escape_markup(str(exc))} "
f"(path: {_escape_markup(str(tmp_path))})"
)
return
# 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():
@@ -2382,6 +2397,9 @@ def workflow_info(
# Local workflow definition not found on disk; fall back to
# catalog/registry lookup below.
pass
except ValueError as exc:
console.print(f"[red]Error:[/red] Invalid workflow: {_escape_markup(str(exc))}")
raise typer.Exit(1)
if definition:
console.print(f"\n[bold cyan]{definition.name}[/bold cyan] ({definition.id})")
@@ -3148,6 +3166,102 @@ def workflow_step_catalog_remove(
console.print(f"[green]✓[/green] Step catalog source '{removed_name}' removed")
@workflow_overlay_app.command("add")
def workflow_overlay_add_cmd(
source: Path = typer.Argument(..., help="Path to overlay YAML file"),
priority: int = typer.Option(
10,
"--priority",
help="Resolution priority (lower = higher precedence, default 10)",
),
):
"""Add a project-local overlay for a workflow."""
from .overlays._commands import workflow_overlay_add
project_root = _require_specify_project()
if workflow_overlay_add(project_root, source, priority) is None:
raise typer.Exit(1)
@workflow_overlay_app.command("set-priority")
def workflow_overlay_set_priority_cmd(
workflow_id: str = typer.Argument(..., help="Workflow ID the overlay extends"),
overlay_id: str = typer.Argument(..., help="Overlay ID"),
priority: int = typer.Argument(
..., help="New priority (lower = higher precedence)"
),
):
"""Set the priority of a project-local overlay."""
from .overlays._commands import workflow_overlay_set_priority
project_root = _require_specify_project()
if not workflow_overlay_set_priority(project_root, workflow_id, overlay_id, priority):
raise typer.Exit(1)
@workflow_overlay_app.command("enable")
def workflow_overlay_enable_cmd(
workflow_id: str = typer.Argument(..., help="Workflow ID the overlay extends"),
overlay_id: str = typer.Argument(..., help="Overlay ID"),
):
"""Enable a project-local overlay."""
from .overlays._commands import workflow_overlay_enable
project_root = _require_specify_project()
if not workflow_overlay_enable(project_root, workflow_id, overlay_id):
raise typer.Exit(1)
@workflow_overlay_app.command("disable")
def workflow_overlay_disable_cmd(
workflow_id: str = typer.Argument(..., help="Workflow ID the overlay extends"),
overlay_id: str = typer.Argument(..., help="Overlay ID"),
):
"""Disable a project-local overlay."""
from .overlays._commands import workflow_overlay_disable
project_root = _require_specify_project()
if not workflow_overlay_disable(project_root, workflow_id, overlay_id):
raise typer.Exit(1)
@workflow_overlay_app.command("remove")
def workflow_overlay_remove_cmd(
workflow_id: str = typer.Argument(..., help="Workflow ID the overlay extends"),
overlay_id: str = typer.Argument(..., help="Overlay ID"),
):
"""Remove a project-local overlay."""
from .overlays._commands import workflow_overlay_remove
project_root = _require_specify_project()
if not workflow_overlay_remove(project_root, workflow_id, overlay_id):
raise typer.Exit(1)
@workflow_overlay_app.command("list")
def workflow_overlay_list_cmd(
workflow_id: str = typer.Argument(..., help="Workflow ID"),
):
"""List overlays for a workflow."""
from .overlays._commands import workflow_overlay_list
project_root = _require_specify_project()
if workflow_overlay_list(project_root, workflow_id) is None:
raise typer.Exit(1)
@workflow_app.command("resolve")
def workflow_resolve_cmd(
workflow_id: str = typer.Argument(..., help="Workflow ID to resolve"),
):
"""Show layer attribution for a resolved workflow."""
from .overlays._commands import workflow_resolve
project_root = _require_specify_project()
if workflow_resolve(project_root, workflow_id) is None:
raise typer.Exit(1)
def register(app: typer.Typer) -> None:
"""Attach the workflow command group to the root Typer app."""
app.add_typer(workflow_app, name="workflow")

View File

@@ -364,13 +364,24 @@ class WorkflowCatalog:
if not url:
continue
self._validate_catalog_url(url)
try:
priority = int(item.get("priority", idx + 1))
except (TypeError, ValueError):
raw_priority = item.get("priority", idx + 1)
# bool is an int subclass: int(True) == 1 would silently accept a
# ``priority: true`` as priority 1. Reject it explicitly, mirroring
# the base CatalogStackBase loader.
if isinstance(raw_priority, bool):
raise WorkflowValidationError(
f"Invalid priority for catalog "
f"'{item.get('name', idx + 1)}': "
f"expected integer, got {item.get('priority')!r}"
f"expected integer, got {raw_priority!r}"
)
try:
priority = int(raw_priority)
except (TypeError, ValueError, OverflowError):
# OverflowError: int(float("inf")) — a ``priority: .inf``.
raise WorkflowValidationError(
f"Invalid priority for catalog "
f"'{item.get('name', idx + 1)}': "
f"expected integer, got {raw_priority!r}"
)
raw_install = item.get("install_allowed", False)
if isinstance(raw_install, str):
@@ -512,8 +523,20 @@ class WorkflowCatalog:
_validate_catalog_url(entry.url)
# Validate EVERY redirect hop, not just the final URL: _open_url follows
# redirects, so an https:// entry that 30x-redirects through http:// (or
# to a non-HTTPS host mid-chain) could otherwise let a network attacker
# rewrite the next hop and slip a payload past a final-URL-only check.
# redirect_validator runs before each hop; the geturl() check below is
# retained as a defense-in-depth backstop. Mirrors the presets/extensions
# catalog fix (#3523 / #3524).
def _validate_redirect(_old_url: str, new_url: str) -> None:
_validate_catalog_url(new_url)
try:
with _open_url(entry.url, timeout=30) as resp:
with _open_url(
entry.url, timeout=30, redirect_validator=_validate_redirect
) as resp:
_validate_catalog_url(resp.geturl())
data = json.loads(resp.read().decode("utf-8"))
except Exception as exc:
@@ -685,7 +708,9 @@ class WorkflowCatalog:
def _coerce_priority(value: Any) -> int:
try:
return int(value)
except (TypeError, ValueError):
except (TypeError, ValueError, OverflowError):
# OverflowError: int(float("inf")) — treat an uncoercible
# existing priority as 0 rather than crashing 'catalog add'.
return 0
max_priority = max(
@@ -869,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()
@@ -1007,13 +1036,23 @@ class StepCatalog:
if not url:
continue
self._validate_catalog_url(url)
try:
priority = int(item.get("priority", idx + 1))
except (TypeError, ValueError):
raw_priority = item.get("priority", idx + 1)
# bool is an int subclass: reject ``priority: true`` explicitly rather
# than silently coercing it to 1 (mirrors CatalogStackBase).
if isinstance(raw_priority, bool):
raise StepValidationError(
f"Invalid priority for catalog "
f"'{item.get('name', idx + 1)}': "
f"expected integer, got {item.get('priority')!r}"
f"expected integer, got {raw_priority!r}"
)
try:
priority = int(raw_priority)
except (TypeError, ValueError, OverflowError):
# OverflowError: int(float("inf")) — a ``priority: .inf``.
raise StepValidationError(
f"Invalid priority for catalog "
f"'{item.get('name', idx + 1)}': "
f"expected integer, got {raw_priority!r}"
)
raw_install = item.get("install_allowed", False)
if isinstance(raw_install, str):
@@ -1157,8 +1196,20 @@ class StepCatalog:
_validate_url(entry.url)
# Validate EVERY redirect hop, not just the final URL: _open_url follows
# redirects, so an https:// entry that 30x-redirects through http:// (or
# to a non-HTTPS host mid-chain) could otherwise let a network attacker
# rewrite the next hop and slip a payload past a final-URL-only check.
# redirect_validator runs before each hop; the geturl() check below is
# retained as a defense-in-depth backstop. Mirrors the presets/extensions
# catalog fix (#3523 / #3524).
def _validate_redirect(_old_url: str, new_url: str) -> None:
_validate_url(new_url)
try:
with _open_url(entry.url, timeout=30) as resp:
with _open_url(
entry.url, timeout=30, redirect_validator=_validate_redirect
) as resp:
_validate_url(resp.geturl())
data = json.loads(resp.read().decode("utf-8"))
except Exception as exc:
@@ -1314,7 +1365,9 @@ class StepCatalog:
def _coerce_priority(value: Any) -> int:
try:
return int(value)
except (TypeError, ValueError):
except (TypeError, ValueError, OverflowError):
# OverflowError: int(float("inf")) — treat an uncoercible
# existing priority as 0 rather than crashing 'catalog add'.
return 0
max_priority = max(

View File

@@ -79,7 +79,11 @@ class WorkflowDefinition:
def from_yaml(cls, path: Path) -> WorkflowDefinition:
"""Load a workflow definition from a YAML file."""
with open(path, encoding="utf-8") as f:
data = yaml.safe_load(f)
try:
data = yaml.safe_load(f)
except yaml.YAMLError as exc:
msg = f"Invalid YAML in {path}: {exc}"
raise ValueError(msg) from exc
if not isinstance(data, dict):
msg = f"Workflow YAML must be a mapping, got {type(data).__name__}."
raise ValueError(msg)
@@ -88,7 +92,11 @@ class WorkflowDefinition:
@classmethod
def from_string(cls, content: str) -> WorkflowDefinition:
"""Load a workflow definition from a YAML string."""
data = yaml.safe_load(content)
try:
data = yaml.safe_load(content)
except yaml.YAMLError as exc:
msg = f"Invalid YAML: {exc}"
raise ValueError(msg) from exc
if not isinstance(data, dict):
msg = f"Workflow YAML must be a mapping, got {type(data).__name__}."
raise ValueError(msg)
@@ -193,6 +201,20 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
f"Must be 'string', 'number', or 'boolean'."
)
# ``enum`` must be a list. Checked here — not only via the
# ``_coerce_input`` call below — because that call is reached only
# when a ``default`` is present, and the ``integration: auto`` case
# strips ``enum`` before coercing; a scalar/string ``enum`` on an
# input with no default (or the auto-integration default) would
# otherwise slip through here and then crash ``_resolve_inputs`` with
# a raw ``TypeError`` at run time. ``None`` means "no enum".
enum_values = input_def.get("enum")
if enum_values is not None and not isinstance(enum_values, list):
errors.append(
f"Input {input_name!r} has invalid 'enum': must be a list, "
f"got {type(enum_values).__name__}."
)
# Validate the default eagerly so authoring mistakes (e.g. a
# default not in the declared enum, or a non-numeric default for
# a number input) surface at install/validation time instead of
@@ -201,13 +223,28 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
# enum-membership check is exempted for that exact case — the
# declared type is still enforced (e.g. ``type: number`` paired
# with ``default: "auto"`` is still rejected).
enum_is_valid = enum_values is None or isinstance(enum_values, list)
if "default" in input_def:
default_value = input_def["default"]
is_auto_integration = (
input_name == "integration" and default_value == "auto"
)
# Strip ``enum`` from the definition handed to ``_coerce_input``
# when either:
# * this is the auto-integration sentinel (enum-membership is
# a runtime concern, exempted for ``"auto"``), or
# * the ``enum`` is malformed (non-list) and already reported
# above — leaving it in would make ``_coerce_input`` re-raise
# the same enum-shape error re-framed as an "invalid default"
# (a confusing duplicate).
# Removing *only* ``enum`` (rather than skipping the check
# entirely) preserves the default's type validation: a
# ``type: string`` input with ``default: 5, enum: 5`` still
# reports the wrong-typed default alongside the enum error,
# instead of hiding it.
strip_enum = is_auto_integration or not enum_is_valid
validation_input_def: dict[str, Any] = input_def
if is_auto_integration and "enum" in input_def:
if strip_enum and "enum" in input_def:
validation_input_def = {
key: value
for key, value in input_def.items()
@@ -727,13 +764,24 @@ class WorkflowEngine:
ValueError:
If the workflow YAML is invalid.
"""
from .overlays import WorkflowResolver
path = Path(source).expanduser()
# Try as a direct file path first
if path.suffix.lower() in (".yml", ".yaml") and path.is_file():
return WorkflowDefinition.from_yaml(path)
# Try as an installed workflow ID
# Try as an installed workflow ID, resolving any overlays.
resolver = WorkflowResolver(self.project_root)
try:
return resolver.resolve(str(source))
except FileNotFoundError:
# Fall back to the direct workflow.yml path so callers still get
# the original error when the workflow id is not installed.
pass
# Legacy direct path check for workflows installed without registry entries.
installed_path = (
self.project_root
/ ".specify"
@@ -1381,11 +1429,18 @@ class WorkflowEngine:
# definition (``string`` rejects non-strings, ``number`` rejects
# bools and uncoercible values, ``boolean`` rejects non-bools),
# so ill-typed values still fail fast here.
#
# ``execute()`` accepts unvalidated definitions, so a malformed
# (non-list) ``enum`` can reach here. Only strip a *list* ``enum``:
# a scalar/string ``enum`` must stay in the definition so
# ``_coerce_input`` raises the clean shape ``ValueError`` instead of
# being silently exempted by the ``auto`` membership skip (which
# would otherwise let ``enum: 5`` resolve successfully).
coerce_input_def = input_def
if (
name == "integration"
and value == "auto"
and "enum" in input_def
and isinstance(input_def.get("enum"), list)
):
coerce_input_def = {
key: val
@@ -1431,6 +1486,22 @@ class WorkflowEngine:
input_type = input_def.get("type", "string")
enum_values = input_def.get("enum")
# ``enum`` must be a list. A scalar (``enum: 5``, ``enum: true``) makes
# the ``value not in enum_values`` membership test below raise a raw
# ``TypeError`` ("argument of type 'int' is not ... iterable"), which
# escapes ``validate_workflow``'s ``except ValueError`` and breaks its
# "return errors, never raise" contract — and crashes ``_resolve_inputs``
# outright at run time. A bare string is just as wrong: ``value in "abc"``
# is a silent substring/character test, not enum membership. Require a
# list so both forms fail fast with a clear message. ``None`` means "no
# enum" and is left alone.
if enum_values is not None and not isinstance(enum_values, list):
msg = (
f"Input {name!r} has invalid 'enum': must be a list, got "
f"{type(enum_values).__name__}."
)
raise ValueError(msg)
if input_type == "number":
# Reject bools explicitly: ``bool`` is a subclass of ``int`` so
# ``float(True)`` succeeds and would silently coerce a YAML

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

@@ -0,0 +1,95 @@
"""Workflow overlay resolver — composes installed workflows from layers."""
from __future__ import annotations
from pathlib import Path
from ..engine import WorkflowDefinition
from .composer import StepListComposer
from .layer_sources import (
BaseWorkflowSource,
Layer,
ProjectOverlaySource,
)
from .merge import ComposedStep
from .schema import _RESERVED_WORKFLOW_IDS, _SAFE_ID_PATTERN
def _validate_workflow_id(workflow_id: str) -> None:
"""Reject workflow IDs that are unsafe as installed-storage path segments."""
if (
not isinstance(workflow_id, str)
or not _SAFE_ID_PATTERN.fullmatch(workflow_id)
or workflow_id in _RESERVED_WORKFLOW_IDS
):
raise ValueError(f"Invalid workflow ID: {workflow_id!r}")
class WorkflowResolver:
"""Resolves a workflow ID to its composed ``WorkflowDefinition``.
Collects layers from two tiers:
- project-local overlays (``.specify/workflows/overlays/<id>/*.yml``)
- the base workflow itself (``.specify/workflows/<id>/workflow.yml``)
Resolution is lower-wins: overlays with lower priority numbers are applied
later and override earlier edits on the same anchors.
"""
def __init__(self, project_root: Path) -> None:
self.project_root = project_root
self._sources = [
ProjectOverlaySource(project_root),
BaseWorkflowSource(project_root),
]
self._composer = StepListComposer()
def collect_all_layers(
self, workflow_id: str, *, include_disabled: bool = False
) -> list[Layer]:
"""Collect overlays sorted by precedence, followed by the base layer.
Lower priority numbers win. Ties are sorted alphabetically by source,
matching ``PresetRegistry.list_by_priority()``. The base workflow is a
foundation rather than a precedence candidate, so it is kept separate.
"""
_validate_workflow_id(workflow_id)
all_layers: list[Layer] = []
for source in self._sources:
all_layers.extend(
source.collect(workflow_id, include_disabled=include_disabled)
)
overlays = [layer for layer in all_layers if layer.tier != "base"]
base_layers = [layer for layer in all_layers if layer.tier == "base"]
return (
sorted(overlays, key=lambda layer: (layer.priority, layer.source))
+ base_layers
)
def resolve(self, workflow_id: str) -> WorkflowDefinition:
"""Resolve a workflow ID to its composed definition.
This method composes layers but does not validate workflow semantics;
callers should validate the returned definition when needed.
Raises:
FileNotFoundError: if the workflow cannot be found.
ValueError: if layer collection/composition fails.
"""
layers = self.collect_all_layers(workflow_id)
definition, _ = self._composer.compose(layers)
if definition is None:
raise FileNotFoundError(f"Workflow not found: {workflow_id}")
return definition
def resolve_with_layers(
self, workflow_id: str
) -> tuple[WorkflowDefinition, list[Layer], list[ComposedStep]]:
"""Resolve a workflow and return its definition plus layer attribution."""
layers = self.collect_all_layers(workflow_id)
definition, attribution = self._composer.compose(layers)
if definition is None:
raise FileNotFoundError(f"Workflow not found: {workflow_id}")
return definition, layers, attribution

View File

@@ -0,0 +1,442 @@
"""CLI handlers for ``specify workflow overlay *`` and ``specify workflow resolve``."""
from __future__ import annotations
from pathlib import Path
from typing import Any
import typer
import yaml
from ..._console import console, err_console
from ...extensions import normalize_priority
from .._commands import (
_commit_workflow_file,
_discard_committed_backup_file,
_reject_unsafe_dir,
_reject_unsafe_workflow_storage,
_safe_discard_staged_workflow_file,
_stage_workflow_file,
)
from . import WorkflowResolver
from .schema import _RESERVED_WORKFLOW_IDS, _SAFE_ID_PATTERN, validate_overlay_yaml
def _validate_overlay_id_or_exit(id_value: str, label: str) -> None:
"""Validate a single-segment overlay/workflow id from CLI arguments."""
if not isinstance(id_value, str) or not id_value:
err_console.print(f"[red]Error:[/red] {label} is required and must be a non-empty string.")
raise typer.Exit(1)
if not _SAFE_ID_PATTERN.fullmatch(id_value):
err_console.print(
f"[red]Error:[/red] Invalid {label} {id_value!r}: "
"only lowercase letters, digits, and hyphens are allowed."
)
raise typer.Exit(1)
def _validate_workflow_id_or_exit(workflow_id: str) -> None:
"""Validate a workflow id, treating the overlay root as reserved."""
_validate_overlay_id_or_exit(workflow_id, "workflow ID")
if workflow_id in _RESERVED_WORKFLOW_IDS:
err_console.print(
f"[red]Error:[/red] Invalid workflow ID {workflow_id!r}: "
"reserved name."
)
raise typer.Exit(1)
def _overlay_root(project_root: Path) -> Path:
"""Return the project-local overlay root after rejecting unsafe ancestors."""
_reject_unsafe_workflow_storage(project_root)
root = project_root / ".specify" / "workflows" / "overlays"
_reject_unsafe_dir(root, ".specify/workflows/overlays")
return root
def _project_overlay_dir(project_root: Path, workflow_id: str) -> Path:
"""Return the project-local overlay directory for a workflow id.
Raises typer.Exit if the resolved path escapes the overlay root.
"""
_validate_workflow_id_or_exit(workflow_id)
root = _overlay_root(project_root)
target = root / workflow_id
return _ensure_contained_dir(target, root)
def _ensure_contained_dir(path: Path, root: Path) -> Path:
"""Ensure *path* resolves inside *root* and is not a symlink.
Returns *path* if safe. Raises typer.Exit on traversal or symlink.
"""
_reject_unsafe_dir(root, ".specify/workflows/overlays")
if path.is_symlink():
err_console.print(
f"[red]Error:[/red] Refusing to use symlinked path {path}."
)
raise typer.Exit(1)
if path.exists() and not path.is_dir():
err_console.print(
f"[red]Error:[/red] Overlay directory path is not a directory: {path}."
)
raise typer.Exit(1)
try:
resolved = path.resolve()
root_resolved = root.resolve()
resolved.relative_to(root_resolved)
except ValueError:
err_console.print(
f"[red]Error:[/red] Path traversal detected: {path} is outside the allowed directory."
)
raise typer.Exit(1)
return path
def _find_overlay_file(project_root: Path, workflow_id: str, overlay_id: str) -> Path | None:
"""Locate a project-local overlay file by its manifest ID, not filename.
Scans all YAML files in the overlay directory and matches on the ``id``
field inside each manifest. This aligns with ``ProjectOverlaySource.collect()``
which also derives identity from the manifest, not the filename.
"""
_validate_workflow_id_or_exit(workflow_id)
_validate_overlay_id_or_exit(overlay_id, "overlay ID")
overlay_dir = _project_overlay_dir(project_root, workflow_id)
if not overlay_dir.is_dir():
return None
try:
entries = sorted(overlay_dir.iterdir())
except OSError:
return None
matches: list[Path] = []
for path in entries:
if not path.is_file() or path.suffix not in (".yml", ".yaml"):
continue
if path.is_symlink():
continue
data, _ = _read_overlay(path)
if data is None:
continue
if data.get("id") == overlay_id:
matches.append(path)
if len(matches) > 1:
paths = ", ".join(str(path) for path in matches)
err_console.print(
f"[red]Error:[/red] Duplicate overlay ID '{overlay_id}' in {paths}. "
"Resolve the duplicate manifest IDs before continuing."
)
raise typer.Exit(1)
return matches[0] if matches else None
def _ensure_contained_path(path: Path, root: Path) -> Path:
"""Return *path* only if it resolves inside *root*; otherwise raise typer.Exit."""
_reject_unsafe_dir(root, ".specify/workflows/overlays")
if path.is_symlink():
err_console.print(
f"[red]Error:[/red] Refusing to use symlinked path {path}."
)
raise typer.Exit(1)
try:
resolved = path.resolve()
root_resolved = root.resolve()
resolved.relative_to(root_resolved)
except ValueError:
err_console.print(
f"[red]Error:[/red] Path traversal detected: {path} is outside the allowed directory."
)
raise typer.Exit(1)
return path
def _read_overlay(path: Path) -> tuple[dict[str, Any] | None, list[str]]:
"""Read and parse an overlay YAML file, returning (data, errors)."""
try:
content = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as exc:
return None, [f"Failed to read {path}: {exc}"]
try:
data = yaml.safe_load(content)
except yaml.YAMLError as exc:
return None, [f"Invalid YAML in {path}: {exc}"]
if not isinstance(data, dict):
return None, [f"Overlay {path} must be a YAML mapping."]
return data, []
def workflow_overlay_add(
project_root: Path,
source: Path,
priority: int | None = None,
) -> Path | None:
"""Add a project-local overlay from a YAML file.
Returns the path of the installed overlay file, or None on failure.
"""
_reject_unsafe_workflow_storage(project_root)
data, errors = _read_overlay(source)
if data is None:
for err in errors:
err_console.print(f"[red]Error:[/red] {err}")
return None
# Apply --priority override before validation so a valid CLI priority
# can fix a missing or invalid priority in the file.
if priority is not None:
if isinstance(priority, bool) or not isinstance(priority, int) or priority < 1:
err_console.print("[red]Error:[/red] Priority must be >= 1.")
return None
data["priority"] = normalize_priority(priority)
overlay, validation_errors = validate_overlay_yaml(data)
if overlay is None:
err_console.print("[red]Error:[/red] Overlay validation failed:")
for err in validation_errors:
err_console.print(f" \u2022 {err}")
return None
data["priority"] = overlay.priority
target_dir = _project_overlay_dir(project_root, overlay.extends)
# Reuse an existing .yaml file so we don't create a duplicate .yml layer.
existing = _find_overlay_file(project_root, overlay.extends, overlay.id)
if existing is not None:
target_path = existing
else:
target_path = _ensure_contained_path(
target_dir / f"{overlay.id}.yml", _overlay_root(project_root)
)
backup: Path | None = None
try:
target_dir.mkdir(parents=True, exist_ok=True)
existed_before = target_path.exists()
staged = _stage_workflow_file(target_path.parent)
try:
staged.write_bytes(yaml.safe_dump(data, sort_keys=False).encode("utf-8"))
backup = _commit_workflow_file(staged, target_path, existed_before)
except BaseException:
_safe_discard_staged_workflow_file(
staged, target_path.parent, existed_before
)
raise
except OSError as exc:
err_console.print(f"[red]Error:[/red] Failed to write overlay: {exc}")
return None
_discard_committed_backup_file(backup)
console.print(
f"[green]\u2713[/green] Overlay '{overlay.id}' added for workflow '{overlay.extends}'"
)
return target_path
def _update_overlay_field(
project_root: Path,
workflow_id: str,
overlay_id: str,
field: str,
value: Any,
) -> bool:
"""Update a single field in a project-local overlay file."""
_reject_unsafe_workflow_storage(project_root)
path = _find_overlay_file(project_root, workflow_id, overlay_id)
if path is None:
err_console.print(
f"[red]Error:[/red] Overlay '{overlay_id}' not found for workflow '{workflow_id}'"
)
return False
data, errors = _read_overlay(path)
if data is None:
for err in errors:
err_console.print(f"[red]Error:[/red] {err}")
return False
data[field] = value
overlay, validation_errors = validate_overlay_yaml(data)
if overlay is None:
err_console.print("[red]Error:[/red] Overlay validation failed:")
for err in validation_errors:
err_console.print(f" \u2022 {err}")
return False
backup: Path | None = None
try:
existed_before = path.exists()
staged = _stage_workflow_file(path.parent)
try:
staged.write_bytes(yaml.safe_dump(data, sort_keys=False).encode("utf-8"))
backup = _commit_workflow_file(staged, path, existed_before)
except BaseException:
_safe_discard_staged_workflow_file(staged, path.parent, existed_before)
raise
except OSError as exc:
err_console.print(f"[red]Error:[/red] Failed to write overlay: {exc}")
return False
_discard_committed_backup_file(backup)
return True
def workflow_overlay_set_priority(
project_root: Path,
workflow_id: str,
overlay_id: str,
priority: int,
) -> bool:
"""Set the priority of a project-local overlay."""
if isinstance(priority, bool) or not isinstance(priority, int) or priority < 1:
err_console.print("[red]Error:[/red] Priority must be >= 1.")
raise typer.Exit(1)
normalized_priority = normalize_priority(priority)
if _update_overlay_field(
project_root, workflow_id, overlay_id, "priority", normalized_priority
):
console.print(
f"[green]\u2713[/green] Priority of overlay '{overlay_id}' set to {normalized_priority}"
)
return True
return False
def workflow_overlay_enable(
project_root: Path,
workflow_id: str,
overlay_id: str,
) -> bool:
"""Enable a project-local overlay."""
if _update_overlay_field(project_root, workflow_id, overlay_id, "enabled", True):
console.print(f"[green]\u2713[/green] Overlay '{overlay_id}' enabled")
return True
return False
def workflow_overlay_disable(
project_root: Path,
workflow_id: str,
overlay_id: str,
) -> bool:
"""Disable a project-local overlay."""
if _update_overlay_field(project_root, workflow_id, overlay_id, "enabled", False):
console.print(f"[green]\u2713[/green] Overlay '{overlay_id}' disabled")
return True
return False
def workflow_overlay_remove(
project_root: Path,
workflow_id: str,
overlay_id: str,
) -> bool:
"""Remove a project-local overlay file."""
_reject_unsafe_workflow_storage(project_root)
path = _find_overlay_file(project_root, workflow_id, overlay_id)
if path is None:
err_console.print(
f"[red]Error:[/red] Overlay '{overlay_id}' not found for workflow '{workflow_id}'"
)
return False
try:
path.unlink()
except OSError as exc:
err_console.print(f"[red]Error:[/red] Failed to remove overlay: {exc}")
return False
console.print(f"[green]\u2713[/green] Overlay '{overlay_id}' removed")
return True
def workflow_overlay_list(project_root: Path, workflow_id: str) -> list[dict[str, Any]] | None:
"""List all overlays for a workflow and print a summary table.
Returns the raw list data for machine-readable callers, or None on error.
"""
_reject_unsafe_workflow_storage(project_root)
_validate_workflow_id_or_exit(workflow_id)
resolver = WorkflowResolver(project_root)
try:
layers = resolver.collect_all_layers(workflow_id, include_disabled=True)
except ValueError as exc:
err_console.print(f"[red]Error:[/red] {exc}")
return None
overlays = [layer for layer in layers if layer.tier != "base"]
if not overlays:
console.print(f"[yellow]No overlays found for workflow '{workflow_id}'.[/yellow]")
return []
console.print(f"Overlays for workflow '{workflow_id}':")
rows: list[dict[str, Any]] = []
for layer in overlays:
overlay = layer.content
rows.append({
"id": overlay.id,
"source": layer.source,
"tier": layer.tier,
"priority": normalize_priority(overlay.priority),
"enabled": overlay.enabled,
"path": str(layer.path) if layer.path else None,
})
enabled_marker = "enabled" if overlay.enabled else "disabled"
console.print(
f" \u2022 {overlay.id} (priority={normalize_priority(overlay.priority)}, "
f"source={layer.source}, {enabled_marker})"
)
return rows
def workflow_resolve(project_root: Path, workflow_id: str) -> dict[str, Any] | None:
"""Print layer attribution for a resolved workflow.
Returns a serializable attribution payload.
"""
_reject_unsafe_workflow_storage(project_root)
_validate_workflow_id_or_exit(workflow_id)
resolver = WorkflowResolver(project_root)
try:
definition, layers, attribution = resolver.resolve_with_layers(workflow_id)
except FileNotFoundError:
err_console.print(
f"[red]Error:[/red] Workflow '{workflow_id}' not found"
)
return None
except ValueError as exc:
err_console.print(f"[red]Error:[/red] {exc}")
return None
console.print(f"Resolved workflow '{workflow_id}':")
console.print("Layers (highest precedence first):")
for layer in layers:
priority = (
"n/a" if layer.tier == "base" else str(normalize_priority(layer.priority))
)
console.print(
f" \u2022 [{layer.tier}] {layer.source} "
f"(priority={priority})"
)
console.print("Step attribution:")
for composed in attribution:
console.print(f" \u2022 {composed.step_id}: {composed.source}")
return {
"workflow_id": workflow_id,
"layers": [
{
"source": layer.source,
"tier": layer.tier,
"priority": (
None
if layer.tier == "base"
else normalize_priority(layer.priority)
),
}
for layer in layers
],
"attribution": [
{"step_id": composed.step_id, "source": composed.source}
for composed in attribution
],
}

View File

@@ -0,0 +1,97 @@
"""Workflow overlay composer — builds a WorkflowDefinition from layers."""
from __future__ import annotations
from typing import Any
from ..engine import WorkflowDefinition
from .layer_sources import Layer
from .merge import OverlayLayer, merge_steps, validate_edits
class StepListComposer:
"""Compose a workflow from a base layer and overlay layers.
- The base layer (tier="base") provides the full step list.
- Overlay layers provide edit operations.
- Overlays are applied in merge order: highest priority number first,
lowest last, so lower priority numbers win. Ties are applied by overlay
ID, with the alphabetically last ID winning.
- Returns a parsed WorkflowDefinition; callers must validate separately.
"""
def compose(
self, layers: list[Layer]
) -> tuple[WorkflowDefinition | None, list]:
"""Compose a ``WorkflowDefinition`` from the given layers.
Returns ``(None, [])`` when no base layer is present.
"""
base_layer: Layer | None = None
overlay_layers: list[Layer] = []
for layer in layers:
if layer.tier == "base":
base_layer = layer
else:
overlay_layers.append(layer)
if base_layer is None or base_layer.path is None:
return None, []
# Read the base workflow definition from disk.
base_definition = WorkflowDefinition.from_yaml(base_layer.path)
base_steps = base_definition.data.get("steps", [])
if not isinstance(base_steps, list):
# Preserve the invalid definition intact so validate_workflow can
# report "'steps' must be a list." to the caller; coercing to []
# here would mask that error.
return base_definition, []
# Last applied wins, so apply lower priority numbers last.
merge_order = sorted(
overlay_layers,
key=lambda layer: (-layer.priority, layer.content.id),
)
# Validate edits against base anchors before mutation.
base_step_ids = self._collect_base_step_ids(base_steps)
for layer in merge_order:
edit_errors = validate_edits(layer.content.edits, base_step_ids)
if edit_errors:
raise ValueError(
f"Overlay '{layer.content.id}' has invalid edits:\n - "
+ "\n - ".join(edit_errors)
)
composed_steps, attribution = merge_steps(
base_steps,
[OverlayLayer(layer.content, layer.source) for layer in merge_order],
)
# Build composed data while preserving all non-step fields from base.
composed_data: dict[str, Any] = dict(base_definition.data)
composed_data["steps"] = composed_steps
composed_definition = WorkflowDefinition(composed_data, source_path=base_layer.path)
return composed_definition, attribution
def _collect_base_step_ids(self, steps: list[dict[str, Any]]) -> set[str]:
"""Collect all base step IDs reachable in the step tree."""
ids: set[str] = set()
for step in steps:
if not isinstance(step, dict):
continue
step_id = step.get("id")
if isinstance(step_id, str):
ids.add(step_id)
for key in ("then", "else", "steps", "default"):
nested = step.get(key)
if isinstance(nested, list):
ids.update(self._collect_base_step_ids(nested))
cases = step.get("cases")
if isinstance(cases, dict):
for case_steps in cases.values():
if isinstance(case_steps, list):
ids.update(self._collect_base_step_ids(case_steps))
return ids

View File

@@ -0,0 +1,234 @@
"""Workflow overlay layer sources."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import yaml
from .schema import Overlay, _RESERVED_WORKFLOW_IDS, _SAFE_ID_PATTERN, validate_overlay_yaml
@dataclass
class Layer:
"""A single layer in the workflow overlay stack."""
content: Overlay
source: str
tier: str
priority: int
path: Path | None = None
class OverlayLoadError(ValueError):
"""Raised when an overlay file cannot be loaded or validated."""
def __init__(self, path: Path, errors: list[str]) -> None:
self.path = path
self.errors = errors
super().__init__(f"Invalid overlay {path}:\n - " + "\n - ".join(errors))
def _validate_workflow_id(workflow_id: str, context_path: Path) -> None:
"""Raise OverlayLoadError if workflow_id is not a safe path-segment identifier.
Mirrors the same check performed by WorkflowResolver so layer sources are
safe to call directly, without going through the resolver.
"""
if (
not isinstance(workflow_id, str)
or not _SAFE_ID_PATTERN.fullmatch(workflow_id)
or workflow_id in _RESERVED_WORKFLOW_IDS
):
raise OverlayLoadError(
context_path,
[f"Invalid workflow ID: {workflow_id!r}"],
)
def _ensure_contained_dir(path: Path, root: Path) -> None:
"""Raise OverlayLoadError if *path* is a symlink, a non-directory, or escapes *root*.
Mirrors the logic of ``_ensure_contained_dir`` in ``overlays/_commands.py``
but raises ``OverlayLoadError`` instead of ``typer.Exit`` so layer sources
can enforce the same invariants without a CLI dependency.
The caller is responsible for ensuring *root* itself is already validated
(e.g. via ``_resolve_project_overlay_root``).
"""
if path.is_symlink():
raise OverlayLoadError(path, ["Symlinked overlay directories are not allowed"])
if path.exists() and not path.is_dir():
raise OverlayLoadError(path, ["Overlay directory path is not a directory"])
try:
path.resolve().relative_to(root.resolve())
except ValueError:
raise OverlayLoadError(
path, ["Path traversal detected: directory escapes allowed root"]
) from None
def _resolve_workflows_root(project_root: Path) -> Path:
"""Return the workflow storage root after rejecting unsafe ancestors."""
project_root_resolved = project_root.resolve()
workflows_root = project_root / ".specify" / "workflows"
current = project_root
for part in (".specify", "workflows"):
current = current / part
if current.is_symlink():
raise OverlayLoadError(
current,
[f"Symlinked workflow directories are not allowed ({current})"],
)
if current.exists() and not current.is_dir():
raise OverlayLoadError(
current,
[f"Workflow directory path is not a directory ({current})"],
)
try:
workflows_root.resolve().relative_to(project_root_resolved)
except ValueError:
raise OverlayLoadError(
workflows_root,
["Workflow directory escapes the project root"],
) from None
return workflows_root
def _resolve_project_overlay_root(project_root: Path) -> Path:
"""Return the unresolved overlay root after rejecting unsafe ancestors."""
workflows_root = _resolve_workflows_root(project_root)
overlays_root = workflows_root / "overlays"
if overlays_root.is_symlink():
raise OverlayLoadError(
overlays_root,
[f"Symlinked overlay directories are not allowed ({overlays_root})"],
)
if overlays_root.exists() and not overlays_root.is_dir():
raise OverlayLoadError(
overlays_root,
[f"Overlay directory path is not a directory ({overlays_root})"],
)
return overlays_root
class ProjectOverlaySource:
"""Project-local overlays: ``.specify/workflows/overlays/<id>/*.yml``."""
tier = "project-overlay"
def __init__(self, project_root: Path) -> None:
self.project_root = project_root
self.overlays_dir = project_root / ".specify" / "workflows" / "overlays"
def collect(self, workflow_id: str, *, include_disabled: bool = False) -> list[Layer]:
"""Collect project-local overlays for the given workflow id.
Args:
workflow_id: Workflow identifier whose overlay directory to scan.
include_disabled: When True, return disabled overlays for
management/list views. Resolution paths keep the default False.
"""
self.overlays_dir = _resolve_project_overlay_root(self.project_root)
_validate_workflow_id(workflow_id, self.overlays_dir)
workflow_overlay_dir = self.overlays_dir / workflow_id
_ensure_contained_dir(workflow_overlay_dir, self.overlays_dir)
if not workflow_overlay_dir.is_dir():
return []
layers: list[Layer] = []
overlay_paths_by_id: dict[str, Path] = {}
try:
entries = sorted(workflow_overlay_dir.iterdir())
except OSError as exc:
raise OverlayLoadError(
workflow_overlay_dir, [f"Cannot enumerate overlays: {exc}"]
) from exc
for path in entries:
if not path.is_file() or path.suffix not in (".yml", ".yaml"):
continue
if path.is_symlink():
raise OverlayLoadError(path, ["Symlinked overlay files are not allowed"])
try:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except yaml.YAMLError as exc:
raise OverlayLoadError(path, [f"Invalid YAML: {exc}"]) from exc
except (OSError, UnicodeDecodeError) as exc:
raise OverlayLoadError(path, [f"Cannot load overlay: {exc}"]) from exc
if (
not include_disabled
and isinstance(data, dict)
and data.get("enabled", True) is False
):
continue
overlay, errors = validate_overlay_yaml(data)
if overlay is None or errors:
raise OverlayLoadError(path, errors)
if overlay.extends != workflow_id:
raise OverlayLoadError(
path,
[
f"Overlay extends {overlay.extends!r}, but is stored under "
f"workflow {workflow_id!r}."
],
)
first_path = overlay_paths_by_id.get(overlay.id)
if first_path is not None:
raise OverlayLoadError(
path,
[
f"Duplicate overlay id {overlay.id!r}; also declared in "
f"{first_path}."
],
)
overlay_paths_by_id[overlay.id] = path
layers.append(
Layer(
content=overlay,
source=f"project:{overlay.id}",
tier=self.tier,
priority=overlay.priority,
path=path,
)
)
return layers
class BaseWorkflowSource:
"""Base workflow layer: ``.specify/workflows/<id>/workflow.yml``."""
tier = "base"
def __init__(self, project_root: Path) -> None:
self.project_root = project_root
self.workflows_dir = project_root / ".specify" / "workflows"
def collect(self, workflow_id: str, *, include_disabled: bool = False) -> list[Layer]:
"""Return the base workflow as a single layer if it exists."""
self.workflows_dir = _resolve_workflows_root(self.project_root)
_validate_workflow_id(workflow_id, self.workflows_dir)
workflow_dir = self.workflows_dir / workflow_id
_ensure_contained_dir(workflow_dir, self.workflows_dir)
path = workflow_dir / "workflow.yml"
if path.is_symlink():
raise OverlayLoadError(path, ["Symlinked workflow files are not allowed"])
if not path.is_file():
return []
# The base layer is represented by an Overlay with empty edits.
overlay = Overlay(
id=workflow_id,
extends=workflow_id,
priority=0,
edits=[],
)
return [
Layer(
content=overlay,
source="base",
tier=self.tier,
priority=0,
path=path,
)
]

View File

@@ -0,0 +1,395 @@
"""Pure-function merge engine for workflow step lists."""
from __future__ import annotations
import copy
from dataclasses import dataclass
from typing import Any
from .schema import VALID_OPERATIONS, Overlay, OverlayEdit
@dataclass(frozen=True)
class ComposedStep:
"""Attribution tracking for a single composed step."""
step_id: str
source: str
@dataclass(frozen=True)
class OverlayLayer:
"""An overlay together with its layer source for attribution."""
overlay: Overlay
source: str
# Nested step keys that may contain a list of steps.
_NESTED_LIST_KEYS = ("then", "else", "steps", "default")
def find_step(
steps: list[dict[str, Any]], step_id: str
) -> tuple[list[dict[str, Any]], int] | None:
"""Recursively locate a step by ID and return its (parent_list, index).
Searches flat lists and nested lists inside ``then``, ``else``, ``steps``,
``default``, and ``cases.*``. Does *not* descend into ``fan-out`` template
steps because those are runtime-multiplied stamps, not uniquely-addressable
nodes.
"""
for i, step in enumerate(steps):
if not isinstance(step, dict):
continue
if step.get("id") == step_id:
return (steps, i)
for key in _NESTED_LIST_KEYS:
nested = step.get(key)
if isinstance(nested, list):
result = find_step(nested, step_id)
if result is not None:
return result
cases = step.get("cases")
if isinstance(cases, dict):
for case_steps in cases.values():
if isinstance(case_steps, list):
result = find_step(case_steps, step_id)
if result is not None:
return result
return None
def _all_base_step_ids(steps: list[dict[str, Any]]) -> set[str]:
"""Collect all step IDs reachable in a step tree (excluding fan-out templates)."""
ids: set[str] = set()
for step in steps:
if not isinstance(step, dict):
continue
step_id = step.get("id")
if isinstance(step_id, str):
ids.add(step_id)
for key in _NESTED_LIST_KEYS:
nested = step.get(key)
if isinstance(nested, list):
ids.update(_all_base_step_ids(nested))
cases = step.get("cases")
if isinstance(cases, dict):
for case_steps in cases.values():
if isinstance(case_steps, list):
ids.update(_all_base_step_ids(case_steps))
return ids
def _descendant_ids(step: dict[str, Any]) -> set[str]:
"""Return all step IDs nested inside *step* (not including *step* itself)."""
ids: set[str] = set()
for key in _NESTED_LIST_KEYS:
nested = step.get(key)
if isinstance(nested, list):
ids.update(_all_base_step_ids(nested))
cases = step.get("cases")
if isinstance(cases, dict):
for case_steps in cases.values():
if isinstance(case_steps, list):
ids.update(_all_base_step_ids(case_steps))
return ids
def _check_anchor_conflicts(
anchor_operations: dict[str, str],
base_steps: list[dict[str, Any]],
) -> list[str]:
"""Return error messages for anchor pairs where one is an ancestor of the other.
Only flags conflicts where the ancestor's winning edit is ``replace`` or
``remove`` — operations that destroy the subtree and make any descendant
anchor unresolvable. Pure insert operations on an ancestor leave it intact,
so its descendants remain reachable regardless of processing order.
Callers should raise on any returned errors before mutating the step tree.
"""
errors: list[str] = []
for anchor, operation in sorted(anchor_operations.items()):
if operation in ("insert_after", "insert_before"):
# Inserts leave the ancestor step intact; descendants are unaffected.
continue
location = find_step(base_steps, anchor)
if location is None:
continue # missing anchors are reported by validate_edits
parent_list, idx = location
step = parent_list[idx]
conflicting = set(anchor_operations.keys()) & _descendant_ids(step)
for child_anchor in sorted(conflicting):
errors.append(
f"Anchor conflict: '{anchor}' is an ancestor of '{child_anchor}'. "
"Targeting both anchors in the same overlay set produces "
"order-dependent results; restructure edits to avoid nesting."
)
return errors
def _init_sources_recursively(
steps: list[dict[str, Any]], sources: dict[str, str]
) -> None:
"""Initialize attribution sources for all base steps, recursively."""
for step in steps:
if not isinstance(step, dict):
continue
step_id = step.get("id")
if isinstance(step_id, str):
sources[step_id] = "base"
for key in _NESTED_LIST_KEYS:
nested = step.get(key)
if isinstance(nested, list):
_init_sources_recursively(nested, sources)
cases = step.get("cases")
if isinstance(cases, dict):
for case_steps in cases.values():
if isinstance(case_steps, list):
_init_sources_recursively(case_steps, sources)
def _record_sources_recursively(
step: dict[str, Any],
source: str,
sources: dict[str, str],
) -> None:
"""Record *source* for a step and all its nested child steps.
Traverses ``then``, ``else``, ``steps``, ``default``, and ``cases.*``
so that ``workflow resolve`` attributes every step inside a composite
insert or replacement to the correct overlay layer.
"""
step_id = step.get("id")
if isinstance(step_id, str):
sources[step_id] = source
for key in _NESTED_LIST_KEYS:
nested = step.get(key)
if isinstance(nested, list):
for child in nested:
if isinstance(child, dict):
_record_sources_recursively(child, source, sources)
cases = step.get("cases")
if isinstance(cases, dict):
for case_steps in cases.values():
if isinstance(case_steps, list):
for child in case_steps:
if isinstance(child, dict):
_record_sources_recursively(child, source, sources)
def _remove_sources_recursively(
step: dict[str, Any],
sources: dict[str, str],
) -> None:
"""Remove source entries for a step and all its nested child steps.
Traverses the same nesting keys as ``_record_sources_recursively``.
"""
step_id = step.get("id")
if isinstance(step_id, str) and sources.get(step_id) == "base":
sources.pop(step_id, None)
for key in _NESTED_LIST_KEYS:
nested = step.get(key)
if isinstance(nested, list):
for child in nested:
if isinstance(child, dict):
_remove_sources_recursively(child, sources)
cases = step.get("cases")
if isinstance(cases, dict):
for case_steps in cases.values():
if isinstance(case_steps, list):
for child in case_steps:
if isinstance(child, dict):
_remove_sources_recursively(child, sources)
def _build_attribution(
steps: list[dict[str, Any]],
sources: dict[str, str],
) -> list[ComposedStep]:
"""Build an ordered attribution list from the composed step tree."""
result: list[ComposedStep] = []
for step in steps:
if not isinstance(step, dict):
continue
step_id = step.get("id")
if isinstance(step_id, str):
result.append(ComposedStep(step_id, sources.get(step_id, "unknown")))
for key in _NESTED_LIST_KEYS:
nested = step.get(key)
if isinstance(nested, list):
result.extend(_build_attribution(nested, sources))
cases = step.get("cases")
if isinstance(cases, dict):
for case_steps in cases.values():
if isinstance(case_steps, list):
result.extend(_build_attribution(case_steps, sources))
return result
def _traverse_and_apply(
steps: list[dict[str, Any]],
edits_by_anchor: dict[str, list[tuple[OverlayLayer, OverlayEdit]]],
sources: dict[str, str],
) -> list[dict[str, Any]]:
"""Walk the original step tree and apply overlay edits as each step is encountered.
Edits are always resolved against the *original* structure — this function
traverses the unmodified list passed in, so a replacement step's new ID can
never be mistaken for a base anchor. Nested lists (``then``, ``else``, etc.)
are recursed into only for steps that survive the edit (not for replaced
steps).
*edits* are expected to be in merge order (lowest priority first, highest
priority last); the winning edit for each anchor is ``edits[-1]``.
"""
result: list[dict[str, Any]] = []
for step in steps:
if not isinstance(step, dict):
result.append(step)
continue
step_id = step.get("id")
edits = edits_by_anchor.get(step_id, []) if isinstance(step_id, str) else []
winning_edit = edits[-1][1] if edits else None
if winning_edit is not None and winning_edit.operation == "remove":
# Winning edit removes this step; ignore all other edits on this anchor.
# Do NOT call _remove_sources_recursively here: _build_attribution only
# traverses the result list, so stale sources entries for removed steps
# are never read. Calling it would incorrectly pop the attribution of a
# *surviving* step that reuses the same ID (e.g. a replacement step
# introduced by a higher-priority overlay targeting a different anchor).
continue
# Insert before (in merge order).
for layer, edit in edits:
if edit.operation == "insert_before":
new_step = copy.deepcopy(edit.step)
_record_sources_recursively(new_step, layer.source, sources)
result.append(new_step)
if winning_edit is not None and winning_edit.operation == "replace":
winning_layer = edits[-1][0]
new_step = copy.deepcopy(winning_edit.step)
_remove_sources_recursively(step, sources)
_record_sources_recursively(new_step, winning_layer.source, sources)
result.append(new_step)
else:
# No replacement: keep this step and recurse into its nested lists.
for key in _NESTED_LIST_KEYS:
nested = step.get(key)
if isinstance(nested, list):
step[key] = _traverse_and_apply(nested, edits_by_anchor, sources)
cases = step.get("cases")
if isinstance(cases, dict):
for case_key, case_steps in cases.items():
if isinstance(case_steps, list):
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":
new_step = copy.deepcopy(edit.step)
_record_sources_recursively(new_step, layer.source, sources)
result.append(new_step)
return result
def merge_steps(
base_steps: list[dict[str, Any]],
overlays: list[OverlayLayer],
) -> tuple[list[dict[str, Any]], list[ComposedStep]]:
"""Apply overlays to base steps in merge order and return composed steps.
*overlays* is expected to be sorted by merge order (lowest priority first,
highest priority last). The returned step list is a deep copy of the base;
base_steps is never mutated.
Higher-wins semantics are enforced for edits that target the same base
anchor: the highest-priority edit (last in *overlays*) decides the fate of
the anchor. A lower-priority ``remove`` cannot prevent a higher-priority
``replace`` or ``insert_*`` on the same anchor.
"""
steps = copy.deepcopy(base_steps)
sources: dict[str, str] = {}
_init_sources_recursively(steps, sources)
# Group edits by anchor, preserving merge order.
edits_by_anchor: dict[str, list[tuple[OverlayLayer, OverlayEdit]]] = {}
for layer in overlays:
for edit in layer.overlay.edits:
edits_by_anchor.setdefault(edit.anchor, []).append((layer, edit))
# Raise early for non-remove edits that target anchors not present in the base.
# Overlays always apply to the original tree; they cannot target steps introduced
# by other overlays.
base_ids = _all_base_step_ids(base_steps)
for anchor, anchor_edits in edits_by_anchor.items():
winning_op = anchor_edits[-1][1].operation
if winning_op != "remove" and anchor not in base_ids:
raise ValueError(f"Anchor '{anchor}' not found in workflow steps.")
# Reject edits that target anchors with a parent/descendant relationship when
# the ancestor edit replaces or removes its subtree — those produce
# order-dependent results. Pure insert edits on an ancestor are safe because
# the ancestor step (and its descendants) remain intact.
anchor_winning_ops = {
anchor: anchor_edits[-1][1].operation
for anchor, anchor_edits in edits_by_anchor.items()
}
anchor_conflicts = _check_anchor_conflicts(anchor_winning_ops, base_steps)
if anchor_conflicts:
raise ValueError(
"Overlay anchor conflict(s) detected:\n - " + "\n - ".join(anchor_conflicts)
)
# Apply all overlay edits via a single-pass traversal of the original tree.
# Each edit is resolved against the original step structure, so a replacement
# step's new ID can never be mistaken for a base anchor in a later edit group.
result = _traverse_and_apply(steps, edits_by_anchor, sources)
attribution = _build_attribution(result, sources)
return result, attribution
def validate_edits(
edits: list[OverlayEdit],
base_step_ids: set[str],
) -> list[str]:
"""Validate overlay edits against a set of known base step IDs.
Returns a list of human-readable error messages. Does not raise.
"""
errors: list[str] = []
for idx, edit in enumerate(edits):
if edit.operation not in VALID_OPERATIONS:
errors.append(f"Edit {idx}: invalid operation {edit.operation!r}.")
continue
if edit.anchor not in base_step_ids:
errors.append(
f"Edit {idx}: anchor '{edit.anchor}' does not match any base step id."
)
if edit.operation == "remove":
if edit.step is not None:
errors.append(f"Edit {idx}: 'remove' must not include a step.")
continue
if not isinstance(edit.step, dict):
errors.append(f"Edit {idx}: '{edit.operation}' requires a step mapping.")
continue
step_id = edit.step.get("id")
if not isinstance(step_id, str) or not step_id:
errors.append(f"Edit {idx}: step is missing required 'id'.")
continue
if ":" in step_id:
errors.append(
f"Edit {idx}: step id {step_id!r} contains ':' which is reserved "
"for engine-generated nested IDs."
)
return errors

View File

@@ -0,0 +1,176 @@
"""Workflow overlay schema — dataclasses and validation for overlay manifests."""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Any, Literal
from ...extensions import normalize_priority
# Safe single-segment identifiers: no path separators, no traversal, no dots.
_SAFE_ID_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
_RESERVED_OVERLAY_WORKFLOW_IDS: frozenset[str] = frozenset({"overlays"})
_RESERVED_WORKFLOW_IDS: frozenset[str] = frozenset({"overlays", "runs", "steps"})
VALID_OPERATIONS = frozenset({"insert_after", "insert_before", "replace", "remove"})
# Map shorthand keys to operation names.
_SHORTHAND_OPERATION_KEYS: frozenset[str] = VALID_OPERATIONS
@dataclass(frozen=True)
class OverlayEdit:
"""A single edit operation on a workflow step list."""
operation: Literal["insert_after", "insert_before", "replace", "remove"]
anchor: str
step: dict[str, Any] | None = None
@dataclass
class Overlay:
"""A declared overlay (one YAML file)."""
id: str
extends: str
edits: list[OverlayEdit]
priority: int = 10
enabled: bool = True
def _validate_safe_id(
value: str,
field_name: str,
allow_reserved: bool = False,
reserved_ids: frozenset[str] = _RESERVED_OVERLAY_WORKFLOW_IDS,
) -> str | None:
"""Return an error message if *value* is not a safe path segment ID."""
if not isinstance(value, str) or not value:
return f"Overlay '{field_name}' is required and must be a non-empty string."
if not _SAFE_ID_PATTERN.fullmatch(value):
return (
f"Overlay '{field_name}' {value!r} contains invalid characters; "
"only lowercase letters, digits, and hyphens are allowed."
)
if not allow_reserved and value in reserved_ids:
return f"Overlay '{field_name}' {value!r} is reserved."
return None
def _parse_edit(edit_raw: dict[str, Any], idx: int) -> tuple[OverlayEdit | None, str | None]:
"""Parse a single edit dict into an OverlayEdit or an error string."""
shorthand_keys = [key for key in _SHORTHAND_OPERATION_KEYS if key in edit_raw]
has_operation = "operation" in edit_raw
operation: str | None = None
anchor: Any = None
if shorthand_keys and has_operation:
return None, (
f"Edit at index {idx} mixes shorthand operation key "
f"({shorthand_keys[0]!r}) with explicit 'operation' field."
)
if len(shorthand_keys) > 1:
return None, (
f"Edit at index {idx} has multiple operation keys: "
f"{', '.join(repr(k) for k in shorthand_keys)}."
)
if shorthand_keys:
operation = shorthand_keys[0]
anchor = edit_raw[operation]
elif has_operation:
operation = edit_raw.get("operation")
anchor = edit_raw.get("anchor")
else:
return None, f"Edit at index {idx} has no operation; expected one of {sorted(VALID_OPERATIONS)}."
if operation not in VALID_OPERATIONS:
return None, f"Edit at index {idx} has invalid operation {operation!r}."
if not isinstance(anchor, str) or not anchor:
return None, f"Edit at index {idx} has invalid 'anchor'."
step = edit_raw.get("step")
if operation == "remove":
if step is not None:
return None, f"Edit at index {idx} ('remove') must not include 'step'."
return OverlayEdit(operation=operation, anchor=anchor), None
if not isinstance(step, dict):
return None, f"Edit at index {idx} ('{operation}') requires 'step' mapping."
step_id = step.get("id")
if not isinstance(step_id, str) or not step_id:
return None, f"Edit at index {idx} step is missing required 'id'."
if ":" in step_id:
return None, (
f"Edit at index {idx} step id {step_id!r} contains ':' "
"which is reserved for engine-generated nested IDs."
)
return OverlayEdit(operation=operation, anchor=anchor, step=step), None
def validate_overlay_yaml(data: dict[str, Any]) -> tuple[Overlay | None, list[str]]:
"""Validate an overlay manifest dict and return (Overlay, errors).
Errors are returned as a list of strings; validation never raises.
"""
errors: list[str] = []
if not isinstance(data, dict):
return None, ["Overlay manifest must be a mapping."]
overlay_id = data.get("id")
if err := _validate_safe_id(overlay_id, "id"):
errors.append(err)
overlay_id = ""
extends = data.get("extends")
if err := _validate_safe_id(
extends,
"extends",
reserved_ids=_RESERVED_WORKFLOW_IDS,
):
errors.append(err)
extends = ""
priority = normalize_priority(data.get("priority", 10))
edits_raw = data.get("edits")
edits: list[OverlayEdit] = []
if not isinstance(edits_raw, list):
errors.append("Overlay 'edits' is required and must be a list.")
elif not edits_raw:
errors.append("Overlay 'edits' must be a non-empty list.")
else:
for idx, edit_raw in enumerate(edits_raw):
if not isinstance(edit_raw, dict):
errors.append(f"Edit at index {idx} must be a mapping.")
continue
edit, err = _parse_edit(edit_raw, idx)
if err:
errors.append(err)
continue
if edit is not None:
edits.append(edit)
enabled = data.get("enabled", True)
if not isinstance(enabled, bool):
errors.append("Overlay 'enabled' must be a boolean.")
enabled = bool(enabled)
if errors:
return None, errors
return (
Overlay(
id=overlay_id,
extends=extends,
priority=priority,
edits=edits,
enabled=enabled,
),
[],
)

View File

@@ -30,6 +30,21 @@ class CommandStep(StepBase):
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
command = config.get("command", "")
# validate() rejects a non-string 'command', but the engine does not
# auto-validate before execute(); an unvalidated run would pass the value
# to build_command_invocation() (via _try_dispatch) and crash there with a
# raw AttributeError (command_name.startswith(...) on a list/int/None).
# Fail the step with the same contract error instead, mirroring the
# 'input'/'options' guards below.
if not isinstance(command, str):
return StepResult(
status=StepStatus.FAILED,
error=(
f"Command step {config.get('id', '?')!r}: 'command' must be a "
f"string, got {type(command).__name__}."
),
)
input_data = config.get("input", {})
# validate() rejects a non-mapping input, but the engine does not
# auto-validate before execute(); a workflow that skipped validation can
@@ -51,16 +66,52 @@ class CommandStep(StepBase):
for key, value in input_data.items():
resolved_input[key] = evaluate_expression(value, context)
# Resolve integration (step → workflow default → project default)
integration = config.get("integration") or context.default_integration
# Resolve integration (step → workflow default → project default).
# Fall back to the workflow default ONLY for a genuinely-unset value
# (missing / YAML-null / empty string). A ``config.get(...) or ...``
# would also swallow a falsey *non-string* ([], {}, 0, False), coercing
# it to the default before the guard below runs — so on an unvalidated
# execute() such a step would silently dispatch with the configured
# default instead of failing. Fall through instead, so every non-string
# reaches the type guard.
integration = config.get("integration")
if integration is None or integration == "":
integration = context.default_integration
if integration and isinstance(integration, str) and "{{" in integration:
integration = evaluate_expression(integration, context)
# Resolve model
model = config.get("model") or context.default_model
# Resolve model (same fallback rationale as 'integration' above).
model = config.get("model")
if model is None or model == "":
model = context.default_model
if model and isinstance(model, str) and "{{" in model:
model = evaluate_expression(model, context)
# A non-string integration/model — a literal list/dict/number that
# skipped validation, an unvalidated workflow-level default, or an
# expression that resolved to one — crashes downstream: get_integration()
# uses the value as a dict key (raw TypeError on an unhashable list/dict,
# even on a *validated* run) and build_exec_args() feeds model into the
# CLI argv. Fail the step with the contract error rather than taking down
# the whole run, mirroring the 'input'/'options' guards above. ``None``
# stays valid — it means "unset" and falls back to dispatch-not-possible.
if integration is not None and not isinstance(integration, str):
return StepResult(
status=StepStatus.FAILED,
error=(
f"Command step {config.get('id', '?')!r}: 'integration' must "
f"be a string, got {type(integration).__name__}."
),
)
if model is not None and not isinstance(model, str):
return StepResult(
status=StepStatus.FAILED,
error=(
f"Command step {config.get('id', '?')!r}: 'model' must be a "
f"string, got {type(model).__name__}."
),
)
# Merge options (workflow defaults ← step overrides)
options = dict(context.default_options)
step_options = config.get("options", {})
@@ -138,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:
@@ -179,6 +234,17 @@ class CommandStep(StepBase):
errors.append(
f"Command step {config.get('id', '?')!r} is missing 'command' field."
)
elif not isinstance(config["command"], str):
# execute() passes 'command' straight to the integration's
# build_command_invocation(), which does command_name.startswith(...);
# a non-string (null, list, int) crashes there with a raw
# AttributeError once dispatch is attempted. Reject it at validation,
# mirroring the prompt-step 'prompt' and shell-step 'run' type checks.
# An expression like "{{ ... }}" is still a str, so it stays valid.
errors.append(
f"Command step {config.get('id', '?')!r}: 'command' must be a "
f"string, got {type(config['command']).__name__}."
)
# execute() iterates input.items() and options.update(step_options); a
# non-mapping here would raise at run time. Validate the shape like the
# sibling steps (switch 'cases', fan-out 'step') so it is reported, not
@@ -191,4 +257,23 @@ class CommandStep(StepBase):
errors.append(
f"Command step {config.get('id', '?')!r}: 'options' must be a mapping."
)
# execute() passes 'integration' to get_integration(), which uses it as a
# dict key — a non-string (list/dict) raises a raw TypeError (unhashable),
# even on a validated run — and feeds 'model' into the CLI argv. Reject a
# literal non-string here, mirroring the sibling type checks. ``None``
# (an explicit ``integration:``/``model:`` YAML null) means "inherit the
# workflow default" and stays valid; an expression like "{{ ... }}" is
# still a str, so it stays valid too.
integration = config.get("integration")
if integration is not None and not isinstance(integration, str):
errors.append(
f"Command step {config.get('id', '?')!r}: 'integration' must be a "
f"string, got {type(integration).__name__}."
)
model = config.get("model")
if model is not None and not isinstance(model, str):
errors.append(
f"Command step {config.get('id', '?')!r}: 'model' must be a "
f"string, got {type(model).__name__}."
)
return errors

View File

@@ -42,6 +42,28 @@ class FanInStep(StepBase):
output={"results": []},
)
# A non-string entry can never match a real step id. An unhashable one
# (a list/dict from a YAML indentation slip like ``wait_for: [[a, b]]``)
# crashes the whole run at ``context.steps.get(step_id, ...)`` below with
# a raw TypeError; a hashable-but-non-string one (``wait_for: [123]``)
# silently joins an empty ``{}`` and still reports COMPLETED — the exact
# "silent empty result + COMPLETED" wiring bug the whole-list guard above
# and the engine's fan-in validation (engine.py) both reject. The engine
# does not auto-validate step config, so fail this step loudly on an
# unvalidated run too, using the engine's phrasing.
bad_entries = [w for w in wait_for if not isinstance(w, str)]
if bad_entries:
first = bad_entries[0]
return StepResult(
status=StepStatus.FAILED,
error=(
f"Fan-in step {config.get('id', '?')!r}: 'wait_for' entries "
f"must be step-id strings, got {type(first).__name__} "
f"({first!r})."
),
output={"results": []},
)
# Collect results from referenced steps
results = []
for step_id in wait_for:

View File

@@ -12,9 +12,10 @@ class FanOutStep(StepBase):
"""Dispatch a step template for each item in a collection.
The engine executes the nested ``step:`` template once per item,
setting ``context.item`` for each iteration. Execution is
currently sequential; ``max_concurrency`` is accepted but not
enforced.
setting ``context.item`` for each iteration. ``max_concurrency``
controls parallelism: ``<= 1`` (the default) runs items
sequentially, while ``> 1`` runs up to that many items concurrently
on a bounded thread pool (see ``WorkflowEngine._run_fan_out``).
"""
type_key = "fan-out"
@@ -25,6 +26,33 @@ class FanOutStep(StepBase):
max_concurrency = config.get("max_concurrency", 1)
step_template = config.get("step", {})
# The engine does not auto-validate step config (see
# ``WorkflowEngine.load_workflow``). On a COMPLETED fan-out it reads the
# ``step_template`` back out and, when it is truthy, calls
# ``template.get("id", ...)`` in ``_run_fan_out``. A truthy non-mapping
# ``step`` (a scalar or list authoring mistake) would crash the whole
# run with AttributeError there — the engine invokes ``execute`` and
# ``_run_fan_out`` with no surrounding try/except. ``validate`` already
# rejects a non-mapping ``step``; fail this step loudly on an
# unvalidated run instead, mirroring the ``items`` guard below. An empty
# or absent ``step`` defaults to ``{}`` (falsy) and the engine's
# ``if template and items`` skips fan-out, so it stays valid here.
if not isinstance(step_template, dict):
return StepResult(
status=StepStatus.FAILED,
error=(
f"Fan-out step {config.get('id', '?')!r}: 'step' must be a "
f"mapping (nested step template), got "
f"{type(step_template).__name__}."
),
output={
"items": [],
"max_concurrency": max_concurrency,
"step_template": {},
"item_count": 0,
},
)
if not isinstance(items, list):
# A non-list here is a wiring error (the expression did not
# resolve to a collection); silently fanning out over zero
@@ -66,8 +94,13 @@ class FanOutStep(StepBase):
f"Fan-out step {config.get('id', '?')!r} is missing "
f"'step' field (nested step template)."
)
step = config.get("step")
if step is not None and not isinstance(step, dict):
elif not isinstance(config["step"], dict):
# A present-but-non-mapping ``step`` (including an explicit
# ``step: null``) is an authoring mistake. ``config.get("step", {})``
# in ``execute`` only substitutes the ``{}`` default for an *absent*
# key, so an explicit ``None`` reaches the runtime guard and FAILS
# the step. Reject it here too so a workflow cannot pass validation
# and then fail during execution.
errors.append(
f"Fan-out step {config.get('id', '?')!r}: 'step' must be a mapping."
)

View File

@@ -43,6 +43,35 @@ class GateStep(StepBase):
options = config.get("options", ["approve", "reject"])
on_reject = config.get("on_reject", "abort")
# ``validate`` rejects a non-list (or empty) ``options``, and requires
# every option to be a string, but the engine does not auto-validate
# before ``execute``. An unvalidated run with a scalar/dict/None
# ``options`` would otherwise reach ``_prompt`` and crash the whole run
# with a raw ``TypeError`` (``enumerate``/``len`` on a non-iterable) or
# ``KeyError`` (indexing a dict); a non-string option would crash at the
# ``choice.lower()`` reject check with ``AttributeError``. Fail this step
# loudly instead — mirroring the switch 'cases' and command 'input'
# guards. Checked before the non-TTY short-circuit so the error surfaces
# in CI too, rather than PAUSING and crashing later on interactive resume.
if (
not isinstance(options, list)
or not options
or not all(isinstance(o, str) for o in options)
):
return StepResult(
status=StepStatus.FAILED,
error=(
f"Gate step {config.get('id', '?')!r}: 'options' must be a "
f"non-empty list of strings, got {type(options).__name__}."
),
output={
"message": message,
"options": options,
"on_reject": on_reject,
"choice": None,
},
)
show_file = config.get("show_file")
if isinstance(show_file, str) and "{{" in show_file:
show_file = evaluate_expression(show_file, context)
@@ -139,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

@@ -42,16 +42,52 @@ class PromptStep(StepBase):
if not isinstance(prompt, str):
prompt = str(prompt)
# Resolve integration (step → workflow default)
integration = config.get("integration") or context.default_integration
# Resolve integration (step → workflow default).
# Fall back to the workflow default ONLY for a genuinely-unset value
# (missing / YAML-null / empty string). A ``config.get(...) or ...``
# would also swallow a falsey *non-string* ([], {}, 0, False), coercing
# it to the default before the guard below runs — so on an unvalidated
# execute() such a step would silently dispatch with the configured
# default instead of failing. Fall through instead, so every non-string
# reaches the type guard.
integration = config.get("integration")
if integration is None or integration == "":
integration = context.default_integration
if integration and isinstance(integration, str) and "{{" in integration:
integration = evaluate_expression(integration, context)
# Resolve model
model = config.get("model") or context.default_model
# Resolve model (same fallback rationale as 'integration' above).
model = config.get("model")
if model is None or model == "":
model = context.default_model
if model and isinstance(model, str) and "{{" in model:
model = evaluate_expression(model, context)
# A non-string integration/model — a literal list/dict/number that
# skipped validation, an unvalidated workflow-level default, or an
# expression that resolved to one — crashes downstream: get_integration()
# uses the value as a dict key (raw TypeError on an unhashable list/dict,
# even on a *validated* run) and build_exec_args() feeds model into the
# CLI argv. Fail the step with the contract error rather than taking down
# the whole run. ``None`` stays valid — it means "unset" and falls back
# to dispatch-not-possible.
if integration is not None and not isinstance(integration, str):
return StepResult(
status=StepStatus.FAILED,
error=(
f"Prompt step {config.get('id', '?')!r}: 'integration' must "
f"be a string, got {type(integration).__name__}."
),
)
if model is not None and not isinstance(model, str):
return StepResult(
status=StepStatus.FAILED,
error=(
f"Prompt step {config.get('id', '?')!r}: 'model' must be a "
f"string, got {type(model).__name__}."
),
)
# Attempt CLI dispatch
dispatch_result = self._try_dispatch(
prompt, integration, model, context
@@ -102,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:
@@ -160,4 +199,35 @@ class PromptStep(StepBase):
errors.append(
f"Prompt step {config.get('id', '?')!r} is missing 'prompt' field."
)
elif not isinstance(config["prompt"], str):
# execute() str()-coerces prompt and dispatches it to the
# integration CLI, so a null or list 'prompt' would send the Python
# repr ('None', "['review', 'this']") to the model as instructions —
# silently wrong, with no error. Reject non-strings at validation,
# mirroring the shell-step 'run' and command-step input/options type
# checks. An expression like "{{ ... }}" is still a str, so it stays
# valid.
errors.append(
f"Prompt step {config.get('id', '?')!r}: 'prompt' must be a "
f"string, got {type(config['prompt']).__name__}."
)
# execute() passes 'integration' to get_integration(), which uses it as a
# dict key — a non-string (list/dict) raises a raw TypeError (unhashable),
# even on a validated run — and feeds 'model' into the CLI argv. Reject a
# literal non-string here, mirroring the 'prompt' check above. ``None``
# (an explicit ``integration:``/``model:`` YAML null) means "inherit the
# workflow default" and stays valid; an expression like "{{ ... }}" is
# still a str, so it stays valid too.
integration = config.get("integration")
if integration is not None and not isinstance(integration, str):
errors.append(
f"Prompt step {config.get('id', '?')!r}: 'integration' must be a "
f"string, got {type(integration).__name__}."
)
model = config.get("model")
if model is not None and not isinstance(model, str):
errors.append(
f"Prompt step {config.get('id', '?')!r}: 'model' must be a "
f"string, got {type(model).__name__}."
)
return errors

View File

@@ -11,6 +11,7 @@ handoffs:
scripts:
sh: scripts/bash/setup-plan.sh --json
ps: scripts/powershell/setup-plan.ps1 -Json
py: scripts/python/setup_plan.py --json
---
## User Input

View File

@@ -12,6 +12,7 @@ handoffs:
scripts:
sh: scripts/bash/setup-tasks.sh --json
ps: scripts/powershell/setup-tasks.ps1 -Json
py: scripts/python/setup_tasks.py --json
---
## User Input

View File

@@ -5,6 +5,8 @@ built-in, install policy gating, payload parsing.
"""
from __future__ import annotations
import json
import tomllib
from pathlib import Path
import yaml
@@ -37,9 +39,46 @@ def test_builtin_default_stack_when_no_config(tmp_path: Path):
assert ids == ["default", "community"]
assert sources[0].install_policy is InstallPolicy.INSTALL_ALLOWED
assert sources[1].install_policy is InstallPolicy.DISCOVERY_ONLY
assert sources[1].priority == 20
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 = {
@@ -95,6 +134,29 @@ def test_builtin_default_stack_constant_shape():
assert ids == {"default", "community"}
def test_repository_community_bundle_catalog_matches_contract():
catalog_path = Path(__file__).parents[2] / "bundles" / "catalog.community.json"
payload = json.loads(catalog_path.read_text(encoding="utf-8"))
assert payload["schema_version"] == "1.0"
assert payload["catalog_url"].endswith("/bundles/catalog.community.json")
entries = load_catalog_payload(payload)
assert all(entry.verified is False for entry in entries.values())
def test_wheel_packages_community_bundle_catalog():
repo_root = Path(__file__).parents[2]
with (repo_root / "pyproject.toml").open("rb") as pyproject_file:
pyproject = tomllib.load(pyproject_file)
force_include = pyproject["tool"]["hatch"]["build"]["targets"]["wheel"][
"force-include"
]
assert force_include["bundles/catalog.community.json"] == (
"specify_cli/core_pack/bundles/catalog.community.json"
)
def test_catalog_entry_rejects_string_tags():
from specify_cli.bundler.models.catalog import CatalogEntry

View File

@@ -124,3 +124,13 @@ 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)

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

@@ -11,7 +11,6 @@ import pytest
from tests.conftest import requires_bash
from tests.extensions.test_extension_agent_context import (
BASH,
POWERSHELL,
_bash_posix_path,
_run_bash_agent_context_script,

View File

@@ -38,6 +38,27 @@ def test_resolve_prefers_highest_precedence_source():
assert resolved.install_allowed is False
def test_explicit_catalog_shadows_builtin_community_at_default_priority():
sources = [
_source("community", 20, "discovery-only"),
_source("explicit", 10, "install-allowed"),
]
payloads = {
"community": catalog_payload({
"shared": catalog_entry_dict("shared", version="1.0.0"),
}),
"explicit": catalog_payload({
"shared": catalog_entry_dict("shared", version="2.0.0"),
}),
}
resolved = _stack(sources, payloads).resolve("shared")
assert resolved.source.id == "explicit"
assert resolved.entry.version == "2.0.0"
assert resolved.install_allowed is True
def test_resolve_unknown_bundle_errors():
stack = _stack(
[_source("only", 1, "install-allowed")],

View File

@@ -31,6 +31,25 @@ def test_builtin_catalog_resolves_offline():
assert stack.search() == []
def test_builtin_community_catalog_resolves_from_packaged_snapshot_offline():
fetcher = make_catalog_fetcher(allow_network=False)
source = _src(
"community",
"builtin://community",
priority=20,
policy="discovery-only",
)
payload = fetcher(source)
stack = CatalogStack([source], fetcher)
assert isinstance(payload.get("bundles"), dict)
assert all(
result.source.id == "community" and not result.install_allowed
for result in stack.search()
)
assert stack.sources[0].install_allowed is False
def test_file_catalog_resolves_offline(tmp_path: Path):
catalog_path = tmp_path / "catalog.json"
write_catalog_file(catalog_path, {"demo": catalog_entry_dict("demo")})

View File

@@ -1,6 +1,8 @@
"""Tests for IntegrationOption, IntegrationBase, MarkdownIntegration, and primitives."""
import shlex
import sys
from types import SimpleNamespace
import pytest
@@ -216,6 +218,42 @@ class TestBuildCommandInvocation:
i = get_integration("codex")
assert i.build_command_invocation("speckit.git.commit", "fix typo") == "/speckit-git-commit fix typo"
def test_forge_core_command_hyphenated(self):
"""Forge 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("forge")
assert i.build_command_invocation("speckit.plan") == "/speckit-plan"
assert i.build_command_invocation("plan") == "/speckit-plan"
def test_forge_extension_command_hyphenated(self):
from specify_cli.integrations import get_integration
i = get_integration("forge")
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"
)
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."""
@@ -477,19 +515,41 @@ class TestProcessTemplatePyScriptType:
assert ".specify/scripts/bash/check-prerequisites.sh --json" in result
assert "python" not in result
def test_body_scripts_example_does_not_override_frontmatter(self):
content = (
"---\n"
"scripts:\n"
" sh: scripts/bash/real.sh --json\n"
"---\n"
"Run {SCRIPT} now.\n"
"```yaml\n"
"scripts:\n"
" sh: examples/not-the-command.sh\n"
"```\n"
)
result = IntegrationBase.process_template(content, "agent", "sh")
assert ".specify/scripts/bash/real.sh --json" in result
assert "examples/not-the-command.sh" in result
def test_py_quotes_interpreter_with_spaces(self, monkeypatch):
# An interpreter path containing whitespace (e.g. Windows
# ``Program Files``) must be quoted so it isn't split into args.
interpreter = r"C:\Program Files\Python\python.exe"
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which", lambda name: None
)
monkeypatch.setattr(
"specify_cli.integrations.base.sys.executable",
r"C:\Program Files\Python\python.exe",
interpreter,
)
monkeypatch.setattr(
"specify_cli.integrations.base.os", SimpleNamespace(name="posix")
)
result = IntegrationBase.process_template(self.CONTENT, "agent", "py")
assert (
'"C:\\Program Files\\Python\\python.exe" '
f"{shlex.quote(interpreter)} "
".specify/scripts/python/check-prerequisites.py --json"
) in result
@@ -511,6 +571,39 @@ class TestProcessTemplatePyScriptType:
)
assert ".venv/bin/python .specify/scripts/python/check-prerequisites.py" in result
def test_setup_py_falls_back_to_platform_shell(
self, monkeypatch, tmp_path
):
template = tmp_path / "fallback.md"
template.write_text(
"---\n"
"scripts:\n"
" sh: scripts/bash/check-prerequisites.sh --json\n"
" ps: scripts/powershell/check-prerequisites.ps1 -Json\n"
"---\n"
"Run {SCRIPT} now.\n",
encoding="utf-8",
)
integration = StubIntegration()
monkeypatch.setattr(
integration, "list_command_templates", lambda: [template]
)
created = integration.setup(
tmp_path,
IntegrationManifest("stub", tmp_path),
script_type="py",
)
rendered = created[0].read_text(encoding="utf-8")
expected = (
".specify/scripts/powershell/check-prerequisites.ps1"
if sys.platform == "win32"
else ".specify/scripts/bash/check-prerequisites.sh"
)
assert "{SCRIPT}" not in rendered
assert expected in rendered
class TestInstallScriptsPython:
def _make_integration_with_scripts(self, monkeypatch, tmp_path):

View File

@@ -1,10 +1,927 @@
"""Tests for BobIntegration."""
from .test_integration_base_markdown import MarkdownIntegrationTests
import os
import warnings
import pytest
import yaml
from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
from specify_cli.integrations.base import SkillsIntegration
from specify_cli.integrations.manifest import IntegrationManifest
class TestBobIntegration(MarkdownIntegrationTests):
KEY = "bob"
FOLDER = ".bob/"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".bob/commands"
class TestBobIntegrationRegistration:
def test_registered(self):
assert "bob" in INTEGRATION_REGISTRY
assert get_integration("bob") is not None
def test_is_integration_base_not_skills_integration(self):
"""BobIntegration extends IntegrationBase directly — not SkillsIntegration.
Bob is dual-mode (skills by default, legacy commands via
``--legacy-commands``), so its skills-ness is a per-project config
decision resolved by the ``is_skills_mode`` hook — not a class-hierarchy
property. It therefore must NOT be a ``SkillsIntegration`` (which is
reserved for statically skills-only agents); shared code consults
``is_skills_mode(parsed_options)`` instead of ``isinstance``.
``invoke_separator='-'`` is set explicitly on the class to match the
default (skills) layout.
"""
from specify_cli.integrations.base import IntegrationBase
bob = get_integration("bob")
assert isinstance(bob, IntegrationBase)
assert not isinstance(bob, SkillsIntegration)
assert bob.invoke_separator == "-"
def test_key_and_config(self):
bob = get_integration("bob")
assert bob.key == "bob"
assert bob.config["folder"] == ".bob/"
# registrar_config mirrors the legacy commands layout so that
# CommandRegistrar.AGENT_CONFIGS["bob"] follows the Copilot pattern:
# extension registration writes to .bob/commands/ for legacy-mode
# projects and is skipped for skills-mode projects (skills_mode_active).
assert bob.config["commands_subdir"] == "commands"
assert bob.registrar_config["dir"] == ".bob/commands"
assert bob.registrar_config["extension"] == ".md"
def test_invoke_separator_is_hyphen(self):
"""Class-level invoke_separator must be '-' so CommandRegistrar.AGENT_CONFIGS
generates correct /speckit-<name> refs without calling effective_invoke_separator."""
bob = get_integration("bob")
assert bob.invoke_separator == "-"
class TestBobOptionsFlag:
def test_options_include_legacy_commands_flag(self):
bob = get_integration("bob")
opts = bob.options()
legacy_opts = [o for o in opts if o.name == "--legacy-commands"]
assert len(legacy_opts) == 1
opt = legacy_opts[0]
assert opt.is_flag is True
# Legacy must be OPT-IN (default=False) — skills are the default
assert opt.default is False
def test_options_include_skills_migration_flag(self):
"""Review #3415, 4724160183, comment 1: a ``--skills`` opt-in exists as
the supported migration path from legacy commands to the skills layout.
It is distinct from the pre-skills-default ``--skills`` flag: here it
*forces* skills mode over on-disk auto-detection.
"""
bob = get_integration("bob")
opts = bob.options()
skills_opts = [o for o in opts if o.name == "--skills"]
assert len(skills_opts) == 1
opt = skills_opts[0]
assert opt.is_flag is True
# Opt-in: disk auto-detection remains the default behavior.
assert opt.default is False
class TestBobIsSkillsModeHook:
"""The is_skills_mode hook is the single source of truth for the mode."""
def test_default_is_skills(self):
bob = get_integration("bob")
assert bob.is_skills_mode(None) is True
assert bob.is_skills_mode({}) is True
def test_legacy_commands_disables_skills(self):
bob = get_integration("bob")
assert bob.is_skills_mode({"legacy_commands": True}) is False
def test_existing_commands_layout_preserved_on_use(self, tmp_path):
"""Regression (review #3415): an existing Bob 1.x project (managed
``.bob/commands/speckit.*.md`` on disk, no stored ``legacy_commands``)
must NOT be treated as skills mode when re-resolved with a
project_root, so ``use``/``switch``/``upgrade`` never silently migrate
it to skills.
"""
bob = get_integration("bob")
cmds = tmp_path / ".bob" / "commands"
cmds.mkdir(parents=True)
(cmds / "speckit.plan.md").write_text("# plan", encoding="utf-8")
# No parsed options at all — the pre-existing-install scenario.
assert bob.is_skills_mode(None, project_root=tmp_path) is False
assert bob.is_skills_mode({}, project_root=tmp_path) is False
def test_existing_skills_layout_stays_skills_on_use(self, tmp_path):
"""A project with managed ``speckit-*`` skills resolves to skills mode."""
bob = get_integration("bob")
(tmp_path / ".bob" / "skills" / "speckit-plan").mkdir(parents=True)
assert bob.is_skills_mode(None, project_root=tmp_path) is True
def test_managed_commands_with_unrelated_skills_dir_stays_legacy(
self, tmp_path
):
"""Regression (review #3415, 4723246468): a legacy Spec Kit install
(managed ``.bob/commands/speckit.*.md``) that *also* carries unrelated
Bob 2 skills (a ``.bob/skills/`` dir with no managed ``speckit-*``
skills) must stay in command mode — the mere presence of a skills
directory is not evidence that Spec Kit is skills-based.
"""
bob = get_integration("bob")
cmds = tmp_path / ".bob" / "commands"
cmds.mkdir(parents=True)
(cmds / "speckit.plan.md").write_text("# plan", encoding="utf-8")
# An unrelated (non-Spec-Kit) skill the user authored.
(tmp_path / ".bob" / "skills" / "my-own-skill").mkdir(parents=True)
assert bob.is_skills_mode(None, project_root=tmp_path) is False
assert bob.effective_invoke_separator(None, project_root=tmp_path) == "."
def test_managed_skills_win_when_both_layouts_present(self, tmp_path):
"""When managed Spec Kit skills exist, skills mode wins even if a stale
managed command file is still on disk (upgrade leftover)."""
bob = get_integration("bob")
cmds = tmp_path / ".bob" / "commands"
cmds.mkdir(parents=True)
(cmds / "speckit.plan.md").write_text("# plan", encoding="utf-8")
(tmp_path / ".bob" / "skills" / "speckit-plan").mkdir(parents=True)
assert bob.is_skills_mode(None, project_root=tmp_path) is True
def test_fresh_project_defaults_to_skills_with_project_root(self, tmp_path):
"""A project with no managed ``.bob/`` artifacts yet defaults to skills."""
bob = get_integration("bob")
assert bob.is_skills_mode(None, project_root=tmp_path) is True
def test_explicit_legacy_flag_wins_over_disk_layout(self, tmp_path):
"""An explicit ``--legacy-commands`` overrides on-disk detection."""
bob = get_integration("bob")
(tmp_path / ".bob" / "skills" / "speckit-plan").mkdir(parents=True)
assert (
bob.is_skills_mode({"legacy_commands": True}, project_root=tmp_path)
is False
)
def test_explicit_skills_flag_forces_skills_over_legacy_disk_layout(
self, tmp_path
):
"""Regression (review #3415, 4724160183, comment 1).
``--skills`` is the supported migration / opt-in: it must force skills
mode even when a managed legacy ``.bob/commands`` layout is on disk
(which otherwise auto-detects to legacy). This gives
``integration upgrade bob --integration-options="--skills"`` a path out
of legacy mode instead of being trapped by disk detection.
"""
bob = get_integration("bob")
cmds = tmp_path / ".bob" / "commands"
cmds.mkdir(parents=True)
(cmds / "speckit.plan.md").write_text("# plan", encoding="utf-8")
assert bob.is_skills_mode({"skills": True}, project_root=tmp_path) is True
assert (
bob.effective_invoke_separator({"skills": True}, project_root=tmp_path)
== "-"
)
def test_skills_and_legacy_flags_are_mutually_exclusive(self):
"""Passing both ``--skills`` and ``--legacy-commands`` exits cleanly."""
import typer
bob = get_integration("bob")
with pytest.raises(typer.Exit):
bob.is_skills_mode({"skills": True, "legacy_commands": True})
def test_effective_invoke_separator_tracks_mode(self):
bob = get_integration("bob")
assert bob.effective_invoke_separator(None) == "-"
assert bob.effective_invoke_separator({"legacy_commands": True}) == "."
assert bob.effective_invoke_separator({"skills": True}) == "-"
def test_invoke_separator_for_mode_tracks_persisted_state(self):
"""Registration paths resolve the separator from persisted ai_skills."""
bob = get_integration("bob")
assert bob.invoke_separator_for_mode(True) == "-"
assert bob.invoke_separator_for_mode(False) == "."
def test_no_skills_mode_method_leaks(self):
"""The old callable _skills_mode method must be gone; consumers use the hook."""
bob = get_integration("bob")
assert not callable(getattr(bob, "_skills_mode", None))
class TestBobDefaultSkillsMode:
"""Default mode: .bob/skills/speckit-<name>/SKILL.md layout."""
def test_setup_creates_skill_files(self, tmp_path):
bob = get_integration("bob")
m = IntegrationManifest("bob", tmp_path)
created = bob.setup(tmp_path, m)
assert len(created) > 0
for f in created:
assert f.exists()
assert f.name == "SKILL.md"
assert f.parent.name.startswith("speckit-")
def test_setup_writes_to_correct_directory(self, tmp_path):
bob = get_integration("bob")
m = IntegrationManifest("bob", tmp_path)
bob.setup(tmp_path, m)
skills_dir = tmp_path / ".bob" / "skills"
assert skills_dir.is_dir()
def test_setup_does_not_warn(self, tmp_path):
bob = get_integration("bob")
m = IntegrationManifest("bob", tmp_path)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
bob.setup(tmp_path, m)
assert not any(
"legacy" in str(item.message).lower() for item in caught
)
def test_setup_no_commands_dir(self, tmp_path):
bob = get_integration("bob")
m = IntegrationManifest("bob", tmp_path)
bob.setup(tmp_path, m)
assert not (tmp_path / ".bob" / "commands").exists()
def test_skill_directory_structure(self, tmp_path):
"""Each command produces speckit-<name>/SKILL.md."""
bob = get_integration("bob")
m = IntegrationManifest("bob", tmp_path)
created = bob.setup(tmp_path, m)
expected_commands = {
"analyze", "clarify", "constitution", "converge", "implement",
"plan", "checklist", "specify", "tasks", "taskstoissues",
}
actual_commands = {f.parent.name.removeprefix("speckit-") for f in created}
assert actual_commands == expected_commands
def test_skill_frontmatter_structure(self, tmp_path):
"""SKILL.md must have name, description, compatibility, metadata."""
bob = get_integration("bob")
m = IntegrationManifest("bob", tmp_path)
created = bob.setup(tmp_path, m)
for f in created:
content = f.read_text(encoding="utf-8")
assert content.startswith("---\n"), f"{f} missing frontmatter"
parts = content.split("---", 2)
fm = yaml.safe_load(parts[1])
assert "name" in fm
assert "description" in fm
assert "compatibility" in fm
assert "metadata" in fm
assert fm["metadata"]["author"] == "github-spec-kit"
def test_templates_are_processed(self, tmp_path):
bob = get_integration("bob")
m = IntegrationManifest("bob", tmp_path)
created = bob.setup(tmp_path, m)
for f in created:
content = f.read_text(encoding="utf-8")
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
def test_command_refs_use_hyphen_separator(self, tmp_path):
"""Default skills layout must use /speckit-<name>, not /speckit.<name>."""
bob = get_integration("bob")
m = IntegrationManifest("bob", tmp_path)
created = bob.setup(tmp_path, m)
for f in created:
content = f.read_text(encoding="utf-8")
assert "/speckit." not in content, (
f"{f.name} contains dot-notation /speckit. reference; "
"skills must use /speckit-<name>"
)
def test_all_files_tracked_in_manifest(self, tmp_path):
bob = get_integration("bob")
m = IntegrationManifest("bob", tmp_path)
created = bob.setup(tmp_path, m)
for f in created:
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
assert rel in m.files, f"{rel} not tracked in manifest"
def test_install_uninstall_roundtrip(self, tmp_path):
bob = get_integration("bob")
m = IntegrationManifest("bob", tmp_path)
created = bob.install(tmp_path, m)
assert len(created) > 0
m.save()
for f in created:
assert f.exists()
removed, skipped = bob.uninstall(tmp_path, m)
assert len(removed) == len(created)
assert skipped == []
class TestBobLegacyCommandsMode:
"""Legacy opt-in mode: .bob/commands/speckit.<name>.md layout."""
def test_setup_legacy_creates_markdown_files(self, tmp_path):
from specify_cli.integrations.bob import BobIntegration
bob = BobIntegration()
m = IntegrationManifest("bob", tmp_path)
created = bob.setup(tmp_path, m, parsed_options={"legacy_commands": True})
assert len(created) > 0
for f in created:
assert f.exists()
assert f.suffix == ".md"
assert f.name.startswith("speckit.")
assert f.parent == tmp_path / ".bob" / "commands"
def test_setup_legacy_warns_deprecated(self, tmp_path):
from specify_cli.integrations.bob import BobIntegration
bob = BobIntegration()
m = IntegrationManifest("bob", tmp_path)
with pytest.warns(UserWarning, match="Bob legacy commands mode"):
bob.setup(tmp_path, m, parsed_options={"legacy_commands": True})
def test_setup_legacy_no_skills_dir(self, tmp_path):
from specify_cli.integrations.bob import BobIntegration
bob = BobIntegration()
m = IntegrationManifest("bob", tmp_path)
bob.setup(tmp_path, m, parsed_options={"legacy_commands": True})
assert not (tmp_path / ".bob" / "skills").exists()
def test_setup_legacy_templates_are_processed(self, tmp_path):
from specify_cli.integrations.bob import BobIntegration
bob = BobIntegration()
m = IntegrationManifest("bob", tmp_path)
bob.setup(tmp_path, m, parsed_options={"legacy_commands": True})
commands_dir = tmp_path / ".bob" / "commands"
for md_file in commands_dir.glob("speckit.*.md"):
content = md_file.read_text(encoding="utf-8")
assert "{SCRIPT}" not in content
assert "__AGENT__" not in content
assert "{ARGS}" not in content
assert "__SPECKIT_COMMAND_" not in content
def test_setup_legacy_all_files_tracked(self, tmp_path):
from specify_cli.integrations.bob import BobIntegration
bob = BobIntegration()
m = IntegrationManifest("bob", tmp_path)
created = bob.setup(tmp_path, m, parsed_options={"legacy_commands": True})
for f in created:
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
assert rel in m.files, f"{rel} not tracked in manifest"
def test_setup_legacy_uninstall_roundtrip(self, tmp_path):
from specify_cli.integrations.bob import BobIntegration
bob = BobIntegration()
m = IntegrationManifest("bob", tmp_path)
created = bob.install(tmp_path, m, parsed_options={"legacy_commands": True})
assert len(created) > 0
m.save()
removed, skipped = bob.uninstall(tmp_path, m)
assert len(removed) == len(created)
assert skipped == []
class TestBobInitFlowDefault:
"""CLI init creates skills by default."""
def test_init_default_creates_skills(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
target = tmp_path / "test-proj"
result = CliRunner().invoke(app, [
"init", str(target), "--integration", "bob",
"--ignore-agent-tools", "--script", "sh",
])
assert result.exit_code == 0, f"init --integration bob failed: {result.output}"
assert (target / ".bob" / "skills" / "speckit-plan" / "SKILL.md").exists()
assert not (target / ".bob" / "commands").exists()
def test_init_default_complete_file_inventory_sh(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "inventory-sh-bob"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "bob", "--script", "sh",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
commands = [
"analyze", "clarify", "constitution", "converge", "implement",
"plan", "checklist", "specify", "tasks", "taskstoissues",
]
for cmd in commands:
assert (project / ".bob" / "skills" / f"speckit-{cmd}" / "SKILL.md").exists(), (
f"Missing .bob/skills/speckit-{cmd}/SKILL.md"
)
class TestBobInitFlowLegacy:
"""CLI init with --legacy-commands produces .bob/commands/*.md."""
def test_init_legacy_creates_commands(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
target = tmp_path / "test-proj"
result = CliRunner().invoke(app, [
"init", str(target), "--integration", "bob",
"--integration-options", "--legacy-commands",
"--ignore-agent-tools", "--script", "sh",
])
assert result.exit_code == 0, f"init --integration bob --legacy-commands failed: {result.output}"
assert (target / ".bob" / "commands" / "speckit.plan.md").exists()
assert not (target / ".bob" / "skills").exists()
def test_init_legacy_does_not_set_ai_skills(self, tmp_path):
"""Legacy install must NOT write ai_skills=True to init-options.json.
Behavioral guard for the dual-mode contract: with --legacy-commands,
BobIntegration.is_skills_mode(parsed_options) returns False, so
_update_init_options_for_integration must not persist ai_skills=True.
(Regression origin: shared code previously probed a bound _skills_mode
method object, which is always truthy, and wrongly enabled skills for
legacy projects.)
"""
from typer.testing import CliRunner
from specify_cli import app
from specify_cli import load_init_options
target = tmp_path / "test-proj"
result = CliRunner().invoke(app, [
"init", str(target), "--integration", "bob",
"--integration-options", "--legacy-commands",
"--ignore-agent-tools", "--script", "sh",
])
assert result.exit_code == 0, f"init failed: {result.output}"
init_opts = load_init_options(target)
assert init_opts.get("ai_skills") is not True, (
"Legacy Bob project must not have ai_skills=True in init-options.json"
)
class TestBobRegistrarConfig:
"""Verify AGENT_CONFIGS["bob"] follows the Copilot pattern for extension registration."""
def test_registrar_config_uses_commands_layout(self):
"""AGENT_CONFIGS["bob"] must use the legacy .md layout (not /SKILL.md).
This mirrors Copilot: the static registrar config targets the non-skills
format so that:
- skills_mode_active becomes True when ai_skills=True, preventing
extension registration from writing SKILL.md files into .bob/skills/
on projects that never asked for legacy files.
- legacy-mode projects receive extension .md files in .bob/commands/.
"""
from specify_cli.agents import CommandRegistrar
registrar = CommandRegistrar()
bob_cfg = registrar.AGENT_CONFIGS.get("bob")
assert bob_cfg is not None, "bob must be in AGENT_CONFIGS"
assert bob_cfg["extension"] == ".md", (
"AGENT_CONFIGS['bob']['extension'] must be '.md' so that "
"skills_mode_active=True suppresses extension registration on "
"skills-mode projects (mirrors the Copilot pattern)"
)
assert bob_cfg["dir"] == ".bob/commands"
def test_skills_mode_project_extension_registration_skipped(self, tmp_path):
"""Extension registrar skips Bob on skills-mode projects (no .bob/commands dir)."""
from specify_cli.agents import CommandRegistrar
# Simulate a skills-mode Bob project: .bob/skills exists, .bob/commands does not
(tmp_path / ".bob" / "skills").mkdir(parents=True)
registrar = CommandRegistrar()
results = registrar.register_commands_for_all_agents(
commands=[{"name": "speckit.test-cmd", "file": "test.md"}],
source_id="test",
source_dir=tmp_path,
project_root=tmp_path,
)
# Bob must not appear in results — .bob/commands doesn't exist
assert "bob" not in results
def test_legacy_mode_project_extension_registration_runs(self, tmp_path):
"""Extension registrar writes to .bob/commands/ for legacy-mode projects."""
import textwrap
from specify_cli.agents import CommandRegistrar
# Simulate a legacy-mode Bob project: .bob/commands exists, .bob/skills does not
commands_dir = tmp_path / ".bob" / "commands"
commands_dir.mkdir(parents=True)
# Provide a minimal command source file
cmd_file = tmp_path / "test.md"
cmd_file.write_text(
textwrap.dedent("""\
---
description: "Test command"
---
Test body.
"""),
encoding="utf-8",
)
registrar = CommandRegistrar()
results = registrar.register_commands_for_all_agents(
commands=[{"name": "speckit.test-cmd", "file": "test.md"}],
source_id="test",
source_dir=tmp_path,
project_root=tmp_path,
)
assert "bob" in results, "bob must appear in results for legacy-mode project"
registered_file = commands_dir / "speckit.test-cmd.md"
assert registered_file.exists(), f"Expected {registered_file} to be written"
def test_legacy_extension_command_refs_use_dot_separator(self, tmp_path):
"""Regression (review #3415): legacy .bob/commands/ extension commands must
render Bob 1.x ``/speckit.<cmd>`` refs, not the skills-layout ``/speckit-<cmd>``.
The single static AGENT_CONFIGS["bob"]["invoke_separator"] is "-" (the
default skills layout); register_commands must instead resolve the
separator from the project's persisted mode via
BobIntegration.invoke_separator_for_mode(False) -> ".".
"""
import textwrap
from specify_cli.agents import CommandRegistrar
# Legacy-mode project: .bob/commands exists, ai_skills is NOT set.
commands_dir = tmp_path / ".bob" / "commands"
commands_dir.mkdir(parents=True)
cmd_file = tmp_path / "test.md"
cmd_file.write_text(
textwrap.dedent("""\
---
description: "Test command"
---
See __SPECKIT_COMMAND_SPECIFY__ for details.
"""),
encoding="utf-8",
)
registrar = CommandRegistrar()
registrar.register_commands_for_all_agents(
commands=[{"name": "speckit.test-cmd", "file": "test.md"}],
source_id="test",
source_dir=tmp_path,
project_root=tmp_path,
)
rendered = (commands_dir / "speckit.test-cmd.md").read_text(encoding="utf-8")
assert "/speckit.specify" in rendered, (
"legacy Bob extension commands must render /speckit.specify (dot)"
)
assert "/speckit-specify" not in rendered
class TestBobUseFlowPreservesLegacyLayout:
"""Regression (review #3415): re-activating an existing Bob 1.x project
must not silently migrate it to the skills layout.
"""
def test_update_init_options_preserves_legacy_commands_project(self, tmp_path):
"""``use``/``switch``/``upgrade`` on a ``.bob/commands``-only project
(no stored ``legacy_commands``) must not write ``ai_skills=True``.
"""
from specify_cli.integrations._helpers import (
_update_init_options_for_integration,
)
from specify_cli import load_init_options
# Existing Bob 1.x project: legacy commands dir on disk, no ai_skills.
cmds = tmp_path / ".bob" / "commands"
cmds.mkdir(parents=True)
(cmds / "speckit.plan.md").write_text("# plan", encoding="utf-8")
bob = get_integration("bob")
# Simulate the use/switch path: no parsed options were stored.
_update_init_options_for_integration(tmp_path, bob, parsed_options=None)
opts = load_init_options(tmp_path)
assert opts.get("ai") == "bob"
assert opts.get("ai_skills") is not True, (
"an existing .bob/commands project must stay legacy on re-activation"
)
def test_update_init_options_keeps_skills_project_as_skills(self, tmp_path):
"""A ``.bob/skills`` project stays skills on re-activation."""
from specify_cli.integrations._helpers import (
_update_init_options_for_integration,
)
from specify_cli import load_init_options
(tmp_path / ".bob" / "skills" / "speckit-plan").mkdir(parents=True)
bob = get_integration("bob")
_update_init_options_for_integration(tmp_path, bob, parsed_options=None)
opts = load_init_options(tmp_path)
assert opts.get("ai_skills") is True
def test_with_integration_setting_stores_dot_separator_for_legacy(self, tmp_path):
"""Regression (review #3415): shared-infra refresh on the use/switch
path resolves the command-ref separator *before* init-options are
rewritten, via ``effective_invoke_separator``. For an existing
``.bob/commands`` project with no stored options this must resolve to
``"."`` (project-aware), not the skills-layout ``"-"``; otherwise core
command references get rewritten to ``/speckit-*``.
"""
from specify_cli.integration_runtime import with_integration_setting
cmds = tmp_path / ".bob" / "commands"
cmds.mkdir(parents=True)
(cmds / "speckit.plan.md").write_text("# plan", encoding="utf-8")
bob = get_integration("bob")
# Simulate the use/switch path: no parsed options stored.
settings = with_integration_setting(
{}, "bob", bob, parsed_options=None, project_root=tmp_path
)
assert settings["bob"]["invoke_separator"] == ".", (
"legacy .bob/commands project must persist the dot separator so "
"shared templates render Bob 1.x /speckit.<cmd> references"
)
def test_use_force_keeps_legacy_command_refs_in_shared_templates(self, tmp_path):
"""End-to-end (review #3415): ``integration use bob --force`` on an
existing Bob 1.x project (legacy layout on disk, stored options
stripped as a pre-PR install would be) must re-render shared templates
with ``/speckit.<cmd>`` (dot), not ``/speckit-<cmd>``.
"""
import json
from typer.testing import CliRunner
from specify_cli import app
# Create a real legacy Bob project (renders shared templates).
target = tmp_path / "proj"
runner = CliRunner()
result = runner.invoke(app, [
"init", str(target), "--integration", "bob",
"--integration-options", "--legacy-commands",
"--ignore-agent-tools", "--script", "sh",
])
assert result.exit_code == 0, f"init failed: {result.output}"
template = target / ".specify" / "templates" / "plan-template.md"
assert template.is_file(), "expected a rendered shared plan template"
assert "/speckit.plan" in template.read_text(encoding="utf-8")
# Simulate a pre-PR Bob 1.x install: no stored options/separator.
integ_json = target / ".specify" / "integration.json"
data = json.loads(integ_json.read_text(encoding="utf-8"))
bob_settings = data["integration_settings"]["bob"]
for stale in ("raw_options", "parsed_options", "invoke_separator"):
bob_settings.pop(stale, None)
integ_json.write_text(json.dumps(data, indent=2), encoding="utf-8")
# Re-activate with --force so shared templates are re-rendered.
import os
old_cwd = os.getcwd()
try:
os.chdir(target)
result = runner.invoke(
app, ["integration", "use", "bob", "--force"]
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"use failed: {result.output}"
rendered = template.read_text(encoding="utf-8")
assert "/speckit.plan" in rendered, (
"legacy Bob project must keep /speckit.plan (dot) after refresh"
)
assert "/speckit-plan" not in rendered, (
"shared templates must not be rewritten to the skills /speckit-plan"
)
# And the persisted separator must reflect the legacy layout.
data = json.loads(integ_json.read_text(encoding="utf-8"))
assert data["integration_settings"]["bob"].get("invoke_separator") == "."
class TestBobCommandRefScopedToActiveAgent:
"""Regression (review #3415, 4716424313).
``CommandRegistrar.register_commands`` runs once per detected agent, but the
persisted ``ai_skills`` flag describes only the *active* integration
(``opts["ai"]``). When another agent (e.g. Copilot) is active in skills
mode while a legacy ``.bob/commands`` layout is also present, Bob's command
references must still render with the ``.`` separator (Bob 1.x
``/speckit.<cmd>``) rather than inheriting Copilot's ``ai_skills=True`` and
rendering ``/speckit-<cmd>``.
"""
def _write_command_ref_ext(self, source_dir):
source_dir.mkdir(parents=True, exist_ok=True)
cmd = source_dir / "run.md"
cmd.write_text(
"---\ndescription: Run\n---\n\nUse __SPECKIT_COMMAND_PLAN__ first.\n",
encoding="utf-8",
)
return [{"name": "speckit.ext.run", "file": "run.md"}]
def test_legacy_bob_ref_not_rewritten_when_other_agent_active_in_skills(
self, tmp_path
):
from specify_cli._init_options import save_init_options
from specify_cli.agents import CommandRegistrar
# Legacy Bob layout on disk; skills layout absent.
(tmp_path / ".bob" / "commands").mkdir(parents=True)
# A different agent (Copilot) is the active integration, in skills mode.
save_init_options(tmp_path, {"ai": "copilot", "ai_skills": True})
source_dir = tmp_path / "ext-src"
commands = self._write_command_ref_ext(source_dir)
registrar = CommandRegistrar()
registered = registrar.register_commands(
"bob", commands, "ext", source_dir, tmp_path,
)
assert "speckit.ext.run" in registered
written = list((tmp_path / ".bob" / "commands").glob("*.md"))
assert written, "expected a rendered Bob command file"
content = written[0].read_text(encoding="utf-8")
assert "__SPECKIT_COMMAND_PLAN__" not in content
assert "/speckit.plan" in content, (
"legacy Bob command refs must use the dot separator even when "
"another agent is active in skills mode"
)
assert "/speckit-plan" not in content
def test_active_bob_skills_command_output_uses_dot(self, tmp_path):
"""Regression (review #3415, 4724160183, comment 2).
The separator must match the *output layout* the registrar writes, not
the project's persisted ``ai_skills`` flag. Even when Bob itself is the
active agent in skills mode, a ``.bob/commands/*.md`` file is a
command-layout artifact and must render Bob 1.x ``/speckit.<cmd>``.
Rendering ``/speckit-<cmd>`` into a command file (as the old
``ai_skills``-driven active-agent branch did) produced an invocation the
command layout can't resolve. Bob skills are written via its own
skills path, so ``register_commands`` only ever emits command-layout
files for Bob.
"""
from specify_cli._init_options import save_init_options
from specify_cli.agents import CommandRegistrar
(tmp_path / ".bob" / "skills").mkdir(parents=True)
save_init_options(tmp_path, {"ai": "bob", "ai_skills": True})
source_dir = tmp_path / "ext-src"
commands = self._write_command_ref_ext(source_dir)
registrar = CommandRegistrar()
registrar.register_commands("bob", commands, "ext", source_dir, tmp_path)
written = list((tmp_path / ".bob" / "commands").glob("*.md"))
assert written, "expected a rendered Bob command file"
content = written[0].read_text(encoding="utf-8")
assert "__SPECKIT_COMMAND_PLAN__" not in content
assert "/speckit.plan" in content, (
"a .bob/commands/*.md command-layout file must use the dot "
"separator even when Bob is the active agent in skills mode"
)
assert "/speckit-plan" not in content
def test_inactive_bob_command_output_uses_dot_even_with_skills_dir(
self, tmp_path
):
"""Regression (review #3415, 4723246468): for an inactive Bob install
the registrar's separator must match the layout it is actually writing
(``.bob/commands/*.md`` — command layout), not on-disk sibling dirs.
Even when a ``.bob/skills/`` directory (with managed ``speckit-*``
skills) coexists, command-layout files must keep ``/speckit.<cmd>``.
"""
from specify_cli._init_options import save_init_options
from specify_cli.agents import CommandRegistrar
# Both layouts on disk; the active agent is something else entirely.
(tmp_path / ".bob" / "commands").mkdir(parents=True)
(tmp_path / ".bob" / "skills" / "speckit-plan").mkdir(parents=True)
save_init_options(tmp_path, {"ai": "claude", "ai_skills": True})
source_dir = tmp_path / "ext-src"
commands = self._write_command_ref_ext(source_dir)
registrar = CommandRegistrar()
registrar.register_commands("bob", commands, "ext", source_dir, tmp_path)
written = list((tmp_path / ".bob" / "commands").glob("*.md"))
assert written, "expected a rendered Bob command file"
content = written[0].read_text(encoding="utf-8")
assert "__SPECKIT_COMMAND_PLAN__" not in content
assert "/speckit.plan" in content, (
"Bob command-layout output must use the dot separator regardless "
"of a coexisting .bob/skills directory"
)
assert "/speckit-plan" not in content
class TestBobSetupPreservesLegacyOnUpgrade:
"""Regression (review #3415, 4723782860, comment 1).
``setup()`` must apply the same managed-artifact detection as ``use`` so
that ``integration upgrade bob`` on a Bob 1.x install (managed
``.bob/commands/speckit.*.md`` on disk, no stored options) preserves the
command layout instead of silently generating skills and stale-deleting
the legacy commands.
"""
def test_setup_without_options_preserves_existing_command_layout(
self, tmp_path
):
from specify_cli.integrations.bob import BobIntegration
# Pre-existing Bob 1.x install: managed command files, no options.
cmds = tmp_path / ".bob" / "commands"
cmds.mkdir(parents=True)
(cmds / "speckit.plan.md").write_text("# plan", encoding="utf-8")
bob = BobIntegration()
m = IntegrationManifest("bob", tmp_path)
with pytest.warns(UserWarning, match="Bob legacy commands mode"):
created = bob.setup(tmp_path, m, parsed_options=None)
# Command layout regenerated; no skills layout introduced.
assert not (tmp_path / ".bob" / "skills").exists(), (
"upgrade must not migrate an existing legacy Bob project to skills"
)
assert created, "expected command files to be regenerated"
for f in created:
assert f.parent == tmp_path / ".bob" / "commands"
assert f.suffix == ".md"
def test_setup_fresh_project_still_defaults_to_skills(self, tmp_path):
"""A fresh project (no managed artifacts) still defaults to skills."""
from specify_cli.integrations.bob import BobIntegration
bob = BobIntegration()
m = IntegrationManifest("bob", tmp_path)
created = bob.setup(tmp_path, m, parsed_options=None)
assert (tmp_path / ".bob" / "skills").is_dir()
assert not (tmp_path / ".bob" / "commands").exists()
assert created
def test_setup_with_skills_flag_migrates_legacy_to_skills(self, tmp_path):
"""Review #3415, 4724160183, comment 1: ``--skills`` on an existing
legacy install forces the skills layout (the migration opt-in), instead
of preserving the auto-detected legacy layout. ``setup()`` scaffolds the
skills layout; the ``integration upgrade`` stale-file pass removes the
old command files.
"""
from specify_cli.integrations.bob import BobIntegration
# Pre-existing Bob 1.x install on disk.
cmds = tmp_path / ".bob" / "commands"
cmds.mkdir(parents=True)
(cmds / "speckit.plan.md").write_text("# plan", encoding="utf-8")
bob = BobIntegration()
m = IntegrationManifest("bob", tmp_path)
# No deprecation warning — the user opted into skills, not legacy.
with warnings.catch_warnings():
warnings.simplefilter("error", UserWarning)
created = bob.setup(tmp_path, m, parsed_options={"skills": True})
assert (tmp_path / ".bob" / "skills").is_dir(), (
"--skills must force the skills layout even when a legacy commands "
"layout is already on disk"
)
assert created
for f in created:
assert f.name == "SKILL.md"
assert f.parent.name.startswith("speckit-")
class TestBobPostProcessSkillContent:
"""Regression (review #3415, 4723782860, comment 2).
Preset/extension skill generators call ``post_process_skill_content`` on
the *registered* ``BobIntegration`` instance. Core Bob skills are
intent-activated and intentionally omit the shared slash-command hook note,
so the registered class must expose the same no-op the skills helper does
(not inherit a note-injecting default) to keep every skill path consistent.
"""
def test_registered_bob_has_post_process_hook(self):
bob = get_integration("bob")
assert hasattr(bob, "post_process_skill_content")
def test_post_process_is_noop_no_hook_note_injected(self):
bob = get_integration("bob")
sample = (
"---\nname: speckit-plan\n---\n\n"
"Run /speckit.plan then /speckit.tasks.\n"
)
assert bob.post_process_skill_content(sample) == sample
def test_post_process_matches_skills_helper(self):
from specify_cli.integrations.bob import _BobSkillsHelper
bob = get_integration("bob")
sample = "---\nname: speckit-analyze\n---\n\nSome body with /speckit.plan.\n"
assert (
bob.post_process_skill_content(sample)
== _BobSkillsHelper().post_process_skill_content(sample)
)

View File

@@ -13,9 +13,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 +276,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))
@@ -922,6 +989,57 @@ class TestCatalogSourceManagement:
assert str(cfg_path) in message
assert "expected a mapping" in message
def test_add_catalog_rejects_inf_priority_in_existing_entry(
self, tmp_path, monkeypatch
):
# ``priority: .inf`` loads as float('inf'); int() on it raises
# OverflowError, which used to escape the IntegrationValidationError
# contract as a raw traceback (github/spec-kit#3526 fixed the sibling
# workflow/step loaders the same way).
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.write_text(
yaml.dump(
{
"catalogs": [
{
"url": "https://a.example.com/catalog.json",
"priority": float("inf"),
}
]
}
),
encoding="utf-8",
)
cat = IntegrationCatalog(tmp_path)
with pytest.raises(
IntegrationValidationError, match="must be an integer"
):
cat.add_catalog("https://new.example.com/catalog.json")
def test_remove_catalog_tolerates_inf_priority(self, tmp_path, monkeypatch):
# Building the remove display order must not crash on a ``priority:
# .inf`` entry; it falls back to positional order like the other
# non-integer priorities do.
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.write_text(
yaml.dump(
{
"catalogs": [
{
"url": "https://a.example.com/catalog.json",
"priority": float("inf"),
},
{"url": "https://b.example.com/catalog.json", "priority": 2},
]
}
),
encoding="utf-8",
)
cat = IntegrationCatalog(tmp_path)
cat.remove_catalog(0) # must not raise OverflowError
def test_add_catalog_skips_blank_url_entries(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"

View File

@@ -575,6 +575,17 @@ class TestCopilotSkillsMode:
assert copilot.effective_invoke_separator({"skills": True}) == "-"
assert copilot.effective_invoke_separator({"skills": False}) == "."
def test_invoke_separator_for_mode_tracks_persisted_state(self):
"""Regression (review #3415): registration paths (preset/extension
command refs) must resolve the separator from the persisted ai_skills
state. A Copilot skills project renders ``/speckit-<cmd>`` (hyphen),
matching ``build_command_invocation``; the default markdown layout
renders ``/speckit.<cmd>`` (dot).
"""
copilot = self._make_copilot()
assert copilot.invoke_separator_for_mode(True) == "-"
assert copilot.invoke_separator_for_mode(False) == "."
def test_skill_body_has_content(self, tmp_path):
"""Each SKILL.md body should contain template content."""
copilot = self._make_copilot()

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

@@ -15,6 +15,22 @@ from tests.conftest import strip_ansi
runner = CliRunner()
@pytest.mark.parametrize(
"args",
[
["init", "--help"],
["integration", "install", "--help"],
["integration", "switch", "--help"],
["integration", "upgrade", "--help"],
],
)
def test_script_help_includes_python_variant(args):
result = runner.invoke(app, args)
assert result.exit_code == 0
assert "sh, ps, or py" in " ".join(strip_ansi(result.output).split())
def _init_project(tmp_path, integration="copilot", integration_options=None):
"""Helper: init a spec-kit project with the given integration."""
project = tmp_path / "proj"
@@ -2477,6 +2493,300 @@ class TestIntegrationUpgrade:
f"found: {[f.name for f in core_remaining]}"
)
def test_upgrade_bob_skills_migration_preserves_manifest(self, tmp_path):
"""Regression (review #3415, 4724160183, comment 1).
``integration upgrade bob --integration-options="--skills"`` migrates a
legacy Bob 1.x install (``.bob/commands/*.md``) to the skills layout
(``.bob/skills/speckit-*/SKILL.md``) and stale-removes the old command
files. Because that stale-file pass shrinks the tracked set, the
upgrade's Phase 2 must NOT delete the freshly-saved ``bob.manifest.json``
— otherwise the migrated project is left untracked and un-upgradeable.
"""
project = _init_project(
tmp_path, "bob", integration_options="--legacy-commands"
)
commands = project / ".bob" / "commands"
skills = project / ".bob" / "skills"
manifest_path = (
project / ".specify" / "integrations" / "bob.manifest.json"
)
assert commands.is_dir() and sorted(commands.glob("speckit.*.md"))
assert not skills.exists()
assert manifest_path.is_file()
result = _run_in_project(project, [
"integration", "upgrade", "bob",
"--integration-options", "--skills",
"--script", "sh", "--force",
])
assert result.exit_code == 0, f"migration upgrade failed: {result.output}"
# Skills layout scaffolded; legacy core command files removed.
assert skills.is_dir(), ".bob/skills/ must exist after --skills migration"
assert sorted(skills.glob("speckit-*")), "expected migrated skill dirs"
core_commands = [
f for f in commands.glob("speckit.*.md")
if "agent-context" not in f.name
] if commands.exists() else []
assert core_commands == [], (
f"legacy core command files should be removed, found: "
f"{[f.name for f in core_commands]}"
)
# The manifest must survive so the project stays tracked/upgradeable.
assert manifest_path.is_file(), (
"bob.manifest.json must survive a layout-shrinking migration"
)
reupgrade = _run_in_project(project, [
"integration", "upgrade", "bob", "--script", "sh", "--force",
])
assert reupgrade.exit_code == 0, (
f"migrated project must remain upgradeable: {reupgrade.output}"
)
def test_upgrade_bob_layout_change_reconciles_extension_artifacts(self, tmp_path):
"""Regression (review #3415, 4725829110).
When a dual-mode agent (Bob) flips layout across an upgrade, the old
layout's *extension* artifacts must be reconciled — not left orphaned.
A legacy Bob install renders enabled extensions as ``.bob/commands/``
command files; migrating to skills via ``--skills`` must remove those
command files, recreate the extension as ``.bob/skills/`` skills, and
update the extension registry accordingly (and vice-versa for the
reverse ``--legacy-commands`` migration).
"""
project = _init_project(
tmp_path, "bob", integration_options="--legacy-commands"
)
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
commands = project / ".bob" / "commands"
skills = project / ".bob" / "skills"
registry_path = project / ".specify" / "extensions" / ".registry"
def _git_registry():
data = json.loads(registry_path.read_text(encoding="utf-8"))
g = data["extensions"]["git"]
return list(g.get("registered_commands", {})), g.get(
"registered_skills", []
)
# Legacy precondition: git renders as command files under .bob/commands.
assert sorted(commands.glob("speckit.git.*.md")), (
"legacy Bob should render the git extension as command files"
)
assert not list(skills.glob("speckit-git-*")) if skills.exists() else True
cmds_agents, skill_names = _git_registry()
assert "bob" in cmds_agents and not skill_names
# Migrate legacy -> skills.
result = _run_in_project(project, [
"integration", "upgrade", "bob",
"--integration-options", "--skills",
"--script", "sh", "--force",
])
assert result.exit_code == 0, f"--skills migration failed: {result.output}"
# Old-layout git command files removed; skills recreated.
assert not sorted(commands.glob("speckit.git.*.md")), (
"git extension command files must be removed after --skills migration"
)
assert sorted(skills.glob("speckit-git-*")), (
"git extension must be recreated as skills after --skills migration"
)
cmds_agents, skill_names = _git_registry()
assert "bob" not in cmds_agents, (
"extension registry must drop the stale bob command entry"
)
assert skill_names, "extension registry must record the migrated skills"
# Migrate skills -> legacy: the reverse reconciliation must also hold.
result = _run_in_project(project, [
"integration", "upgrade", "bob",
"--integration-options", "--legacy-commands",
"--script", "sh", "--force",
])
assert result.exit_code == 0, (
f"--legacy-commands migration failed: {result.output}"
)
assert not sorted(skills.glob("speckit-git-*")), (
"git extension skills must be removed after --legacy-commands migration"
)
assert sorted(commands.glob("speckit.git.*.md")), (
"git extension command files must be recreated in legacy layout"
)
cmds_agents, skill_names = _git_registry()
assert "bob" in cmds_agents and not skill_names
def test_upgrade_bob_layout_change_rejected_with_presets_installed(self, tmp_path):
"""Regression (review #3415, 4726193915).
A command↔skills layout change cannot reconcile preset artifacts (no
agent-scoped preset re-registration exists). Rather than silently
orphaning preset files / leaving the registry inconsistent, a
layout-changing ``upgrade`` must reject the migration with an
actionable error *before any mutation* when preset overrides are
installed for the agent. A same-layout upgrade must still succeed.
"""
project = _init_project(
tmp_path, "bob", integration_options="--legacy-commands"
)
commands = project / ".bob" / "commands"
skills = project / ".bob" / "skills"
assert sorted(commands.glob("speckit.*.md"))
# Simulate an installed preset that registered command overrides for bob.
presets_dir = project / ".specify" / "presets"
presets_dir.mkdir(parents=True, exist_ok=True)
(presets_dir / ".registry").write_text(
json.dumps({
"presets": {
"my-preset": {
"version": "1.0.0",
"enabled": True,
"registered_commands": {"bob": ["speckit.plan"]},
"registered_skills": [],
}
}
}),
encoding="utf-8",
)
# Layout-changing upgrade is rejected, and nothing is mutated.
result = _run_in_project(project, [
"integration", "upgrade", "bob",
"--integration-options", "--skills",
"--script", "sh", "--force",
])
assert result.exit_code != 0, "layout change with presets must be rejected"
assert "preset" in result.output.lower()
assert "my-preset" in result.output
assert not skills.exists(), "no skills layout must be scaffolded on rejection"
assert sorted(commands.glob("speckit.*.md")), (
"legacy command files must be left untouched on rejection"
)
# A same-layout upgrade (no flag) must still succeed with presets present.
result = _run_in_project(project, [
"integration", "upgrade", "bob", "--script", "sh", "--force",
])
assert result.exit_code == 0, (
f"same-layout upgrade must not be blocked by presets: {result.output}"
)
def test_upgrade_bob_layout_change_rejected_when_preset_registry_unreadable(
self, tmp_path
):
"""Regression (review #3415, 4744636079).
The preset guard must fail *closed*: if the preset registry exists but
cannot be read/parsed (corruption, permissions), the layout-changing
upgrade must be rejected before any mutation rather than proceeding on
a false "no presets installed" assumption (which would let ``--force``
delete preset-overridden command files while their registry state is
unknown). A genuinely absent registry must still be allowed.
"""
project = _init_project(
tmp_path, "bob", integration_options="--legacy-commands"
)
commands = project / ".bob" / "commands"
skills = project / ".bob" / "skills"
assert sorted(commands.glob("speckit.*.md"))
# Corrupted (unparseable) registry: exists but cannot be read as JSON.
presets_dir = project / ".specify" / "presets"
presets_dir.mkdir(parents=True, exist_ok=True)
(presets_dir / ".registry").write_text("{ not valid json", encoding="utf-8")
result = _run_in_project(project, [
"integration", "upgrade", "bob",
"--integration-options", "--skills",
"--script", "sh", "--force",
])
assert result.exit_code != 0, (
"layout change must be rejected when preset registry is unreadable"
)
assert "preset registry" in result.output.lower()
assert not skills.exists(), "no skills layout may be scaffolded on rejection"
assert sorted(commands.glob("speckit.*.md")), (
"legacy command files must be untouched when failing closed"
)
# A valid, empty registry must NOT block the migration.
(presets_dir / ".registry").write_text(
json.dumps({"presets": {}}), encoding="utf-8"
)
result = _run_in_project(project, [
"integration", "upgrade", "bob",
"--integration-options", "--skills",
"--script", "sh", "--force",
])
assert result.exit_code == 0, (
f"valid empty preset registry must not block migration: {result.output}"
)
assert skills.exists(), "skills layout should be scaffolded once unblocked"
def test_upgrade_secondary_bob_layout_change_preserves_active_agent_skills(
self, tmp_path
):
"""Regression (review #3415, 4726347306).
``integration upgrade`` supports upgrading a *secondary* (non-active)
integration. The layout-change extension reconciliation must NOT run
for a secondary agent: ``unregister_agent_artifacts`` treats the
unscoped per-extension ``registered_skills`` as belonging to the passed
agent and, if that agent's skills dir is absent, scans every agent's
skills dir — which could delete/untrack the *active* agent's extension
skills. The following re-registration cannot repair that because
extension skill rendering is active-agent-scoped (#2948).
"""
# Active agent: copilot in skills mode → git extension renders as skills.
project = _init_project(tmp_path, "copilot", integration_options="--skills")
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
skill = project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md"
assert skill.exists(), "precondition: active copilot has the git extension skill"
registry_path = project / ".specify" / "extensions" / ".registry"
def _git_skills():
data = json.loads(registry_path.read_text(encoding="utf-8"))
return data["extensions"]["git"].get("registered_skills", [])
assert _git_skills(), "precondition: git skills registered for active copilot"
# Add a secondary (non-active) Bob in the legacy commands layout.
result = _run_in_project(project, [
"integration", "install", "bob",
"--integration-options", "--legacy-commands",
"--script", "sh", "--force",
])
assert result.exit_code == 0, result.output
# Flip the *secondary* Bob's layout to skills. copilot stays active.
result = _run_in_project(project, [
"integration", "upgrade", "bob",
"--integration-options", "--skills",
"--script", "sh", "--force",
])
assert result.exit_code == 0, result.output
# The active agent's extension skill must be untouched on disk and in
# the registry — the secondary layout change must not reconcile it.
assert skill.exists(), (
"secondary Bob layout change must not delete the active agent's "
"extension skill"
)
assert _git_skills(), (
"secondary Bob layout change must not untrack the active agent's "
"extension skills in the registry"
)
def test_upgrade_preserves_existing_vscode_settings(self, tmp_path):
"""Regression: copilot upgrade must not stale-delete .vscode/settings.json.
@@ -2616,6 +2926,82 @@ class TestIntegrationUpgrade:
"deleted extension skill (#2886)"
)
def test_installed_presets_affecting_agent_absent_vs_unreadable(self, tmp_path):
"""Unit (review #3415, 4744636079): fail closed only when unreadable.
The preset guard helper must return an empty list for a genuinely
absent registry, but raise ``_PresetRegistryUnreadableError`` when the
registry exists yet cannot be read/parsed — so a layout-changing
upgrade never proceeds on a false "no presets" result.
"""
from specify_cli.integrations._migrate_commands import (
_PresetRegistryUnreadableError,
_installed_presets_affecting_agent,
)
project = tmp_path / "proj"
project.mkdir()
# Genuinely absent registry → empty list (safe to proceed).
assert _installed_presets_affecting_agent(project, "bob") == []
presets_dir = project / ".specify" / "presets"
presets_dir.mkdir(parents=True)
registry = presets_dir / ".registry"
# Corrupted JSON → unreadable → raise.
registry.write_text("{ not json", encoding="utf-8")
with pytest.raises(_PresetRegistryUnreadableError):
_installed_presets_affecting_agent(project, "bob")
# Malformed structure (presets not a dict) → unreadable → raise.
registry.write_text(json.dumps({"presets": []}), encoding="utf-8")
with pytest.raises(_PresetRegistryUnreadableError):
_installed_presets_affecting_agent(project, "bob")
# Malformed per-preset entry (not a dict) → ownership unknown → raise.
registry.write_text(
json.dumps({"presets": {"p1": []}}), encoding="utf-8"
)
with pytest.raises(_PresetRegistryUnreadableError):
_installed_presets_affecting_agent(project, "bob")
# Malformed registered_commands (not a dict) → raise.
registry.write_text(
json.dumps({"presets": {"p1": {"registered_commands": []}}}),
encoding="utf-8",
)
with pytest.raises(_PresetRegistryUnreadableError):
_installed_presets_affecting_agent(project, "bob")
# Malformed registered_skills (not a list) → raise.
registry.write_text(
json.dumps({"presets": {"p1": {"registered_skills": {}}}}),
encoding="utf-8",
)
with pytest.raises(_PresetRegistryUnreadableError):
_installed_presets_affecting_agent(project, "bob")
# Valid, empty registry → empty list.
registry.write_text(json.dumps({"presets": {}}), encoding="utf-8")
assert _installed_presets_affecting_agent(project, "bob") == []
# Valid registry with a preset registered for bob → reported.
registry.write_text(
json.dumps({
"presets": {
"p1": {"registered_commands": {"bob": ["speckit.plan"]}},
"p2": {"registered_commands": {"codex": ["speckit.plan"]}},
"p3": {"registered_skills": ["speckit-x"]},
}
}),
encoding="utf-8",
)
assert sorted(_installed_presets_affecting_agent(project, "bob")) == [
"p1",
"p3",
]
# ── Full lifecycle ───────────────────────────────────────────────────
@@ -2733,6 +3119,30 @@ class TestParseIntegrationOptionsEqualsForm:
assert excinfo.value.exit_code == 1
assert "Error: Could not parse integration options: No closing quotation." in capsys.readouterr().out
def test_bad_option_token_with_rich_markup_exits_cleanly(self):
"""A bad option token carrying Rich markup must exit cleanly, not crash.
The token is user-controlled and gets interpolated into console.print.
A value like '[/red]foo' parses fine through shlex but is an unexpected
value / unknown option — and an unbalanced Rich tag would raise
rich.errors.MarkupError inside console.print, leaking a traceback
instead of the intended typer.Exit(1). The token must be escaped."""
import typer
from specify_cli.integrations._commands import _parse_integration_options
from specify_cli.integrations import get_integration
integration = get_integration("generic")
assert integration is not None
# Unexpected value token carrying markup.
with pytest.raises(typer.Exit):
_parse_integration_options(integration, "[/red]foo")
# Unknown option token carrying markup.
with pytest.raises(typer.Exit):
_parse_integration_options(integration, "--[/red]bad")
class TestUninstallNoManifestClearsInitOptions:
def test_init_options_cleared_on_no_manifest_uninstall(self, tmp_path):

View File

@@ -220,6 +220,28 @@ class TestManifestUninstall:
m.uninstall()
assert not m.manifest_path.exists()
def test_remove_manifest_false_preserves_manifest_file(self, tmp_path):
"""Regression (review #3415, 4724160183): a partial cleanup must not
delete ``{key}.manifest.json``.
The upgrade stale-file pass builds a throwaway manifest sharing the
integration's key over a subset of files and uninstalls it. With
``remove_manifest=False`` the tracked files are still removed but the
real, freshly-saved manifest for that key survives — otherwise a
layout-shrinking upgrade (e.g. Bob migrating legacy commands → skills)
would leave the integration untracked and un-upgradeable.
"""
m = IntegrationManifest("test", tmp_path, version="1.0")
m.record_file("f.txt", "content")
m.save()
assert m.manifest_path.exists()
removed, skipped = m.uninstall(remove_manifest=False)
assert len(removed) == 1
assert not (tmp_path / "f.txt").exists()
assert m.manifest_path.exists(), (
"remove_manifest=False must keep the manifest file on disk"
)
def test_cleans_empty_parent_dirs(self, tmp_path):
m = IntegrationManifest("test", tmp_path)
m.record_file("a/b/c/f.txt", "content")

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