* fix(integrations): declare PiIntegration multi_install_safe
PiIntegration writes only to its isolated, static root .pi/prompts,
disjoint from every other integration, yet never declared
multi_install_safe — so it inherited the IntegrationBase default False,
leaving `specify integration status` in a permanent unsafe-multi-install
ERROR state when pi is co-installed alongside another agent.
Add `multi_install_safe = True`, mirroring the isolated MarkdownIntegration
cohort (qwen, shai, qodercli) and the kiro-cli #3471 fix. The parametrized
registry isolation contracts auto-include pi and pass.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(integrations): list pi in the multi-install-safe reference table
Declaring PiIntegration multi_install_safe means the reference table in
docs/reference/integrations.md (which states it lists all currently
declared multi-install-safe integrations) should include it. Add the
alphabetized pi row with its .pi/prompts isolation path.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
run_command() enforces a list[str] argv contract, so a shell parameter
served no purpose beyond keeping an unnecessary shell-injection surface
that a future refactor could re-enable. Remove the parameter (and its
now-dead ValueError guard) so shell=False is the only possible behavior.
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 74a1bd02-f6cd-412a-b5a8-a7767a5e058d
* fix(bundler): InstallResult.changed counts uninstalled as a change
The `changed` property only considered `installed` and `refreshed`, omitting
`uninstalled`. A `bundle update` whose new manifest drops components (removing
them via the refresh path) with no new install/refresh produces
installed=[], refreshed=[], uninstalled=[dropped set] — yet `changed` returned
False, misreporting a mutating update as a no-op.
Include `uninstalled` in the disjunction (it is the third mutating outcome
list on the same dataclass, also the sole output of the remove_bundle path).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci: pin ruff to 0.15.0 to avoid 0.16.0 default-ruleset breakage
ruff 0.16.0 expanded its default rule set from ~59 to ~413 rules,
causing the unpinned `uvx ruff check` step to report ~1475 pre-existing
violations unrelated to this change. Pin to 0.15.0 to restore green lint.
Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
`workflow info` rendered each step as `→ <id> [<type>]`, but console.print
has Rich markup enabled, so `[<type>]` was parsed as a style tag named after
the step type (command/gate/prompt/…) and silently swallowed — every step
printed as `→ <id> ` with the type gone.
Escape the literal bracket with `\[` (and escape id/type via _escape_markup,
as the sibling workflow_list does), so Rich renders `[<type>]` literally.
Mirrors the in-file `\[disabled]` precedent.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_apply_filter parsed a name(arg) filter with an UNANCHORED regex
(re.match(r"(\w+)\((.+)\)")), so any tokens after the closing paren were
silently discarded. Because _evaluate_simple_expression splits the top-level
pipe before comparison/boolean operators, `count | default(0) > 5` was split
into value `count` and filter segment `default(0) > 5`; the segment matched
as `default(0)` and `> 5` vanished — the filter's value was returned as the
whole expression, giving a silently wrong result.
Use re.fullmatch so a mis-wired segment falls through to the existing
"unsupported form" ValueError, mirroring the from_json branch's strict
trailing-token handling. The greedy `.+` still matches legitimate forms
(literal `)` / `|` inside quoted args), so registered/chained/quoted-pipe
filters are unaffected.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(extensions): parse SKILL.md on the --- delimiter line during removal
ExtensionManager._unregister_extension_skills verified an installed skill
before deleting it by reading metadata.source back from its SKILL.md with a
raw split("---", 2). That substring split stops at the first "---" anywhere
after the opening delimiter, including one embedded in a command description
(e.g. "Separate sections with --- markers"). The frontmatter was then
truncated mid-value, metadata.source parsed empty, the skill looked
unrelated, and its directory was left orphaned on uninstall.
Parse on the "---" delimiter *line* instead, reusing CommandRegistrar.
parse_frontmatter (the line-anchored parser from #3590) in both the fast
(registry-driven) and fallback (directory-scan) removal paths.
Add a regression test that installs an extension whose command description
contains "---", removes it, and asserts the skill directory is gone. Fails
before the fix (dir orphaned), passes after.
* test: cover the fallback scan branch for the --- SKILL.md parse
Copilot noted the new regression test only exercised the fast removal
path (skills_project keeps ai_skills enabled, so remove() resolves the
skills dir directly). Add test_skills_removed_with_dashes_via_fallback_scan,
which deletes init-options.json after install so _get_skills_dir() returns
None and removal takes the fallback directory-scan branch. That branch
re-reads metadata.source with an independently duplicated parser; reverting
it to the old substring split now fails this test (dir orphaned) while the
fast-path test still passes.
* fix(cli): guard lazy .hostname ValueError in extension/preset add --from
`extension add --from <url>` and `preset add --from <url>` validated the URL
by reading `parsed.hostname` OUTSIDE their `try/except ValueError` guards. A
bracketed-but-invalid IPv6 authority (e.g. "https://[not-an-ip]/x.zip") parses
cleanly under urlparse() on Python < 3.14 and only raises ValueError lazily on
the first .hostname access. On the interpreters spec-kit supports (>=3.11) that
raw ValueError leaked past the CLI, printing an uncaught traceback instead of
the clean "Invalid URL" error. (The raise moved eager into urlparse() only in
3.14.) Same bug class as the catalog/download fixes #3433/#3435/#3437/#3577.
- extensions/_commands.py: read parsed.hostname inside the existing try and
reuse it for the localhost check.
- presets/_commands.py: guard the up-front `urlparse(from_url).hostname` read
(preserves the "Invalid URL" message), and harden the nested
`_is_allowed_download_url` to take a URL string and parse+read .hostname
inside its own try/except -> returns False on malformed input. This also
covers the redirect-validator and final-URL (post-redirect) checks, where the
URL is server-controlled.
Regression tests for each command: a bracketed-non-IP URL, plus a monkeypatched
lazy-.hostname raiser that reproduces the pre-3.14 shape independently of the
running interpreter (fails with a raw ValueError before the fix, verified via
test-the-test).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* fix(cli): address Copilot review on --from URL guard comments/tests
Copilot's review on #3651 flagged two accuracy problems:
1. The guard comments asserted a specific (and incorrect) CPython version
history -- that "https://[not-an-ip]/..." parses cleanly under urlparse()
on Python < 3.14 and only raises ValueError lazily on the first .hostname
access. In fact the eager bracketed-host check (gh-103848, CVE-2024-11168)
was backported to the 3.11 branch and shipped in 3.11.4, so on every
interpreter spec-kit supports (>=3.11) that URL is rejected eagerly at
urlparse(). Reworded the three source comments to state the guard as a
defensive policy (parsing OR the .hostname read can raise ValueError, guard
both) without asserting version history.
2. The two monkeypatched lazy-.hostname tests were described as reproducing
"the exact production path" / "the Python < 3.14 shape". They are synthetic
defensive cases. Relabeled them as synthetic defensive coverage that does
not reproduce any specific CPython behavior, and dropped the version-history
claims from the bracketed-non-IP test docstrings.
The second-round suggestion (_is_allowed_download_url(final_url) instead of
_is_allowed_download_url(_urlparse(final_url))) was already applied in the
original commit.
Behavior unchanged; comments/docstrings only. URL-guard tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* fix(bundler): reject a top-level non-mapping bundle-catalogs.yml in _merge_config
_merge_config silently ignored a top-level non-mapping document (a YAML list
or scalar) — `data.get("catalogs") if isinstance(data, dict) else None` made
it fall through to the built-in default stack — while the sibling reader of
the SAME file (commands_impl/catalog_config._read) raises "expected a mapping
at the top level". #3623 already made the inner non-list `catalogs` value
agree between the two readers; this closes the remaining top-level-shape gap
so both readers reject the same malformed documents.
An empty file (load_yaml coerces to {}), absent `catalogs`, and `catalogs: []`
all remain no-ops.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(bundler): reject FALSY non-mapping catalog configs (parse raw, not load_yaml)
Address review (Copilot on #3659): the top-level guard used the shared
load_yaml, whose `yaml.safe_load(...) or {}` coerces a FALSY top-level
document ([], false, 0, '') to {} BEFORE the isinstance check — so those
malformed configs silently fell back to the built-in defaults instead of
raising. Only truthy non-mappings ([a,b], 42) were caught.
Parse the raw document in both readers of bundle-catalogs.yml
(models/catalog._merge_config AND commands_impl/catalog_config._read):
an empty document (None) stays a no-op, but every non-mapping top level —
falsy or truthy — now raises "expected a mapping at the top level". This
keeps the two readers genuinely consistent. Tests cover the falsy cases for
both.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(bundler): correct load_yaml so only empty documents become {} (not falsy non-mappings)
Address review (Copilot re-review of #3659): the previous fix duplicated
YAML parsing + exception wrapping inline in two readers, bypassing the
centralized yamlio helper. Instead, correct the root cause in load_yaml.
load_yaml did `yaml.safe_load(...) or {}`, which coerced ANY falsy result
(None empty-doc, but also [], false, 0, '') to {} — contradicting its own
docstring ("{} for an empty document") and hiding malformed non-mapping
configs from callers' shape guards. Change to `{} if data is None else data`
so only an empty document becomes {}; a non-mapping top level is returned
as-parsed.
Revert the inline raw-parse in models/catalog._merge_config and
commands_impl/catalog_config._read back to the centralized load_yaml; their
existing `isinstance(data, dict)` guards now correctly reject falsy
non-mappings too. All three load_yaml callers (these two + manifest.from_dict)
already guard the top-level shape, so none regresses. Falsy-case tests for
both readers retained.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(bundler): distinguish an empty YAML document from an explicit null in load_yaml
Address review (Copilot on #3659): yaml.safe_load returns None for BOTH an
empty document AND an explicit null scalar (`null`/`~`), so mapping None to {}
still let a top-level null bundle-catalogs.yml fall back to defaults instead of
being rejected by the mapping guard.
Use yaml.compose (which yields a node only for a non-empty document) to tell
the two apart: a truly empty document becomes {}, while an explicit null is
returned as None so the callers' isinstance(dict) guard rejects it like any
other non-mapping. Drop the now-incorrect `if data is None: return []`
short-circuit in catalog_config._read so an explicit null reaches that guard.
Tests cover null/~ for both readers plus empty/comment-only no-op.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(integrations): declare OmpIntegration multi_install_safe
OmpIntegration is a plain MarkdownIntegration whose files live only under
its isolated, static root .omp/commands/, disjoint from every other
integration. But it never declared multi_install_safe, so it inherited the
IntegrationBase default False — leaving `specify integration status` in a
permanent unsafe-multi-install ERROR state whenever omp is co-installed
alongside another agent, with no acknowledgment path.
Add `multi_install_safe = True`, mirroring the isolated MarkdownIntegration
cohort (qwen, shai, qodercli, junie, kilocode) and the kiro-cli #3471 fix.
The parametrized registry isolation contracts auto-include omp once the flag
is set and pass (.omp/commands is isolated and its manifest disjoint).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(integrations): list omp in the multi-install-safe reference table
Declaring OmpIntegration multi_install_safe means the reference table in
docs/reference/integrations.md (which states it lists all currently
declared multi-install-safe integrations) should include it. Add the
alphabetized omp row with its .omp/commands isolation path.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After `specify extension add`, the "Provided commands" summary hyphenated
command names only for Cline. For a Forge project the names were printed in
dotted form (e.g. `speckit.test-ext.hello`), but Forge registers them
hyphenated (`speckit-test-ext-hello`), so the printed names didn't match
what the user actually invokes in Forge.
Extend the existing Cline handling to Forge via `format_forge_command_name`,
completing the Forge command-name parity already fixed for hook invocations
(#3641) and the init next-steps panel (#3642).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CatalogEntry.from_dict used `data.get("requires") or {}` and
`data.get("provides") or {}`, so a FALSY non-mapping ([], '', 0, false) was
coerced to {} before the isinstance guard — a corrupt catalog entry passed
silently. Only a truthy non-mapping was rejected.
Handle None explicitly and reject every other non-mapping, mirroring the
merged manifest requires/provides/integration guards (#3629, #3661).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
load_records and InstalledBundleRecord.from_dict defaulted their list fields
with `data.get(...) or []` BEFORE the isinstance(list) guard, so a FALSY
non-list value (0, '', False, {}) was coerced to [] and the guard became dead
code — a corrupt .specify/bundle-records.json was silently read as "no
bundles"/"no components" instead of raising. Only an absent/None value should
mean empty.
Handle None explicitly and reject every other non-list, mirroring the merged
requires/provides/integration guards (#3629, #3661) and the catalog_config
sibling reader.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
with_integration_setting recomputed invoke_separator from the raw
parsed_options argument. When only script_type changes (parsed_options and
raw_options both None), the previously-stored parsed_options are retained on
the setting, but the separator was derived from the None argument — dropping
an options-dependent separator (e.g. Copilot --skills -> "-") back to the
default ".", desynchronizing invoke_separator from the stored options.
Derive the separator from current.get("parsed_options") — the options
actually stored after the update — so it stays consistent in every branch.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_traverse_and_apply's insert_after loop iterated reversed(edits) over the
flat per-anchor edit list. The reversal is only meant to place a
higher-priority OVERLAY closer to the anchor (mirroring insert_before's
winner-closest behaviour), but reversing the flat list also flipped the
declared order of multiple insert_after edits authored within a SINGLE
overlay: [insert_after a->x, insert_after a->y] produced [a, y, x, b]
instead of [a, x, y, b]. insert_before (a forward loop) already preserves
order, so the two operations were asymmetric.
Group contiguous same-layer edits and reverse the GROUP order only, keeping
each overlay's own inserts in declared order. Cross-overlay priority is
unchanged (higher-priority overlay still lands closest to the anchor).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(bundler): reject falsy non-mapping requires/provides in manifest from_dict
BundleManifest.from_dict used `data.get("requires") or {}` and
`data.get("provides") or {}`, so a FALSY non-mapping value ([], '', 0,
false) was coerced to {} BEFORE the isinstance guard — a malformed manifest
passed validation as one that requires/provides nothing. Only a truthy
non-mapping (e.g. "extensions") was rejected.
Handle None explicitly (default to {}) and reject every other non-mapping,
matching the sibling 'integration' guard added in #3629. Absent fields still
parse to the empty default.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(bundler): correct absent-optional-mapping regression assertion
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dump_yaml called yaml.safe_dump without allow_unicode=True, so non-ASCII
content was written as \xNN / \uXXXX escapes instead of literal UTF-8 — a
round-trip readability loss for bundle config. The centralized helper
_utils.dump_frontmatter and the extensions/presets config writers all pass
allow_unicode=True; align dump_yaml with them.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* tidy kiro-cli multi-install-safe declaration + add docs row and test
kiro-cli is declared multi_install_safe (it uses a fully isolated .kiro/
root, .kiro/prompts command dir, a stable '.' separator, and a dedicated
manifest — #3471). main ended up with the flag assigned twice in the class
body; this drops the bare duplicate and keeps a single declaration with the
comment explaining why it's safe.
also adds the missing kiro-cli row to the multi-install-safe table in
docs/reference/integrations.md, and a test asserting the flag is set (the
registry contract tests already enforce the actual path isolation against
every other safe integration).
* address review: drop agent-file column and duplicate test
per maintainer guidance on #3477: the Isolation table listed each
integration's agent-context file (AGENTS.md, CLAUDE.md, etc.) alongside
its command dir, which contradicted the safety definition (Copilot flagged
kiro-cli/codex both mapping to AGENTS.md). those context files are owned by
the optional agent-context extension, not by multi_install_safe — the flag
governs only the command directory + manifest.
- renamed the column to 'Command directory' and removed the agent-file
entries, so it lists only what each integration actually manages
- reworded the definition to note agent-context is a separate concern and
is not multi-install safe
- removed the duplicate test_declared_multi_install_safe (the existing
test_declares_multi_install_safe already asserts the same thing)
* address review: keep agent-root requirement; reframe agent-context targeting
- Restore 'static, unique agent root' alongside command directory in the
multi-install-safe definition — base.py and test_registry.py
(test_safe_integrations_have_distinct_agent_roots) enforce both.
- Reframe the agent-context note: multi_install_safe is an integration-level
declaration about command/skill paths, so describe context-file targeting
as independent of it rather than calling the extension 'not multi-install
safe'. The extension can even sync multiple anchors via context_files.
* address review: hoist multi_install_safe to top of class
Move the multi_install_safe = True declaration to the top of
KiroCliIntegration (right after key) so it is visible at the exact spot
the diff touched. The flag was never actually removed — it was declared
once further down the class — but placing it at the top makes the opt-in
unmistakable in review and keeps the single declaration. Verified the
integration still resolves multi_install_safe is True; 24 kiro-cli tests
pass.
_collect_files returned sorted(collected), i.e. pathlib.Path order, which is
platform-dependent: on Windows PurePath compares case-folded with backslash
separators, whereas the zip member NAMES are the canonical POSIX arcnames
(build_bundle: file_path.relative_to(bundle_dir).as_posix()). So the same
bundle built on Windows vs Linux/macOS produced archives whose members were
laid out in different order — not byte-for-byte identical across build hosts,
contradicting the packager's reproducible-build guarantee (fixed timestamps +
canonical modes).
Order by the same canonical POSIX-arcname key used to name members, so member
order is host-independent.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ClineIntegration defined its command-content transform as
post_process_content, but the overridable base hook is
IntegrationBase.post_process_command_content, which
CommandRegistrar.register_commands() dispatches to for every non-skills
integration. Because the names differed, Cline's method never overrode the
base hook, so extension/preset command files registered for Cline silently
ran the base no-op and never received Cline's dot-to-hyphen hook-command
note (_inject_hook_command_note) — the note that tells the agent to replace
dots with hyphens when invoking hook commands. (Handoff references are
already hyphenated independently by the registrar's _hyphenate_body_refs,
so that transform was unaffected; renaming simply makes Cline's own copy
run too, harmlessly, since both are idempotent.)
Rename to post_process_command_content (matching the base hook and the
post_process_skill_content convention used by claude/copilot/agy/kimi/
droid/vibe) and update the single internal caller in setup(). Cline's own
setup() post-processing of core commands is unchanged. No test referenced
the old name.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The GateStep docstring said on_reject "controls abort / skip behaviour",
omitting the third value. validate() accepts 'abort', 'skip', or 'retry',
and execute() has a dedicated retry branch (returns PAUSED so the next
resume re-runs the gate) distinct from abort (FAILED) and skip (COMPLETED).
Add 'retry' to the docstring so it matches the same file's validate() and
execute() authority.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The InitStep `script` field docstring claimed only 'sh' or 'ps', but the
step's own VALID_SCRIPT_TYPES = tuple(SCRIPT_TYPE_CHOICES.keys()) is
('sh', 'ps', 'py') and validate() accepts all three (its error message is
built from VALID_SCRIPT_TYPES). Update the docstring to list 'py' too, so
it no longer contradicts the same class's validate() authority.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
LingmaIntegration writes only to its isolated, static root .lingma/skills,
disjoint from every other integration, yet never declared
multi_install_safe — inheriting the IntegrationBase default False and
leaving `specify integration status` in a permanent unsafe-multi-install
ERROR state when lingma is co-installed alongside another agent.
Add `multi_install_safe = True`, mirroring the structurally-identical
trae/zcode SkillsIntegrations and the kiro-cli #3471 fix. The parametrized
registry isolation contracts auto-include lingma and pass.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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>
* 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>
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>
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>
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>
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>
`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>
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>
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>
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>
_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>
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>
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>
* 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.
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>
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.
* 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>
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>
`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>
`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>
* 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>
* 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>
* 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>
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.
* 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>